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();