Add support for typing indicators.

This commit is contained in:
Greyson Parrelli 2018-10-29 15:14:31 -07:00
parent 3f25fb7d5f
commit 776b0e23ae
39 changed files with 1231 additions and 60 deletions

View File

@ -149,6 +149,7 @@ dependencies {
}
}
// TODO: Update dependencies after getting final libsignal
dependencyVerification {
verify = [
'com.android.support:design:7874ad1904eedc74aa41cffffb7f759d8990056f3bbbc9264911651c67c42f5f',

View File

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

View File

@ -36,8 +36,8 @@
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/contact_photo"
android:foreground="@drawable/contact_photo_background"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_width="@dimen/conversation_item_avatar_size"
android:layout_height="@dimen/conversation_item_avatar_size"
android:cropToPadding="true"
android:contentDescription="@string/conversation_item_received__contact_photo_description" />

View File

@ -88,21 +88,36 @@
android:visibility="gone"
tools:visibility="visible"/>
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/subject"
android:layout_width="match_parent"
<FrameLayout
android:id="@+id/subject_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingRight="1dp"
android:layout_below="@id/from"
android:layout_toRightOf="@id/indicators_parent"
android:layout_toEndOf="@id/indicators_parent"
android:layout_toLeftOf="@+id/status"
android:layout_toStartOf="@+id/status">
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/subject"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Signal.Text.Preview"
android:textColor="?attr/conversation_list_item_subject_color"
android:maxLines="1"
tools:text="Wheels arrive at 3pm flat. This is a somewhat longer message."
android:ellipsize="end" />
<org.thoughtcrime.securesms.components.TypingIndicatorView
android:id="@+id/typing_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/from"
android:layout_toRightOf="@id/indicators_parent"
android:layout_toEndOf="@id/indicators_parent"
android:layout_toLeftOf="@+id/status"
android:layout_toStartOf="@+id/status"
android:paddingRight="1dp"
style="@style/Signal.Text.Preview"
android:textColor="?attr/conversation_list_item_subject_color"
android:maxLines="1"
tools:text="Wheels arrive at 3pm flat. This is a somewhat longer message."
android:ellipsize="end" />
android:layout_marginTop="4dp"
android:visibility="gone"
app:typingIndicator_tint="?conversation_list_typing_tint"/>
</FrameLayout>
<org.thoughtcrime.securesms.components.ThumbnailView
android:id="@+id/thumbnail"

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.ConversationTypingView
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="8dp"
android:paddingRight="8dp"
android:paddingTop="2dp"
android:gravity="center_vertical">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/typing_avatar"
android:foreground="@drawable/contact_photo_background"
android:layout_width="@dimen/conversation_item_avatar_size"
android:layout_height="@dimen/conversation_item_avatar_size"
android:cropToPadding="true"
android:contentDescription="@string/conversation_item_received__contact_photo_description" />
<FrameLayout
android:id="@+id/typing_bubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginStart="8dp"
android:padding="12dp"
android:background="@drawable/message_bubble_background">
<org.thoughtcrime.securesms.components.TypingIndicatorView
android:id="@+id/typing_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</FrameLayout>
</org.thoughtcrime.securesms.components.ConversationTypingView>

View File

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:background="#FF2090ea">
<TextView android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:id="@+id/blurb"
android:textSize="@dimen/onboarding_title_size"
android:textIsSelectable="false"
android:gravity="center_horizontal"
android:paddingLeft="20dp"
android:paddingRight="20dp"
android:fontFamily="sans-serif-light"
android:text="@string/ExperienceUpgradeActivity_introducing_typing_indicators"
android:layout_marginTop="20dp"
android:textColor="@android:color/white" />
<FrameLayout android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginBottom="20dp"
android:layout_marginTop="20dp">
<ImageView android:layout_width="170dp"
android:layout_height="170dp"
android:src="@drawable/circle_tintable"
android:scaleType="fitCenter"/>
<ImageView
android:layout_width="120dp"
android:layout_height="70dp"
android:layout_gravity="center"
android:src="@drawable/message_bubble_background"
android:tint="@color/signal_primary"/>
<org.thoughtcrime.securesms.components.TypingIndicatorView
android:id="@+id/typing_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleX="2.5"
android:scaleY="2.5"
android:layout_gravity="center" />
</FrameLayout>
<TextView android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/subblurb"
android:textSize="@dimen/onboarding_subtitle_size"
android:textIsSelectable="false"
android:gravity="center_horizontal"
android:paddingLeft="20dp"
android:paddingRight="20dp"
android:fontFamily="sans-serif-light"
android:text="@string/ExperienceUpgradeActivity_now_you_can_optionally_see_and_share_when_messages_are_being_typed"
android:textColor="@android:color/white" />
<android.support.v7.widget.SwitchCompat
android:id="@+id/preference"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="20dp"
android:enabled="true"
android:checked="true"
android:text="@string/ExperienceUpgradeActivity_enable_typing_indicators"
android:textSize="20sp"
android:textColor="@android:color/white"
app:theme="@style/Color1SwitchStyle"/>
</LinearLayout>

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="org.thoughtcrime.securesms.components.TypingIndicatorView">
<View
android:id="@+id/typing_dot1"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_margin="2dp"
android:scaleX="0.5"
android:scaleY="0.5"
android:alpha="0.5"
android:background="@drawable/circle_white" />
<View
android:id="@+id/typing_dot2"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_margin="2dp"
android:scaleX="0.5"
android:scaleY="0.5"
android:alpha="0.5"
android:background="@drawable/circle_white" />
<View
android:id="@+id/typing_dot3"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_margin="2dp"
android:scaleX="0.5"
android:scaleY="0.5"
android:alpha="0.5"
android:background="@drawable/circle_white" />
</merge>

View File

@ -15,6 +15,7 @@
<attr name="conversation_list_item_unread_background" format="reference"/>
<attr name="conversation_list_item_divider" format="reference"/>
<attr name="conversation_list_toolbar_background" format="reference"/>
<attr name="conversation_list_typing_tint" format="color"/>
<attr name="conversation_sent_card_background" format="reference|color"/>
<attr name="conversation_group_member_name" format="reference|color"/>
@ -295,4 +296,8 @@
<attr name="conversationThumbnail_maxHeight" format="dimension" />
</declare-styleable>
<declare-styleable name="TypingIndicatorView">
<attr name="typingIndicator_tint" format="color" />
</declare-styleable>
</resources>

View File

@ -42,6 +42,8 @@
<dimen name="conversation_vertical_message_spacing_default">8dp</dimen>
<dimen name="conversation_vertical_message_spacing_collapse">1dp</dimen>
<dimen name="conversation_item_avatar_size">36dp</dimen>
<dimen name="quote_corner_radius_large">10dp</dimen>
<dimen name="quote_corner_radius_bottom">4dp</dimen>
<dimen name="quote_corner_radius_preview">18dp</dimen>

View File

@ -314,6 +314,11 @@
<string name="ExperienceUpgradeActivity_now_you_can_share_a_profile_photo_and_name_with_friends_on_signal">Now you can share a profile photo and name with friends on Signal</string>
<string name="ExperienceUpgradeActivity_signal_profiles_are_here">Signal profiles are here</string>
<string name="ExperienceUpgradeActivity_introducing_typing_indicators">Introducing typing indicators.</string>
<string name="ExperienceUpgradeActivity_now_you_can_optionally_see_and_share_when_messages_are_being_typed">Now you can optionally see and share when messages are being typed</string>
<string name="ExperienceUpgradeActivity_typing_ui_title">Typing indicators are here</string>
<string name="ExperienceUpgradeActivity_enable_typing_indicators">Enable typing indicators</string>
<!-- GcmBroadcastReceiver -->
<string name="GcmBroadcastReceiver_retrieving_a_message">Retrieving a message...</string>
@ -1182,6 +1187,8 @@
<string name="preferences__incognito_keyboard">Incognito keyboard</string>
<string name="preferences__read_receipts">Read receipts</string>
<string name="preferences__if_read_receipts_are_disabled_you_wont_be_able_to_see_read_receipts">If read receipts are disabled, you won\'t be able to see read receipts from others.</string>
<string name="preferences__typing_indicators">Typing indicators</string>
<string name="preferences__if_typing_indicators_are_disabled_you_wont_be_able_to_see_typing_indicators">If typing indicators are disabled, you won\'t be able to see typing indicators from others.</string>
<string name="preferences__request_keyboard_to_disable_personalized_learning">Request keyboard to disable personalized learning</string>
<string name="preferences_app_protection__blocked_contacts">Blocked contacts</string>
<string name="preferences_chats__when_using_mobile_data">When using mobile data</string>

View File

@ -140,6 +140,7 @@
<item name="conversation_list_item_unread_background">@drawable/unread_count_background_light</item>
<item name="conversation_list_item_divider">@drawable/conversation_list_divider_shape</item>
<item name="conversation_list_toolbar_background">@color/core_blue</item>
<item name="conversation_list_typing_tint">@color/core_grey_60</item>
<item name="fab_color">@color/textsecure_primary</item>
<item name="lower_right_divet">@drawable/divet_lower_right_dark</item>
@ -290,6 +291,7 @@
<item name="conversation_list_item_unread_background">@drawable/unread_count_background_dark</item>
<item name="conversation_list_item_divider">@drawable/conversation_list_divider_shape_dark</item>
<item name="conversation_list_toolbar_background">@color/core_grey_90</item>
<item name="conversation_list_typing_tint">@color/core_white</item>
<item name="conversation_group_member_name">#99ffffff</item>

View File

@ -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"/>
<org.thoughtcrime.securesms.components.SwitchPreferenceCompat
android:defaultValue="true"
android:key="pref_typing_indicators"
android:title="@string/preferences__typing_indicators"
android:summary="@string/preferences__if_typing_indicators_are_disabled_you_wont_be_able_to_see_typing_indicators"/>
<Preference android:key="preference_category_blocked"
android:title="@string/preferences_app_protection__blocked_contacts" />
</PreferenceCategory>

View File

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

View File

@ -13,5 +13,6 @@ public interface BindableConversationListItem extends Unbindable {
public void bind(@NonNull ThreadRecord thread,
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull Set<Long> selectedThreads, boolean batchMode);
}

View File

@ -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> reminderView;
private Stub<UnverifiedBannerView> unverifiedBannerView;
private Stub<GroupShareProfileView> groupShareProfileView;
private TypingStatusTextWatcher typingTextWatcher;
private AttachmentTypeSelector attachmentTypeSelector;
private AttachmentManager attachmentManager;
@ -308,8 +310,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
initializeProfiles();
initializeDraft().addListener(new AssertedSuccessListener<Boolean>() {
@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<Draft> drafts) {
if (drafts.isEmpty()) {
future.set(false);
updateToggleButtonState();
return;
}
AtomicInteger draftsRemaining = new AtomicInteger(drafts.size());
AtomicBoolean success = new AtomicBoolean(false);
ListenableFuture.Listener<Boolean> listener = new AssertedSuccessListener<Boolean>() {
@ -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<Void, Void, Long>() {
@ -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<OutgoingTextMessage, Void, Long>() {
@ -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;

View File

@ -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<Recipient> 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<MessageRecord> 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<MessageRecord> 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<Cursor> 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() {

View File

@ -59,6 +59,7 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter<ConversationList
private final Set<Long> batchSet = Collections.synchronizedSet(new HashSet<Long>());
private boolean batchMode = false;
private final Set<Long> typingSet = new HashSet<>();
protected static class ViewHolder extends RecyclerView.ViewHolder {
public <V extends View & BindableConversationListItem> ViewHolder(final @NonNull V itemView)
@ -78,6 +79,11 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter<ConversationList
return Conversions.byteArrayToLong(digest.digest(record.getRecipient().getAddress().serialize().getBytes()));
}
@Override
protected long getFastAccessItemId(int position) {
return super.getFastAccessItemId(position);
}
ConversationListAdapter(@NonNull Context context,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@ -135,7 +141,7 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter<ConversationList
@Override
public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) {
viewHolder.getItem().bind(getThreadRecord(cursor), glideRequests, locale, batchSet, batchMode);
viewHolder.getItem().bind(getThreadRecord(cursor), glideRequests, locale, typingSet, batchSet, batchMode);
}
@Override
@ -151,6 +157,12 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter<ConversationList
}
}
public void setTypingThreads(@NonNull Set<Long> threadsIds) {
typingSet.clear();
typingSet.addAll(threadsIds);
notifyDataSetChanged();
}
private ThreadRecord getThreadRecord(@NonNull Cursor cursor) {
return threadDatabase.readerFor(cursor).getCurrent();
}

View File

@ -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<Long> selectedConversations = new HashSet<>(getListAdapter().getBatchSelections());

View File

@ -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<Long> 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<Long> 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<Long> typingThreads,
@NonNull Set<Long> 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<Long> typingThreads,
@NonNull Set<Long> 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);
}
}

View File

@ -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<Long> selectedThreads, boolean batchMode) {
public void bind(@NonNull ThreadRecord thread,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull Set<Long> selectedThreads,
boolean batchMode)
{
this.description.setText(getContext().getString(R.string.ConversationListItemAction_archived_conversations_d, thread.getCount()));
}

View File

@ -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<Long> selectedThreads, boolean batchMode) {
public void bind(@NonNull ThreadRecord thread,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull Set<Long> selectedThreads,
boolean batchMode)
{
}
}

View File

@ -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<IntroPage> pages;

View File

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

View File

@ -40,6 +40,7 @@ public class ReadReceiptsIntroFragment extends Fragment {
.getJobManager()
.add(new MultiDeviceConfigurationUpdateJob(getContext(),
isChecked,
TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()),
TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext())));
});

