From 776b0e23ae078caa61b7556f1c76c7d80d4d2857 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 29 Oct 2018 15:14:31 -0700 Subject: [PATCH] Add support for typing indicators. --- build.gradle | 1 + res/layout/conversation_fragment.xml | 2 +- res/layout/conversation_item_received.xml | 4 +- res/layout/conversation_list_item_view.xml | 43 ++-- res/layout/conversation_typing_view.xml | 36 ++++ ...nce_upgrade_typing_indicators_fragment.xml | 77 ++++++++ res/layout/typing_indicator_view.xml | 40 ++++ res/values/attrs.xml | 5 + res/values/dimens.xml | 2 + res/values/strings.xml | 7 + res/values/themes.xml | 2 + res/xml/preferences_app_protection.xml | 6 + .../securesms/ApplicationContext.java | 22 +++ .../BindableConversationListItem.java | 1 + .../securesms/ConversationActivity.java | 46 ++++- .../securesms/ConversationFragment.java | 105 +++++++++- .../securesms/ConversationListAdapter.java | 14 +- .../securesms/ConversationListFragment.java | 14 ++ .../securesms/ConversationListItem.java | 60 ++++-- .../securesms/ConversationListItemAction.java | 8 +- .../ConversationListItemInboxZero.java | 8 +- .../securesms/ExperienceUpgradeActivity.java | 11 +- .../securesms/PassphraseCreateActivity.java | 1 + .../securesms/ReadReceiptsIntroFragment.java | 1 + .../TypingIndicatorIntroFragment.java | 52 +++++ .../components/ConversationTypingView.java | 54 +++++ .../components/TypingIndicatorView.java | 115 +++++++++++ .../components/TypingStatusRepository.java | 184 ++++++++++++++++++ .../components/TypingStatusSender.java | 117 +++++++++++ .../SmoothScrollingLinearLayoutManager.java | 31 +++ .../database/CursorRecyclerViewAdapter.java | 4 + .../SignalCommunicationModule.java | 4 +- .../MultiDeviceConfigurationUpdateJob.java | 11 +- .../securesms/jobs/PushDecryptJob.java | 52 +++++ .../securesms/jobs/TypingSendJob.java | 108 ++++++++++ .../AppProtectionPreferenceFragment.java | 22 +++ .../securesms/search/SearchListAdapter.java | 2 +- .../securesms/util/TextSecurePreferences.java | 10 + src/org/thoughtcrime/securesms/util/Util.java | 9 + 39 files changed, 1231 insertions(+), 60 deletions(-) create mode 100644 res/layout/conversation_typing_view.xml create mode 100644 res/layout/experience_upgrade_typing_indicators_fragment.xml create mode 100644 res/layout/typing_indicator_view.xml create mode 100644 src/org/thoughtcrime/securesms/TypingIndicatorIntroFragment.java create mode 100644 src/org/thoughtcrime/securesms/components/ConversationTypingView.java create mode 100644 src/org/thoughtcrime/securesms/components/TypingIndicatorView.java create mode 100644 src/org/thoughtcrime/securesms/components/TypingStatusRepository.java create mode 100644 src/org/thoughtcrime/securesms/components/TypingStatusSender.java create mode 100644 src/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java create mode 100644 src/org/thoughtcrime/securesms/jobs/TypingSendJob.java diff --git a/build.gradle b/build.gradle index d9e355b023..6fda390e50 100644 --- a/build.gradle +++ b/build.gradle @@ -149,6 +149,7 @@ dependencies { } } +// TODO: Update dependencies after getting final libsignal dependencyVerification { verify = [ 'com.android.support:design:7874ad1904eedc74aa41cffffb7f759d8990056f3bbbc9264911651c67c42f5f', diff --git a/res/layout/conversation_fragment.xml b/res/layout/conversation_fragment.xml index 1dd360c9fb..354dbebe40 100644 --- a/res/layout/conversation_fragment.xml +++ b/res/layout/conversation_fragment.xml @@ -9,7 +9,7 @@ android:id="@android:id/list" android:layout_width="match_parent" android:layout_height="match_parent" - android:paddingBottom="16dp" + android:paddingBottom="2dp" android:scrollbars="vertical" android:cacheColorHint="?conversation_background" android:clipChildren="false" diff --git a/res/layout/conversation_item_received.xml b/res/layout/conversation_item_received.xml index 2cf0cd763b..f713115ae5 100644 --- a/res/layout/conversation_item_received.xml +++ b/res/layout/conversation_item_received.xml @@ -36,8 +36,8 @@ diff --git a/res/layout/conversation_list_item_view.xml b/res/layout/conversation_list_item_view.xml index d67e77e8f1..f27bb644d1 100644 --- a/res/layout/conversation_list_item_view.xml +++ b/res/layout/conversation_list_item_view.xml @@ -88,21 +88,36 @@ android:visibility="gone" tools:visibility="visible"/> - + + + + + android:layout_marginTop="4dp" + android:visibility="gone" + app:typingIndicator_tint="?conversation_list_typing_tint"/> + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/experience_upgrade_typing_indicators_fragment.xml b/res/layout/experience_upgrade_typing_indicators_fragment.xml new file mode 100644 index 0000000000..67808fa410 --- /dev/null +++ b/res/layout/experience_upgrade_typing_indicators_fragment.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/typing_indicator_view.xml b/res/layout/typing_indicator_view.xml new file mode 100644 index 0000000000..23a4cf19ef --- /dev/null +++ b/res/layout/typing_indicator_view.xml @@ -0,0 +1,40 @@ + + + + + + + + + + \ No newline at end of file diff --git a/res/values/attrs.xml b/res/values/attrs.xml index 882491504e..3b4d3ea751 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -15,6 +15,7 @@ + @@ -295,4 +296,8 @@ + + + + diff --git a/res/values/dimens.xml b/res/values/dimens.xml index 128637382f..310c769aec 100644 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -42,6 +42,8 @@ 8dp 1dp + 36dp + 10dp 4dp 18dp diff --git a/res/values/strings.xml b/res/values/strings.xml index f2aa4acb34..087e22fb32 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -314,6 +314,11 @@ Now you can share a profile photo and name with friends on Signal Signal profiles are here + Introducing typing indicators. + Now you can optionally see and share when messages are being typed + Typing indicators are here + Enable typing indicators + Retrieving a message... @@ -1182,6 +1187,8 @@ Incognito keyboard Read receipts If read receipts are disabled, you won\'t be able to see read receipts from others. + Typing indicators + If typing indicators are disabled, you won\'t be able to see typing indicators from others. Request keyboard to disable personalized learning Blocked contacts When using mobile data diff --git a/res/values/themes.xml b/res/values/themes.xml index a625d8cf2a..b17c51bd5d 100644 --- a/res/values/themes.xml +++ b/res/values/themes.xml @@ -140,6 +140,7 @@ @drawable/unread_count_background_light @drawable/conversation_list_divider_shape @color/core_blue + @color/core_grey_60 @color/textsecure_primary @drawable/divet_lower_right_dark @@ -290,6 +291,7 @@ @drawable/unread_count_background_dark @drawable/conversation_list_divider_shape_dark @color/core_grey_90 + @color/core_white #99ffffff diff --git a/res/xml/preferences_app_protection.xml b/res/xml/preferences_app_protection.xml index ce193d443e..74d5d10bfa 100644 --- a/res/xml/preferences_app_protection.xml +++ b/res/xml/preferences_app_protection.xml @@ -63,6 +63,12 @@ android:title="@string/preferences__read_receipts" android:summary="@string/preferences__if_read_receipts_are_disabled_you_wont_be_able_to_see_read_receipts"/> + + diff --git a/src/org/thoughtcrime/securesms/ApplicationContext.java b/src/org/thoughtcrime/securesms/ApplicationContext.java index 24f1198f25..2062724d58 100644 --- a/src/org/thoughtcrime/securesms/ApplicationContext.java +++ b/src/org/thoughtcrime/securesms/ApplicationContext.java @@ -28,6 +28,8 @@ import android.support.multidex.MultiDexApplication; import com.google.android.gms.security.ProviderInstaller; +import org.thoughtcrime.securesms.components.TypingStatusRepository; +import org.thoughtcrime.securesms.components.TypingStatusSender; import org.thoughtcrime.securesms.crypto.PRNGFixes; import org.thoughtcrime.securesms.dependencies.AxolotlStorageModule; import org.thoughtcrime.securesms.dependencies.InjectableType; @@ -82,6 +84,8 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc private static final String TAG = ApplicationContext.class.getSimpleName(); private ExpiringMessageManager expiringMessageManager; + private TypingStatusRepository typingStatusRepository; + private TypingStatusSender typingStatusSender; private JobManager jobManager; private IncomingMessageObserver incomingMessageObserver; private ObjectGraph objectGraph; @@ -104,6 +108,8 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc initializeJobManager(); initializeMessageRetrieval(); initializeExpiringMessageManager(); + initializeTypingStatusRepository(); + initializeTypingStatusSender(); initializeGcmCheck(); initializeSignedPreKeyCheck(); initializePeriodicTasks(); @@ -145,6 +151,14 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc return expiringMessageManager; } + public TypingStatusRepository getTypingStatusRepository() { + return typingStatusRepository; + } + + public TypingStatusSender getTypingStatusSender() { + return typingStatusSender; + } + public boolean isAppVisible() { return isAppVisible; } @@ -206,6 +220,14 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc this.expiringMessageManager = new ExpiringMessageManager(this); } + private void initializeTypingStatusRepository() { + this.typingStatusRepository = new TypingStatusRepository(); + } + + private void initializeTypingStatusSender() { + this.typingStatusSender = new TypingStatusSender(this); + } + private void initializePeriodicTasks() { RotateSignedPreKeyListener.schedule(this); DirectoryRefreshListener.schedule(this); diff --git a/src/org/thoughtcrime/securesms/BindableConversationListItem.java b/src/org/thoughtcrime/securesms/BindableConversationListItem.java index d35460ad6e..124ec7b00c 100644 --- a/src/org/thoughtcrime/securesms/BindableConversationListItem.java +++ b/src/org/thoughtcrime/securesms/BindableConversationListItem.java @@ -13,5 +13,6 @@ public interface BindableConversationListItem extends Unbindable { public void bind(@NonNull ThreadRecord thread, @NonNull GlideRequests glideRequests, @NonNull Locale locale, + @NonNull Set typingThreads, @NonNull Set selectedThreads, boolean batchMode); } diff --git a/src/org/thoughtcrime/securesms/ConversationActivity.java b/src/org/thoughtcrime/securesms/ConversationActivity.java index ae64e2b415..61465a6009 100644 --- a/src/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/ConversationActivity.java @@ -104,6 +104,7 @@ import org.thoughtcrime.securesms.contacts.ContactAccessor.ContactData; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity; import org.thoughtcrime.securesms.contactshare.ContactUtil; +import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable; import org.thoughtcrime.securesms.crypto.SecurityEvent; import org.thoughtcrime.securesms.database.Address; @@ -253,6 +254,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity protected Stub reminderView; private Stub unverifiedBannerView; private Stub groupShareProfileView; + private TypingStatusTextWatcher typingTextWatcher; private AttachmentTypeSelector attachmentTypeSelector; private AttachmentManager attachmentManager; @@ -308,8 +310,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity initializeProfiles(); initializeDraft().addListener(new AssertedSuccessListener() { @Override - public void onSuccess(Boolean result) { - if (result != null && result) { + public void onSuccess(Boolean loadedDraft) { + if (loadedDraft != null && loadedDraft) { Log.i(TAG, "Finished loading draft"); Util.runOnMain(() -> { if (fragment != null && fragment.isResumed()) { @@ -319,6 +321,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } }); } + + if (TextSecurePreferences.isTypingIndicatorsEnabled(ConversationActivity.this)) { + composeText.addTextChangedListener(typingTextWatcher); + } } }); } @@ -337,7 +343,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity if (!Util.isEmpty(composeText) || attachmentManager.isAttachmentPresent()) { saveDraft(); attachmentManager.clear(glideRequests, false); - composeText.setText(""); + silentlySetComposeText(""); } setIntent(intent); @@ -1132,6 +1138,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity @Override protected void onPostExecute(List drafts) { + if (drafts.isEmpty()) { + future.set(false); + updateToggleButtonState(); + return; + } + AtomicInteger draftsRemaining = new AtomicInteger(drafts.size()); AtomicBoolean success = new AtomicBoolean(false); ListenableFuture.Listener listener = new AssertedSuccessListener() { @@ -1381,6 +1393,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity attachmentTypeSelector = null; attachmentManager = new AttachmentManager(this, this); audioRecorder = new AudioRecorder(this); + typingTextWatcher = new TypingStatusTextWatcher(); SendButtonListener sendButtonListener = new SendButtonListener(); ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener(); @@ -1851,6 +1864,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity if (isSecureText && !forceSms) { outgoingMessage = new OutgoingSecureMediaMessage(outgoingMessageCandidate); + ApplicationContext.getInstance(context).getTypingStatusSender().onTypingStopped(threadId); } else { outgoingMessage = outgoingMessageCandidate; } @@ -1862,7 +1876,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity .onAllGranted(() -> { inputPanel.clearQuote(); attachmentManager.clear(glideRequests, false); - composeText.setText(""); + silentlySetComposeText(""); final long id = fragment.stageOutgoingMessage(outgoingMessage); new AsyncTask() { @@ -1898,6 +1912,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity if (isSecureText && !forceSms) { message = new OutgoingEncryptedMessage(recipient, messageBody, expiresIn); + ApplicationContext.getInstance(context).getTypingStatusSender().onTypingStopped(threadId); } else { message = new OutgoingTextMessage(recipient, messageBody, expiresIn, subscriptionId); } @@ -1907,7 +1922,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity .ifNecessary(forceSms || !isSecureText) .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_sms_permission_in_order_to_send_an_sms)) .onAllGranted(() -> { - this.composeText.setText(""); + silentlySetComposeText(""); final long id = fragment.stageOutgoingMessage(message); new AsyncTask() { @@ -2107,6 +2122,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } } + private void silentlySetComposeText(String text) { + typingTextWatcher.setEnabled(false); + composeText.setText(text); + typingTextWatcher.setEnabled(true); + } // Listeners @@ -2217,6 +2237,22 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity public void onFocusChange(View v, boolean hasFocus) {} } + private class TypingStatusTextWatcher extends SimpleTextWatcher { + + private boolean enabled = true; + + @Override + public void onTextChanged(String text) { + if (enabled && threadId > 0 && isSecureText && !isSmsForced()) { + ApplicationContext.getInstance(ConversationActivity.this).getTypingStatusSender().onTypingStarted(threadId); + } + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + } + @Override public void setThreadId(long threadId) { this.threadId = threadId; diff --git a/src/org/thoughtcrime/securesms/ConversationFragment.java b/src/org/thoughtcrime/securesms/ConversationFragment.java index 13b5122154..31ea9b05a3 100644 --- a/src/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/org/thoughtcrime/securesms/ConversationFragment.java @@ -18,6 +18,7 @@ package org.thoughtcrime.securesms; import android.annotation.SuppressLint; import android.app.Activity; +import android.arch.lifecycle.Observer; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; @@ -25,6 +26,7 @@ import android.database.Cursor; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; +import android.os.Parcelable; import android.support.annotation.NonNull; import android.support.v4.app.ActivityCompat; import android.support.v4.app.ActivityOptionsCompat; @@ -35,11 +37,17 @@ import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.support.v7.view.ActionMode; import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.LinearSmoothScroller; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView.OnScrollListener; import android.text.ClipboardManager; import android.text.TextUtils; + +import org.thoughtcrime.securesms.components.ConversationTypingView; +import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager; import org.thoughtcrime.securesms.logging.Log; + +import android.util.DisplayMetrics; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -77,6 +85,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; +import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; @@ -116,6 +125,7 @@ public class ConversationFragment extends Fragment private RecyclerView.ItemDecoration lastSeenDecoration; private ViewSwitcher topLoadMoreView; private ViewSwitcher bottomLoadMoreView; + private ConversationTypingView typingView; private UnknownSenderView unknownSenderView; private View composeDivider; private View scrollToBottomButton; @@ -137,7 +147,7 @@ public class ConversationFragment extends Fragment scrollToBottomButton.setOnClickListener(v -> scrollToBottom()); - final LinearLayoutManager layoutManager = new LinearLayoutManager(getActivity(), LinearLayoutManager.VERTICAL, true); + final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true); list.setHasFixedSize(false); list.setLayoutManager(layoutManager); list.setItemAnimator(null); @@ -147,6 +157,8 @@ public class ConversationFragment extends Fragment initializeLoadMoreView(topLoadMoreView); initializeLoadMoreView(bottomLoadMoreView); + typingView = (ConversationTypingView) inflater.inflate(R.layout.conversation_typing_view, container, false); + return view; } @@ -215,6 +227,7 @@ public class ConversationFragment extends Fragment OnScrollListener scrollListener = new ConversationScrollListener(getActivity()); list.addOnScrollListener(scrollListener); + initializeTypingObserver(); } private void initializeListAdapter() { @@ -238,6 +251,67 @@ public class ConversationFragment extends Fragment }); } + private void initializeTypingObserver() { + if (!TextSecurePreferences.isTypingIndicatorsEnabled(requireContext())) { + return; + } + + ApplicationContext.getInstance(requireContext()).getTypingStatusRepository().getTypists(threadId).observe(this, typingState -> { + List recipients; + boolean replacedByIncomingMessage; + + if (typingState != null) { + recipients = typingState.getTypists(); + replacedByIncomingMessage = typingState.isReplacedByIncomingMessage(); + } else { + recipients = Collections.emptyList(); + replacedByIncomingMessage = false; + } + + typingView.setTypists(GlideApp.with(ConversationFragment.this), recipients, recipient.isGroupRecipient()); + + ConversationAdapter adapter = getListAdapter(); + + if (adapter.getHeaderView() != null && adapter.getHeaderView() != typingView) { + Log.i(TAG, "Skipping typing indicator -- the header slot is occupied."); + return; + } + + if (recipients.size() > 0) { + if (adapter.getHeaderView() == null && getListLayoutManager().findFirstCompletelyVisibleItemPosition() == 0) { + list.setVerticalScrollBarEnabled(false); + list.post(() -> getListLayoutManager().smoothScrollToPosition(requireContext(), 0, 250)); + list.postDelayed(() -> list.setVerticalScrollBarEnabled(true), 300); + adapter.setHeaderView(typingView); + adapter.notifyItemInserted(0); + } else { + if (adapter.getHeaderView() == null) { + adapter.setHeaderView(typingView); + adapter.notifyItemInserted(0); + } else { + adapter.setHeaderView(typingView); + adapter.notifyItemChanged(0); + } + } + } else { + if (getListLayoutManager().findFirstCompletelyVisibleItemPosition() == 0 && getListLayoutManager().getItemCount() > 1 && !replacedByIncomingMessage) { + getListLayoutManager().smoothScrollToPosition(requireContext(), 1, 250); + list.setVerticalScrollBarEnabled(false); + list.postDelayed(() -> { + adapter.setHeaderView(null); + adapter.notifyItemRemoved(0); + list.post(() -> list.setVerticalScrollBarEnabled(true)); + }, 200); + } else if (!replacedByIncomingMessage) { + adapter.setHeaderView(null); + adapter.notifyItemRemoved(0); + } else { + adapter.setHeaderView(null); + } + } + }); + } + private void setCorrectMenuVisibility(Menu menu) { Set messageRecords = getListAdapter().getSelectedItems(); boolean actionMessage = false; @@ -296,6 +370,10 @@ public class ConversationFragment extends Fragment return (ConversationAdapter) list.getAdapter(); } + private SmoothScrollingLinearLayoutManager getListLayoutManager() { + return (SmoothScrollingLinearLayoutManager) list.getLayoutManager(); + } + private MessageRecord getSelectedMessageRecord() { Set messageRecords = getListAdapter().getSelectedItems(); @@ -501,7 +579,7 @@ public class ConversationFragment extends Fragment if (!loader.hasSent() && !recipient.isSystemContact() && !recipient.isGroupRecipient() && recipient.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) { adapter.setHeaderView(unknownSenderView); } else { - adapter.setHeaderView(null); + clearHeaderIfNotTyping(adapter); } if (loader.hasOffset()) { @@ -513,6 +591,10 @@ public class ConversationFragment extends Fragment int lastSeenPosition = adapter.findLastSeenPosition(lastSeen); + if (adapter.getHeaderView() == typingView) { + lastSeenPosition = Math.max(lastSeenPosition - 1, 0); + } + if (firstLoad) { if (startingPosition >= 0) { scrollToStartingPosition(startingPosition); @@ -536,6 +618,12 @@ public class ConversationFragment extends Fragment } } + private void clearHeaderIfNotTyping(ConversationAdapter adapter) { + if (adapter.getHeaderView() != typingView) { + adapter.setHeaderView(null); + } + } + @Override public void onLoaderReset(Loader arg0) { if (list.getAdapter() != null) { @@ -547,7 +635,7 @@ public class ConversationFragment extends Fragment MessageRecord messageRecord = DatabaseFactory.getMmsDatabase(getContext()).readerFor(message, threadId).getCurrent(); if (getListAdapter() != null) { - getListAdapter().setHeaderView(null); + clearHeaderIfNotTyping(getListAdapter()); setLastSeen(0); getListAdapter().addFastRecord(messageRecord); } @@ -559,7 +647,7 @@ public class ConversationFragment extends Fragment MessageRecord messageRecord = DatabaseFactory.getSmsDatabase(getContext()).readerFor(message, threadId).getCurrent(); if (getListAdapter() != null) { - getListAdapter().setHeaderView(null); + clearHeaderIfNotTyping(getListAdapter()); setLastSeen(0); getListAdapter().addFastRecord(messageRecord); } @@ -648,11 +736,12 @@ public class ConversationFragment extends Fragment private boolean isAtBottom() { if (list.getChildCount() == 0) return true; - View bottomView = list.getChildAt(0); - int firstVisibleItem = ((LinearLayoutManager) list.getLayoutManager()).findFirstVisibleItemPosition(); - boolean isAtBottom = (firstVisibleItem == 0); + int firstCompletelyVisiblePosition = ((LinearLayoutManager) list.getLayoutManager()).findFirstCompletelyVisibleItemPosition(); - return isAtBottom && bottomView.getBottom() <= list.getHeight(); + if (getListAdapter().getHeaderView() == typingView) { + return firstCompletelyVisiblePosition <= 1; + } + return firstCompletelyVisiblePosition == 0; } private boolean isAtZoomScrollHeight() { diff --git a/src/org/thoughtcrime/securesms/ConversationListAdapter.java b/src/org/thoughtcrime/securesms/ConversationListAdapter.java index 1e5e284921..399272d457 100644 --- a/src/org/thoughtcrime/securesms/ConversationListAdapter.java +++ b/src/org/thoughtcrime/securesms/ConversationListAdapter.java @@ -59,6 +59,7 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter batchSet = Collections.synchronizedSet(new HashSet()); private boolean batchMode = false; + private final Set typingSet = new HashSet<>(); protected static class ViewHolder extends RecyclerView.ViewHolder { public ViewHolder(final @NonNull V itemView) @@ -78,6 +79,11 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter threadsIds) { + typingSet.clear(); + typingSet.addAll(threadsIds); + notifyDataSetChanged(); + } + private ThreadRecord getThreadRecord(@NonNull Cursor cursor) { return threadDatabase.readerFor(cursor).getCurrent(); } diff --git a/src/org/thoughtcrime/securesms/ConversationListFragment.java b/src/org/thoughtcrime/securesms/ConversationListFragment.java index c806a41e19..f818e6857f 100644 --- a/src/org/thoughtcrime/securesms/ConversationListFragment.java +++ b/src/org/thoughtcrime/securesms/ConversationListFragment.java @@ -75,15 +75,18 @@ import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.loaders.ConversationListLoader; import org.thoughtcrime.securesms.events.ReminderUpdateEvent; import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob; +import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask; import org.whispersystems.libsignal.util.guava.Optional; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -146,6 +149,7 @@ public class ConversationListFragment extends Fragment setHasOptionsMenu(true); fab.setOnClickListener(v -> startActivity(new Intent(getActivity(), NewConversationActivity.class))); initializeListAdapter(); + initializeTypingObserver(); } @Override @@ -226,6 +230,16 @@ public class ConversationListFragment extends Fragment getLoaderManager().restartLoader(0, null, this); } + private void initializeTypingObserver() { + ApplicationContext.getInstance(requireContext()).getTypingStatusRepository().getTypingThreads().observe(this, threadIds -> { + if (threadIds == null) { + threadIds = Collections.emptySet(); + } + + getListAdapter().setTypingThreads(threadIds); + }); + } + @SuppressLint("StaticFieldLeak") private void handleArchiveAllSelected() { final Set selectedConversations = new HashSet<>(getListAdapter().getBatchSelections()); diff --git a/src/org/thoughtcrime/securesms/ConversationListItem.java b/src/org/thoughtcrime/securesms/ConversationListItem.java index 12a6785968..6a1fdafe8c 100644 --- a/src/org/thoughtcrime/securesms/ConversationListItem.java +++ b/src/org/thoughtcrime/securesms/ConversationListItem.java @@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.DeliveryStatusView; import org.thoughtcrime.securesms.components.FromTextView; import org.thoughtcrime.securesms.components.ThumbnailView; +import org.thoughtcrime.securesms.components.TypingIndicatorView; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; @@ -71,18 +72,20 @@ public class ConversationListItem extends RelativeLayout private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL); private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL); - private Set selectedThreads; - private Recipient recipient; - private long threadId; - private GlideRequests glideRequests; - private TextView subjectView; - private FromTextView fromView; - private TextView dateView; - private TextView archivedView; - private DeliveryStatusView deliveryStatusIndicator; - private AlertView alertView; - private TextView unreadIndicator; - private long lastSeen; + private Set selectedThreads; + private Recipient recipient; + private long threadId; + private GlideRequests glideRequests; + private View subjectContainer; + private TextView subjectView; + private TypingIndicatorView typingView; + private FromTextView fromView; + private TextView dateView; + private TextView archivedView; + private DeliveryStatusView deliveryStatusIndicator; + private AlertView alertView; + private TextView unreadIndicator; + private long lastSeen; private int unreadCount; private AvatarImageView contactPhotoImage; @@ -101,7 +104,9 @@ public class ConversationListItem extends RelativeLayout @Override protected void onFinishInflate() { super.onFinishInflate(); + this.subjectContainer = findViewById(R.id.subject_container); this.subjectView = findViewById(R.id.subject); + this.typingView = findViewById(R.id.typing_indicator); this.fromView = findViewById(R.id.from); this.dateView = findViewById(R.id.date); this.deliveryStatusIndicator = findViewById(R.id.delivery_status); @@ -120,15 +125,17 @@ public class ConversationListItem extends RelativeLayout public void bind(@NonNull ThreadRecord thread, @NonNull GlideRequests glideRequests, @NonNull Locale locale, + @NonNull Set typingThreads, @NonNull Set selectedThreads, boolean batchMode) { - bind(thread, glideRequests, locale, selectedThreads, batchMode, null); + bind(thread, glideRequests, locale, typingThreads, selectedThreads, batchMode, null); } public void bind(@NonNull ThreadRecord thread, @NonNull GlideRequests glideRequests, @NonNull Locale locale, + @NonNull Set typingThreads, @NonNull Set selectedThreads, boolean batchMode, @Nullable String highlightSubstring) @@ -148,10 +155,21 @@ public class ConversationListItem extends RelativeLayout this.fromView.setText(recipient, unreadCount == 0); } - this.subjectView.setText(thread.getDisplayBody()); - this.subjectView.setTypeface(unreadCount == 0 ? LIGHT_TYPEFACE : BOLD_TYPEFACE); - this.subjectView.setTextColor(unreadCount == 0 ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color) - : ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color)); + if (typingThreads.contains(threadId)) { + this.subjectView.setVisibility(INVISIBLE); + + this.typingView.setVisibility(VISIBLE); + this.typingView.startAnimation(); + } else { + this.typingView.setVisibility(GONE); + this.typingView.stopAnimation(); + + this.subjectView.setVisibility(VISIBLE); + this.subjectView.setText(thread.getDisplayBody()); + this.subjectView.setTypeface(unreadCount == 0 ? LIGHT_TYPEFACE : BOLD_TYPEFACE); + this.subjectView.setTextColor(unreadCount == 0 ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color) + : ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color)); + } if (thread.getDate() > 0) { CharSequence date = DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, thread.getDate()); @@ -259,22 +277,22 @@ public class ConversationListItem extends RelativeLayout this.thumbnailView.setVisibility(View.VISIBLE); this.thumbnailView.setImageResource(glideRequests, thread.getSnippetUri()); - LayoutParams subjectParams = (RelativeLayout.LayoutParams)this.subjectView.getLayoutParams(); + LayoutParams subjectParams = (RelativeLayout.LayoutParams)this.subjectContainer .getLayoutParams(); subjectParams.addRule(RelativeLayout.LEFT_OF, R.id.thumbnail); if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) { subjectParams.addRule(RelativeLayout.START_OF, R.id.thumbnail); } - this.subjectView.setLayoutParams(subjectParams); + this.subjectContainer.setLayoutParams(subjectParams); this.post(new ThumbnailPositioner(thumbnailView, archivedView, deliveryStatusIndicator, dateView)); } else { this.thumbnailView.setVisibility(View.GONE); - LayoutParams subjectParams = (RelativeLayout.LayoutParams)this.subjectView.getLayoutParams(); + LayoutParams subjectParams = (RelativeLayout.LayoutParams)this.subjectContainer.getLayoutParams(); subjectParams.addRule(RelativeLayout.LEFT_OF, R.id.status); if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) { subjectParams.addRule(RelativeLayout.START_OF, R.id.status); } - this.subjectView.setLayoutParams(subjectParams); + this.subjectContainer.setLayoutParams(subjectParams); } } diff --git a/src/org/thoughtcrime/securesms/ConversationListItemAction.java b/src/org/thoughtcrime/securesms/ConversationListItemAction.java index 63c7d4e39b..993a486c3f 100644 --- a/src/org/thoughtcrime/securesms/ConversationListItemAction.java +++ b/src/org/thoughtcrime/securesms/ConversationListItemAction.java @@ -39,7 +39,13 @@ public class ConversationListItemAction extends LinearLayout implements Bindable } @Override - public void bind(@NonNull ThreadRecord thread, @NonNull GlideRequests glideRequests, @NonNull Locale locale, @NonNull Set selectedThreads, boolean batchMode) { + public void bind(@NonNull ThreadRecord thread, + @NonNull GlideRequests glideRequests, + @NonNull Locale locale, + @NonNull Set typingThreads, + @NonNull Set selectedThreads, + boolean batchMode) + { this.description.setText(getContext().getString(R.string.ConversationListItemAction_archived_conversations_d, thread.getCount())); } diff --git a/src/org/thoughtcrime/securesms/ConversationListItemInboxZero.java b/src/org/thoughtcrime/securesms/ConversationListItemInboxZero.java index cf9be6c991..1aace90b79 100644 --- a/src/org/thoughtcrime/securesms/ConversationListItemInboxZero.java +++ b/src/org/thoughtcrime/securesms/ConversationListItemInboxZero.java @@ -39,7 +39,13 @@ public class ConversationListItemInboxZero extends LinearLayout implements Binda } @Override - public void bind(@NonNull ThreadRecord thread, @NonNull GlideRequests glideRequests, @NonNull Locale locale, @NonNull Set selectedThreads, boolean batchMode) { + public void bind(@NonNull ThreadRecord thread, + @NonNull GlideRequests glideRequests, + @NonNull Locale locale, + @NonNull Set typingThreads, + @NonNull Set selectedThreads, + boolean batchMode) + { } } diff --git a/src/org/thoughtcrime/securesms/ExperienceUpgradeActivity.java b/src/org/thoughtcrime/securesms/ExperienceUpgradeActivity.java index e0f5b8e3ad..4b02f4e4fa 100644 --- a/src/org/thoughtcrime/securesms/ExperienceUpgradeActivity.java +++ b/src/org/thoughtcrime/securesms/ExperienceUpgradeActivity.java @@ -12,12 +12,12 @@ import android.support.annotation.Nullable; import android.support.annotation.StringRes; import android.support.v4.app.NotificationCompat; import android.support.v4.view.ViewPager; -import org.thoughtcrime.securesms.logging.Log; import com.melnykov.fab.FloatingActionButton; import com.nineoldandroids.animation.ArgbEvaluator; import org.thoughtcrime.securesms.IntroPagerAdapter.IntroPage; +import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -67,7 +67,14 @@ public class ExperienceUpgradeActivity extends BaseActionBarActivity { R.string.experience_upgrade_preference_fragment__read_receipts_are_here, R.string.experience_upgrade_preference_fragment__optionally_see_and_share_when_messages_have_been_read, R.string.experience_upgrade_preference_fragment__optionally_see_and_share_when_messages_have_been_read, - null); + null), + TYPING_INDICATORS(430, + new IntroPage(0xFF2090EA, + TypingIndicatorIntroFragment.newInstance()), + R.string.ExperienceUpgradeActivity_introducing_typing_indicators, + R.string.ExperienceUpgradeActivity_now_you_can_optionally_see_and_share_when_messages_are_being_typed, + R.string.ExperienceUpgradeActivity_now_you_can_optionally_see_and_share_when_messages_are_being_typed, + null); private int version; private List pages; diff --git a/src/org/thoughtcrime/securesms/PassphraseCreateActivity.java b/src/org/thoughtcrime/securesms/PassphraseCreateActivity.java index baec5ae747..3f35e876e9 100644 --- a/src/org/thoughtcrime/securesms/PassphraseCreateActivity.java +++ b/src/org/thoughtcrime/securesms/PassphraseCreateActivity.java @@ -69,6 +69,7 @@ public class PassphraseCreateActivity extends PassphraseActivity { TextSecurePreferences.setLastExperienceVersionCode(PassphraseCreateActivity.this, Util.getCurrentApkReleaseVersion(PassphraseCreateActivity.this)); TextSecurePreferences.setPasswordDisabled(PassphraseCreateActivity.this, true); TextSecurePreferences.setReadReceiptsEnabled(PassphraseCreateActivity.this, true); + TextSecurePreferences.setTypingIndicatorsEnabled(PassphraseCreateActivity.this, true); return null; } diff --git a/src/org/thoughtcrime/securesms/ReadReceiptsIntroFragment.java b/src/org/thoughtcrime/securesms/ReadReceiptsIntroFragment.java index 2a68284368..4f2649451f 100644 --- a/src/org/thoughtcrime/securesms/ReadReceiptsIntroFragment.java +++ b/src/org/thoughtcrime/securesms/ReadReceiptsIntroFragment.java @@ -40,6 +40,7 @@ public class ReadReceiptsIntroFragment extends Fragment { .getJobManager() .add(new MultiDeviceConfigurationUpdateJob(getContext(), isChecked, + TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()), TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()))); }); diff --git a/src/org/thoughtcrime/securesms/TypingIndicatorIntroFragment.java b/src/org/thoughtcrime/securesms/TypingIndicatorIntroFragment.java new file mode 100644 index 0000000000..e65793e6ef --- /dev/null +++ b/src/org/thoughtcrime/securesms/TypingIndicatorIntroFragment.java @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms; + + +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v7.widget.SwitchCompat; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.thoughtcrime.securesms.components.TypingIndicatorView; +import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.ViewUtil; + +public class TypingIndicatorIntroFragment extends Fragment { + + public static TypingIndicatorIntroFragment newInstance() { + TypingIndicatorIntroFragment fragment = new TypingIndicatorIntroFragment(); + Bundle args = new Bundle(); + fragment.setArguments(args); + return fragment; + } + + public TypingIndicatorIntroFragment() {} + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.experience_upgrade_typing_indicators_fragment, container, false); + SwitchCompat preference = ViewUtil.findById(v, R.id.preference); + + ((TypingIndicatorView) v.findViewById(R.id.typing_indicator)).startAnimation(); + + preference.setChecked(TextSecurePreferences.isTypingIndicatorsEnabled(getContext())); + preference.setOnCheckedChangeListener((buttonView, isChecked) -> { + TextSecurePreferences.setTypingIndicatorsEnabled(getContext(), isChecked); + ApplicationContext.getInstance(requireContext()) + .getJobManager() + .add(new MultiDeviceConfigurationUpdateJob(getContext(), + TextSecurePreferences.isReadReceiptsEnabled(requireContext()), + isChecked, + TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()))); + }); + + return v; + } +} diff --git a/src/org/thoughtcrime/securesms/components/ConversationTypingView.java b/src/org/thoughtcrime/securesms/components/ConversationTypingView.java new file mode 100644 index 0000000000..002b62323c --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/ConversationTypingView.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.List; + +public class ConversationTypingView extends LinearLayout { + + private AvatarImageView avatar; + private View bubble; + private TypingIndicatorView indicator; + + public ConversationTypingView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + avatar = findViewById(R.id.typing_avatar); + bubble = findViewById(R.id.typing_bubble); + indicator = findViewById(R.id.typing_indicator); + } + + public void setTypists(@NonNull GlideRequests glideRequests, @NonNull List typists, boolean isGroupThread) { + if (typists.isEmpty()) { + indicator.stopAnimation(); + return; + } + + Recipient typist = typists.get(0); + bubble.getBackground().setColorFilter(typist.getColor().toConversationColor(getContext()), PorterDuff.Mode.MULTIPLY); + + if (isGroupThread) { + avatar.setAvatar(glideRequests, typist, false); + avatar.setVisibility(VISIBLE); + } else { + avatar.setVisibility(GONE); + } + + indicator.startAnimation(); + } +} diff --git a/src/org/thoughtcrime/securesms/components/TypingIndicatorView.java b/src/org/thoughtcrime/securesms/components/TypingIndicatorView.java new file mode 100644 index 0000000000..af6db0e3bc --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/TypingIndicatorView.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.components; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; + +import org.thoughtcrime.securesms.R; + +public class TypingIndicatorView extends LinearLayout { + + private static final long DURATION = 300; + private static final long PRE_DELAY = 500; + private static final long POST_DELAY = 500; + + private View dot1; + private View dot2; + private View dot3; + + private AnimatorSet animation1; + private AnimatorSet animation2; + private AnimatorSet animation3; + + public TypingIndicatorView(Context context) { + super(context); + initialize(null); + } + + public TypingIndicatorView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(attrs); + } + + private void initialize(@Nullable AttributeSet attrs) { + inflate(getContext(), R.layout.typing_indicator_view, this); + + dot1 = findViewById(R.id.typing_dot1); + dot2 = findViewById(R.id.typing_dot2); + dot3 = findViewById(R.id.typing_dot3); + + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.TypingIndicatorView, 0, 0); + int tint = typedArray.getColor(R.styleable.TypingIndicatorView_typingIndicator_tint, Color.WHITE); + typedArray.recycle(); + + dot1.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); + dot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); + dot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); + } + + animation1 = getAnimation(dot1, DURATION, 0 ); + animation2 = getAnimation(dot2, DURATION, DURATION / 2); + animation3 = getAnimation(dot3, DURATION, DURATION ); + + animation3.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + postDelayed(TypingIndicatorView.this::startAnimation, POST_DELAY); + } + }); + } + + public void startAnimation() { + stopAnimation(); + postDelayed(() -> { + animation1.start(); + animation2.start(); + animation3.start(); + }, PRE_DELAY); + } + + public void stopAnimation() { + animation1.cancel(); + animation2.cancel(); + animation3.cancel(); + + reset(dot1); + reset(dot2); + reset(dot3); + } + + private AnimatorSet getAnimation(@NonNull View view, long duration, long startDelay) { + AnimatorSet grow = new AnimatorSet(); + grow.playTogether(ObjectAnimator.ofFloat(view, View.SCALE_X, 0.5f, 1).setDuration(duration), + ObjectAnimator.ofFloat(view, View.SCALE_Y, 0.5f, 1).setDuration(duration), + ObjectAnimator.ofFloat(view, View.ALPHA, 0.5f, 1).setDuration(duration)); + + AnimatorSet shrink = new AnimatorSet(); + shrink.playTogether(ObjectAnimator.ofFloat(view, View.SCALE_X, 1, 0.5f).setDuration(duration), + ObjectAnimator.ofFloat(view, View.SCALE_Y, 1, 0.5f).setDuration(duration), + ObjectAnimator.ofFloat(view, View.ALPHA, 1, 0.5f).setDuration(duration)); + + AnimatorSet all = new AnimatorSet(); + all.playSequentially(grow, shrink); + all.setStartDelay(startDelay); + + return all; + } + + private void reset(View view) { + view.clearAnimation(); + view.setScaleX(0.5f); + view.setScaleY(0.5f); + view.setAlpha(0.5f); + } +} diff --git a/src/org/thoughtcrime/securesms/components/TypingStatusRepository.java b/src/org/thoughtcrime/securesms/components/TypingStatusRepository.java new file mode 100644 index 0000000000..fb03fc0302 --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/TypingStatusRepository.java @@ -0,0 +1,184 @@ +package org.thoughtcrime.securesms.components; + +import android.annotation.SuppressLint; +import android.arch.lifecycle.LiveData; +import android.arch.lifecycle.MutableLiveData; +import android.support.annotation.NonNull; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.Util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@SuppressLint("UseSparseArrays") +public class TypingStatusRepository { + + private static final String TAG = TypingStatusRepository.class.getSimpleName(); + + private static final long RECIPIENT_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(15); + + private final Map> typistMap; + private final Map timers; + private final Map> notifiers; + private final MutableLiveData> threadsNotifier; + + public TypingStatusRepository() { + this.typistMap = new HashMap<>(); + this.timers = new HashMap<>(); + this.notifiers = new HashMap<>(); + this.threadsNotifier = new MutableLiveData<>(); + } + + public synchronized void onTypingStarted(long threadId, Recipient author, int device) { + Set typists = Util.getOrDefault(typistMap, threadId, new LinkedHashSet<>()); + Typist typist = new Typist(author, device, threadId); + + if (!typists.contains(typist)) { + typists.add(typist); + typistMap.put(threadId, typists); + notifyThread(threadId, typists, false); + } + + Runnable timer = timers.get(typist); + if (timer != null) { + Util.cancelRunnableOnMain(timer); + } + + timer = () -> onTypingStopped(threadId, author, device, false); + Util.runOnMainDelayed(timer, RECIPIENT_TYPING_TIMEOUT); + timers.put(typist, timer); + } + + public synchronized void onTypingStopped(long threadId, Recipient author, int device, boolean isReplacedByIncomingMessage) { + Set typists = Util.getOrDefault(typistMap, threadId, new LinkedHashSet<>()); + Typist typist = new Typist(author, device, threadId); + + if (typists.contains(typist)) { + typists.remove(typist); + notifyThread(threadId, typists, isReplacedByIncomingMessage); + } + + if (typists.isEmpty()) { + typistMap.remove(threadId); + } + + Runnable timer = timers.get(typist); + if (timer != null) { + Util.cancelRunnableOnMain(timer); + timers.remove(typist); + } + } + + public synchronized LiveData getTypists(long threadId) { + MutableLiveData notifier = Util.getOrDefault(notifiers, threadId, new MutableLiveData<>()); + notifiers.put(threadId, notifier); + return notifier; + } + + public synchronized LiveData> getTypingThreads() { + return threadsNotifier; + } + + public synchronized void clear() { + TypingState empty = new TypingState(Collections.emptyList(), false); + for (MutableLiveData notifier : notifiers.values()) { + notifier.postValue(empty); + } + + notifiers.clear(); + typistMap.clear(); + timers.clear(); + + threadsNotifier.postValue(Collections.emptySet()); + } + + private void notifyThread(long threadId, @NonNull Set typists, boolean isReplacedByIncomingMessage) { + Log.d(TAG, "notifyThread() threadId: " + threadId + " typists: " + typists.size() + " isReplaced: " + isReplacedByIncomingMessage); + + MutableLiveData notifier = Util.getOrDefault(notifiers, threadId, new MutableLiveData<>()); + notifiers.put(threadId, notifier); + + Set uniqueTypists = new LinkedHashSet<>(); + for (Typist typist : typists) { + uniqueTypists.add(typist.getAuthor()); + } + + notifier.postValue(new TypingState(new ArrayList<>(uniqueTypists), isReplacedByIncomingMessage)); + + Set activeThreads = Stream.of(typistMap.keySet()).filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet()); + threadsNotifier.postValue(activeThreads); + } + + public static class TypingState { + private final List typists; + private final boolean replacedByIncomingMessage; + + public TypingState(List typists, boolean replacedByIncomingMessage) { + this.typists = typists; + this.replacedByIncomingMessage = replacedByIncomingMessage; + } + + public List getTypists() { + return typists; + } + + public boolean isReplacedByIncomingMessage() { + return replacedByIncomingMessage; + } + } + + private static class Typist { + private final Recipient author; + private final int device; + private final long threadId; + + private Typist(@NonNull Recipient author, int device, long threadId) { + this.author = author; + this.device = device; + this.threadId = threadId; + } + + public Recipient getAuthor() { + return author; + } + + public int getDevice() { + return device; + } + + public long getThreadId() { + return threadId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Typist typist = (Typist) o; + + if (device != typist.device) return false; + if (threadId != typist.threadId) return false; + return author.equals(typist.author); + } + + @Override + public int hashCode() { + int result = author.hashCode(); + result = 31 * result + device; + result = 31 * result + (int) (threadId ^ (threadId >>> 32)); + return result; + } + } +} diff --git a/src/org/thoughtcrime/securesms/components/TypingStatusSender.java b/src/org/thoughtcrime/securesms/components/TypingStatusSender.java new file mode 100644 index 0000000000..5281fe2a4c --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/TypingStatusSender.java @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.components; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.support.annotation.NonNull; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.jobs.TypingSendJob; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@SuppressLint("UseSparseArrays") +public class TypingStatusSender { + + private static final String TAG = TypingStatusSender.class.getSimpleName(); + + private static final long REFRESH_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(10); + private static final long PAUSE_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(3); + + private final Context context; + private final Map selfTypingTimers; + + public TypingStatusSender(@NonNull Context context) { + this.context = context; + this.selfTypingTimers = new HashMap<>(); + } + + public synchronized void onTypingStarted(long threadId) { + TimerPair pair = Util.getOrDefault(selfTypingTimers, threadId, new TimerPair()); + selfTypingTimers.put(threadId, pair); + + if (pair.getStart() == null) { + sendTyping(threadId, true); + + Runnable start = new StartRunnable(threadId); + Util.runOnMainDelayed(start, REFRESH_TYPING_TIMEOUT); + pair.setStart(start); + } + + if (pair.getStop() != null) { + Util.cancelRunnableOnMain(pair.getStop()); + } + + Runnable stop = () -> onTypingStopped(threadId, true); + Util.runOnMainDelayed(stop, PAUSE_TYPING_TIMEOUT); + pair.setStop(stop); + } + + public synchronized void onTypingStopped(long threadId) { + onTypingStopped(threadId, false); + } + + private synchronized void onTypingStopped(long threadId, boolean notify) { + TimerPair pair = Util.getOrDefault(selfTypingTimers, threadId, new TimerPair()); + selfTypingTimers.put(threadId, pair); + + if (pair.getStart() != null) { + Util.cancelRunnableOnMain(pair.getStart()); + + if (notify) { + sendTyping(threadId, false); + } + } + + if (pair.getStop() != null) { + Util.cancelRunnableOnMain(pair.getStop()); + } + + pair.setStart(null); + pair.setStop(null); + } + + private void sendTyping(long threadId, boolean typingStarted) { + ApplicationContext.getInstance(context).getJobManager().add(new TypingSendJob(context, threadId, typingStarted)); + } + + private class StartRunnable implements Runnable { + + private final long threadId; + + private StartRunnable(long threadId) { + this.threadId = threadId; + } + + @Override + public void run() { + sendTyping(threadId, true); + Util.runOnMainDelayed(this, REFRESH_TYPING_TIMEOUT); + } + } + + private static class TimerPair { + private Runnable start; + private Runnable stop; + + public Runnable getStart() { + return start; + } + + public void setStart(Runnable start) { + this.start = start; + } + + public Runnable getStop() { + return stop; + } + + public void setStop(Runnable stop) { + this.stop = stop; + } + } +} diff --git a/src/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java b/src/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java new file mode 100644 index 0000000000..01bebbf9d8 --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.components.recyclerview; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.LinearSmoothScroller; +import android.util.DisplayMetrics; + +public class SmoothScrollingLinearLayoutManager extends LinearLayoutManager { + + public SmoothScrollingLinearLayoutManager(Context context, boolean reverseLayout) { + super(context, LinearLayoutManager.VERTICAL, reverseLayout); + } + + public void smoothScrollToPosition(@NonNull Context context, int position, float millisecondsPerInch) { + final LinearSmoothScroller scroller = new LinearSmoothScroller(context) { + @Override + protected int getVerticalSnapPreference() { + return LinearSmoothScroller.SNAP_TO_END; + } + + @Override + protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { + return millisecondsPerInch / displayMetrics.densityDpi; + } + }; + + scroller.setTargetPosition(position); + startSmoothScroll(scroller); + } +} diff --git a/src/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java b/src/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java index 31b9cc42ac..fec67b20c5 100644 --- a/src/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java +++ b/src/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java @@ -71,6 +71,10 @@ public abstract class CursorRecyclerViewAdapter smsMessageId) throws StorageFailedException { + notifyTypingStoppedFromIncomingMessage(getMessageDestination(content, message), content.getSender(), content.getSenderDevice()); + try { MmsDatabase database = DatabaseFactory.getMmsDatabase(context); Optional quote = getValidatedQuote(message.getQuote()); @@ -735,6 +741,8 @@ public class PushDecryptJob extends ContextJob { if (smsMessageId.isPresent() && !message.getGroupInfo().isPresent()) { threadId = database.updateBundleMessageBody(smsMessageId.get(), body).second; } else { + notifyTypingStoppedFromIncomingMessage(recipient, content.getSender(), content.getSenderDevice()); + IncomingTextMessage textMessage = new IncomingTextMessage(Address.fromExternal(context, content.getSender()), content.getSenderDevice(), message.getTimestamp(), body, @@ -938,6 +946,40 @@ public class PushDecryptJob extends ContextJob { } } + private void handleTypingMessage(@NonNull SignalServiceContent content, + @NonNull SignalServiceTypingMessage typingMessage) + { + if (!TextSecurePreferences.isTypingIndicatorsEnabled(context)) { + return; + } + + Recipient author = Recipient.from(context, Address.fromExternal(context, content.getSender()), false); + + long threadId; + + if (typingMessage.getGroupId().isPresent()) { + Address groupAddress = Address.fromExternal(context, GroupUtil.getEncodedId(typingMessage.getGroupId().get(), false)); + Recipient groupRecipient = Recipient.from(context, groupAddress, false); + + threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); + } else { + threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(author); + } + + if (threadId <= 0) { + Log.w(TAG, "Couldn't find a matching thread for a typing message."); + return; + } + + if (typingMessage.isTypingStarted()) { + Log.d(TAG, "Typing started on thread " + threadId); + ApplicationContext.getInstance(context).getTypingStatusRepository().onTypingStarted(threadId, author, content.getSenderDevice()); + } else { + Log.d(TAG, "Typing stopped on thread " + threadId); + ApplicationContext.getInstance(context).getTypingStatusRepository().onTypingStopped(threadId, author, content.getSenderDevice(), false); + } + } + private Optional getValidatedQuote(Optional quote) { if (!quote.isPresent()) return Optional.absent(); @@ -1012,6 +1054,16 @@ public class PushDecryptJob extends ContextJob { } } + private void notifyTypingStoppedFromIncomingMessage(@NonNull Recipient conversationRecipient, @NonNull String sender, int device) { + Recipient author = Recipient.from(context, Address.fromExternal(context, sender), false); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(conversationRecipient); + + if (threadId > 0) { + Log.d(TAG, "Typing stopped on thread " + threadId + " due to an incoming message."); + ApplicationContext.getInstance(context).getTypingStatusRepository().onTypingStopped(threadId, author, device, true); + } + } + private boolean shouldIgnore(@Nullable SignalServiceContent content) { if (content == null) { Log.w(TAG, "Got a message with null content."); diff --git a/src/org/thoughtcrime/securesms/jobs/TypingSendJob.java b/src/org/thoughtcrime/securesms/jobs/TypingSendJob.java new file mode 100644 index 0000000000..f54f788e86 --- /dev/null +++ b/src/org/thoughtcrime/securesms/jobs/TypingSendJob.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; +import android.support.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.InjectableType; +import org.thoughtcrime.securesms.jobmanager.JobParameters; +import org.thoughtcrime.securesms.jobmanager.SafeData; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage.Action; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.util.Collections; +import java.util.List; + +import javax.inject.Inject; + +import androidx.work.Data; + +public class TypingSendJob extends ContextJob implements InjectableType { + + private static final String TAG = TypingSendJob.class.getSimpleName(); + + private static final String KEY_THREAD_ID = "thread_id"; + private static final String KEY_TYPING = "typing"; + + private long threadId; + private boolean typing; + + @Inject SignalServiceMessageSender messageSender; + + public TypingSendJob() { + super(null, null); + } + + public TypingSendJob(Context context, long threadId, boolean typing) { + super(context, JobParameters.newBuilder() + .withGroupId("TYPING_" + threadId) + .withRetryCount(1) + .create()); + + this.threadId = threadId; + this.typing = typing; + } + + @Override + protected void initialize(@NonNull SafeData data) { + this.threadId = data.getLong(KEY_THREAD_ID); + this.typing = data.getBoolean(KEY_TYPING); + } + + @NonNull + @Override + protected Data serialize(@NonNull Data.Builder dataBuilder) { + return dataBuilder.putLong(KEY_THREAD_ID, threadId) + .putBoolean(KEY_TYPING, typing) + .build(); + } + + @Override + public void onRun() throws Exception { + if (!TextSecurePreferences.isTypingIndicatorsEnabled(context)) { + return; + } + + Log.d(TAG, "Sending typing " + (typing ? "started" : "stopped") + " for thread " + threadId); + + Recipient recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + + if (recipient == null) { + throw new IllegalStateException("Tried to send a typing indicator to a non-existent thread."); + } + + List recipients = Collections.singletonList(recipient); + Optional groupId = Optional.absent(); + + if (recipient.isGroupRecipient()) { + recipients = DatabaseFactory.getGroupDatabase(context).getGroupMembers(recipient.getAddress().toGroupString(), false); + groupId = Optional.of(GroupUtil.getDecodedId(recipient.getAddress().toGroupString())); + } + + List addresses = Stream.of(recipients).map(r -> new SignalServiceAddress(r.getAddress().serialize())).toList(); + List> unidentifiedAccess = Stream.of(recipients).map(r -> UnidentifiedAccessUtil.getAccessFor(context, r)).toList(); + SignalServiceTypingMessage typingMessage = new SignalServiceTypingMessage(typing ? Action.STARTED : Action.STOPPED, System.currentTimeMillis(), groupId); + + messageSender.sendTyping(addresses, unidentifiedAccess, typingMessage); + } + + @Override + protected void onCanceled() { + } + + @Override + protected boolean onShouldRetry(Exception exception) { + return false; + } +} diff --git a/src/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java b/src/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java index adb77a640b..a7439813d6 100644 --- a/src/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java +++ b/src/org/thoughtcrime/securesms/preferences/AppProtectionPreferenceFragment.java @@ -65,6 +65,7 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment this.findPreference(TextSecurePreferences.CHANGE_PASSPHRASE_PREF).setOnPreferenceClickListener(new ChangePassphraseClickListener()); this.findPreference(TextSecurePreferences.PASSPHRASE_TIMEOUT_INTERVAL_PREF).setOnPreferenceClickListener(new PassphraseIntervalClickListener()); this.findPreference(TextSecurePreferences.READ_RECEIPTS_PREF).setOnPreferenceChangeListener(new ReadReceiptToggleListener()); + this.findPreference(TextSecurePreferences.TYPING_INDICATORS).setOnPreferenceChangeListener(new TypingIndicatorsToggleListener()); this.findPreference(PREFERENCE_CATEGORY_BLOCKED).setOnPreferenceClickListener(new BlockedContactsClickListener()); this.findPreference(TextSecurePreferences.SHOW_UNIDENTIFIED_DELIVERY_INDICATORS).setOnPreferenceChangeListener(new ShowUnidentifiedDeliveryIndicatorsChangedListener()); this.findPreference(TextSecurePreferences.UNIVERSAL_UNIDENTIFIED_ACCESS).setOnPreferenceChangeListener(new UniversalUnidentifiedAccessChangedListener()); @@ -187,12 +188,32 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment .getJobManager() .add(new MultiDeviceConfigurationUpdateJob(getContext(), enabled, + TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()), TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()))); return true; } } + private class TypingIndicatorsToggleListener implements Preference.OnPreferenceChangeListener { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + boolean enabled = (boolean)newValue; + ApplicationContext.getInstance(getContext()) + .getJobManager() + .add(new MultiDeviceConfigurationUpdateJob(getContext(), + TextSecurePreferences.isReadReceiptsEnabled(requireContext()), + enabled, + TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()))); + + if (!enabled) { + ApplicationContext.getInstance(requireContext()).getTypingStatusRepository().clear(); + } + + return true; + } + } + public static CharSequence getSummary(Context context) { final int privacySummaryResId = R.string.ApplicationPreferencesActivity_privacy_summary; final String onRes = context.getString(R.string.ApplicationPreferencesActivity_on); @@ -289,6 +310,7 @@ public class AppProtectionPreferenceFragment extends CorrectedPreferenceFragment .getJobManager() .add(new MultiDeviceConfigurationUpdateJob(getContext(), TextSecurePreferences.isReadReceiptsEnabled(getContext()), + TextSecurePreferences.isTypingIndicatorsEnabled(getContext()), enabled)); return true; diff --git a/src/org/thoughtcrime/securesms/search/SearchListAdapter.java b/src/org/thoughtcrime/securesms/search/SearchListAdapter.java index 69c8f4c4ef..c4d4740b87 100644 --- a/src/org/thoughtcrime/securesms/search/SearchListAdapter.java +++ b/src/org/thoughtcrime/securesms/search/SearchListAdapter.java @@ -163,7 +163,7 @@ class SearchListAdapter extends RecyclerView.Adapter eventListener.onConversationClicked(conversationResult)); } diff --git a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java index 63eb9b87d8..ead6eb1901 100644 --- a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -171,6 +171,8 @@ public class TextSecurePreferences { public static final String SHOW_UNIDENTIFIED_DELIVERY_INDICATORS = "pref_show_unidentifed_delivery_indicators"; private static final String UNIDENTIFIED_DELIVERY_ENABLED = "pref_unidentified_delivery_enabled"; + public static final String TYPING_INDICATORS = "pref_typing_indicators"; + public static boolean isScreenLockEnabled(@NonNull Context context) { return getBooleanPreference(context, SCREEN_LOCK, false); } @@ -336,6 +338,14 @@ public class TextSecurePreferences { setBooleanPreference(context, READ_RECEIPTS_PREF, enabled); } + public static boolean isTypingIndicatorsEnabled(Context context) { + return getBooleanPreference(context, TYPING_INDICATORS, false); + } + + public static void setTypingIndicatorsEnabled(Context context, boolean enabled) { + setBooleanPreference(context, TYPING_INDICATORS, enabled); + } + public static @Nullable String getProfileKey(Context context) { return getStringPreference(context, PROFILE_KEY_PREF, null); } diff --git a/src/org/thoughtcrime/securesms/util/Util.java b/src/org/thoughtcrime/securesms/util/Util.java index 8416e6b2d7..6ea310b338 100644 --- a/src/org/thoughtcrime/securesms/util/Util.java +++ b/src/org/thoughtcrime/securesms/util/Util.java @@ -68,6 +68,7 @@ import java.util.Collection; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadPoolExecutor; @@ -132,6 +133,10 @@ public class Util { return value == null || value.getText() == null || TextUtils.isEmpty(value.getTextTrimmed()); } + public static V getOrDefault(@NonNull Map map, K key, V defaultValue) { + return map.containsKey(key) ? map.get(key) : defaultValue; + } + public static CharSequence getBoldedString(String value) { SpannableString spanned = new SpannableString(value); spanned.setSpan(new StyleSpan(Typeface.BOLD), 0, @@ -397,6 +402,10 @@ public class Util { handler.postDelayed(runnable, delayMillis); } + public static void cancelRunnableOnMain(@NonNull Runnable runnable) { + handler.removeCallbacks(runnable); + } + public static void runOnMainSync(final @NonNull Runnable runnable) { if (isMainThread()) { runnable.run();