mirror of
https://github.com/oxen-io/session-android.git
synced 2024-12-24 16:57:50 +00:00
Add support for typing indicators.
This commit is contained in:
parent
3f25fb7d5f
commit
776b0e23ae
@ -149,6 +149,7 @@ dependencies {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Update dependencies after getting final libsignal
|
||||
dependencyVerification {
|
||||
verify = [
|
||||
'com.android.support:design:7874ad1904eedc74aa41cffffb7f759d8990056f3bbbc9264911651c67c42f5f',
|
||||
|
@ -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"
|
||||
|
@ -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" />
|
||||
|
||||
|
@ -88,22 +88,37 @@
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"/>
|
||||
|
||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
android:id="@+id/subject"
|
||||
<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"
|
||||
android:paddingRight="1dp"
|
||||
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_marginTop="4dp"
|
||||
android:visibility="gone"
|
||||
app:typingIndicator_tint="?conversation_list_typing_tint"/>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<org.thoughtcrime.securesms.components.ThumbnailView
|
||||
android:id="@+id/thumbnail"
|
||||
android:layout_width="40dp"
|
||||
|
36
res/layout/conversation_typing_view.xml
Normal file
36
res/layout/conversation_typing_view.xml
Normal 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>
|
77
res/layout/experience_upgrade_typing_indicators_fragment.xml
Normal file
77
res/layout/experience_upgrade_typing_indicators_fragment.xml
Normal 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>
|
40
res/layout/typing_indicator_view.xml
Normal file
40
res/layout/typing_indicator_view.xml
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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() {
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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());
|
||||
|
@ -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;
|
||||
@ -75,7 +76,9 @@ public class ConversationListItem extends RelativeLayout
|
||||
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;
|
||||
@ -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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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()));
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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,6 +67,13 @@ 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),
|
||||
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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ public class ReadReceiptsIntroFragment extends Fragment {
|
||||
.getJobManager()
|
||||
.add(new MultiDeviceConfigurationUpdateJob(getContext(),
|
||||
isChecked,
|
||||
TextSecurePreferences.isTypingIndicatorsEnabled(requireContext()),
|
||||
TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext())));
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
|
@ -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.");
|
||||
|
108
src/org/thoughtcrime/securesms/jobs/TypingSendJob.java
Normal file
108
src/org/thoughtcrime/securesms/jobs/TypingSendJob.java
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
|
Loading…
x
Reference in New Issue
Block a user