View File

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

View File

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

View File

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

View File

@ -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<Long, Set<Typist>> typistMap;
private final Map<Typist, Runnable> timers;
private final Map<Long, MutableLiveData<TypingState>> notifiers;
private final MutableLiveData<Set<Long>> 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<Typist> 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<Typist> 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<TypingState> getTypists(long threadId) {
MutableLiveData<TypingState> notifier = Util.getOrDefault(notifiers, threadId, new MutableLiveData<>());
notifiers.put(threadId, notifier);
return notifier;
}
public synchronized LiveData<Set<Long>> getTypingThreads() {
return threadsNotifier;
}
public synchronized void clear() {
TypingState empty = new TypingState(Collections.emptyList(), false);
for (MutableLiveData<TypingState> notifier : notifiers.values()) {
notifier.postValue(empty);
}
notifiers.clear();
typistMap.clear();
timers.clear();
threadsNotifier.postValue(Collections.emptySet());
}
private void notifyThread(long threadId, @NonNull Set<Typist> typists, boolean isReplacedByIncomingMessage) {
Log.d(TAG, "notifyThread() threadId: " + threadId + " typists: " + typists.size() + " isReplaced: " + isReplacedByIncomingMessage);
MutableLiveData<TypingState> notifier = Util.getOrDefault(notifiers, threadId, new MutableLiveData<>());
notifiers.put(threadId, notifier);
Set<Recipient> uniqueTypists = new LinkedHashSet<>();
for (Typist typist : typists) {
uniqueTypists.add(typist.getAuthor());
}
notifier.postValue(new TypingState(new ArrayList<>(uniqueTypists), isReplacedByIncomingMessage));
Set<Long> activeThreads = Stream.of(typistMap.keySet()).filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet());
threadsNotifier.postValue(activeThreads);
}
public static class TypingState {
private final List<Recipient> typists;
private final boolean replacedByIncomingMessage;
public TypingState(List<Recipient> typists, boolean replacedByIncomingMessage) {
this.typists = typists;
this.replacedByIncomingMessage = replacedByIncomingMessage;
}
public List<Recipient> 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;
}
}
}

View File

@ -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<Long, TimerPair> 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;
}
}
}

View File

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

View File

@ -71,6 +71,10 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
this.header = header;
}
public View getHeaderView() {
return this.header;
}
public void setFooterView(@Nullable View footer) {
this.footer = footer;
}

View File

@ -6,6 +6,7 @@ import org.thoughtcrime.securesms.gcm.GcmBroadcastReceiver;
import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob;
import org.thoughtcrime.securesms.jobs.RefreshUnidentifiedDeliveryAbilityJob;
import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob;
import org.thoughtcrime.securesms.jobs.TypingSendJob;
import org.thoughtcrime.securesms.logging.Log;
import org.greenrobot.eventbus.EventBus;
@ -93,7 +94,8 @@ import dagger.Provides;
SendDeliveryReceiptJob.class,
RotateProfileKeyJob.class,
MultiDeviceConfigurationUpdateJob.class,
RefreshUnidentifiedDeliveryAbilityJob.class})
RefreshUnidentifiedDeliveryAbilityJob.class,
TypingSendJob.class})
public class SignalCommunicationModule {
private static final String TAG = SignalCommunicationModule.class.getSimpleName();

View File

@ -30,36 +30,41 @@ public class MultiDeviceConfigurationUpdateJob extends ContextJob implements Inj
private static final String TAG = MultiDeviceConfigurationUpdateJob.class.getSimpleName();
private static final String KEY_READ_RECEIPTS_ENABLED = "read_receipts_enabled";
private static final String KEY_TYPING_INDICATORS_ENABLED = "typing_indicators_enabled";
private static final String KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED = "unidentified_delivery_indicators_enabled";
@Inject transient SignalServiceMessageSender messageSender;
private boolean readReceiptsEnabled;
private boolean typingIndicatorsEnabled;
private boolean unidentifiedDeliveryIndicatorsEnabled;
public MultiDeviceConfigurationUpdateJob() {
super(null, null);
}
public MultiDeviceConfigurationUpdateJob(Context context, boolean readReceiptsEnabled, boolean unidentifiedDeliveryIndicatorsEnabled) {
public MultiDeviceConfigurationUpdateJob(Context context, boolean readReceiptsEnabled, boolean typingIndicatorsEnabled, boolean unidentifiedDeliveryIndicatorsEnabled) {
super(context, JobParameters.newBuilder()
.withGroupId("__MULTI_DEVICE_CONFIGURATION_UPDATE_JOB__")
.withNetworkRequirement()
.create());
this.readReceiptsEnabled = readReceiptsEnabled;
this.typingIndicatorsEnabled = typingIndicatorsEnabled;
this.unidentifiedDeliveryIndicatorsEnabled = unidentifiedDeliveryIndicatorsEnabled;
}
@Override
protected void initialize(@NonNull SafeData data) {
readReceiptsEnabled = data.getBoolean(KEY_READ_RECEIPTS_ENABLED);
typingIndicatorsEnabled = data.getBoolean(KEY_TYPING_INDICATORS_ENABLED);
unidentifiedDeliveryIndicatorsEnabled = data.getBoolean(KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED);
}
@Override
protected @NonNull Data serialize(@NonNull Data.Builder dataBuilder) {
return dataBuilder.putBoolean(KEY_READ_RECEIPTS_ENABLED, readReceiptsEnabled)
.putBoolean(KEY_TYPING_INDICATORS_ENABLED, typingIndicatorsEnabled)
.putBoolean(KEY_UNIDENTIFIED_DELIVERY_INDICATORS_ENABLED, unidentifiedDeliveryIndicatorsEnabled)
.build();
}
@ -71,7 +76,9 @@ public class MultiDeviceConfigurationUpdateJob extends ContextJob implements Inj
return;
}
messageSender.sendMessage(SignalServiceSyncMessage.forConfiguration(new ConfigurationMessage(Optional.of(readReceiptsEnabled), Optional.of(unidentifiedDeliveryIndicatorsEnabled), Optional.absent())),
messageSender.sendMessage(SignalServiceSyncMessage.forConfiguration(new ConfigurationMessage(Optional.of(readReceiptsEnabled),
Optional.of(unidentifiedDeliveryIndicatorsEnabled),
Optional.of(typingIndicatorsEnabled))),
UnidentifiedAccessUtil.getAccessForSync(context));
}

View File

@ -83,6 +83,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
import org.whispersystems.signalservice.api.messages.calls.AnswerMessage;
import org.whispersystems.signalservice.api.messages.calls.BusyMessage;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
@ -268,6 +269,8 @@ public class PushDecryptJob extends ContextJob {
if (message.isReadReceipt()) handleReadReceipt(content, message);
else if (message.isDeliveryReceipt()) handleDeliveryReceipt(content, message);
} else if (content.getTypingMessage().isPresent()) {
handleTypingMessage(content, content.getTypingMessage().get());
} else {
Log.w(TAG, "Got unrecognized message...");
}
@ -576,6 +579,7 @@ public class PushDecryptJob extends ContextJob {
.getJobManager()
.add(new MultiDeviceConfigurationUpdateJob(getContext(),
TextSecurePreferences.isReadReceiptsEnabled(getContext()),
TextSecurePreferences.isTypingIndicatorsEnabled(getContext()),
TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext())));
}
}
@ -609,6 +613,8 @@ public class PushDecryptJob extends ContextJob {
@NonNull Optional<Long> smsMessageId)
throws StorageFailedException
{
notifyTypingStoppedFromIncomingMessage(getMessageDestination(content, message), content.getSender(), content.getSenderDevice());
try {
MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
Optional<QuoteModel> 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<QuoteModel> getValidatedQuote(Optional<SignalServiceDataMessage.Quote> 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.");

View File

@ -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<Recipient> recipients = Collections.singletonList(recipient);
Optional<byte[]> groupId = Optional.absent();
if (recipient.isGroupRecipient()) {
recipients = DatabaseFactory.getGroupDatabase(context).getGroupMembers(recipient.getAddress().toGroupString(), false);
groupId = Optional.of(GroupUtil.getDecodedId(recipient.getAddress().toGroupString()));
}
List<SignalServiceAddress> addresses = Stream.of(recipients).map(r -> new SignalServiceAddress(r.getAddress().serialize())).toList();
List<Optional<UnidentifiedAccessPair>> 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;
}
}

View File

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

View File

@ -163,7 +163,7 @@ class SearchListAdapter extends RecyclerView.Adapter<SearchListAdapter.Search
@NonNull Locale locale,
@Nullable String query)
{
root.bind(conversationResult, glideRequests, locale, Collections.emptySet(), false, query);
root.bind(conversationResult, glideRequests, locale, Collections.emptySet(), Collections.emptySet(), false, query);
root.setOnClickListener(view -> eventListener.onConversationClicked(conversationResult));
}

View File

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

View File

@ -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 <K, V> V getOrDefault(@NonNull Map<K, V> 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();