From a3e3667dc286f7dcc1f6400956b37054394d9130 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 9 Jun 2021 16:35:36 -0300 Subject: [PATCH] Add 'tick' to update conversation bubble timestamps every 1m. --- .../securesms/BindableConversationItem.java | 4 + .../conversation/ConversationAdapter.java | 26 +++++- .../conversation/ConversationFragment.java | 11 +++ .../conversation/ConversationItem.java | 5 ++ .../conversation/ConversationUpdateTick.kt | 51 +++++++++++ .../ConversationUpdateTickTest.kt | 88 +++++++++++++++++++ 6 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateTick.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/conversation/ConversationUpdateTickTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index f4c7c002dd..9104e8cb4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -52,6 +52,10 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, void setEventListener(@Nullable EventListener listener); + default void updateTimestamps() { + // Intentionally Blank. + } + interface EventListener { void onQuoteClicked(MmsMessageRecord messageRecord); void onLinkPreviewClicked(@NonNull LinkPreview linkPreview); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index 1461cfbd76..1662978b1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -48,12 +48,12 @@ import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer; -import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.CachedInflater; import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.Util; @@ -100,6 +100,8 @@ public class ConversationAdapter private static final int MESSAGE_TYPE_FOOTER = 6; private static final int MESSAGE_TYPE_PLACEHOLDER = 7; + private static final int PAYLOAD_TIMESTAMP = 0; + private static final long HEADER_ID = Long.MIN_VALUE; private static final long FOOTER_ID = Long.MIN_VALUE + 1; @@ -247,6 +249,24 @@ public class ConversationAdapter } } + @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List payloads) { + if (payloads.contains(PAYLOAD_TIMESTAMP)) { + switch (getItemViewType(position)) { + case MESSAGE_TYPE_INCOMING_TEXT: + case MESSAGE_TYPE_INCOMING_MULTIMEDIA: + case MESSAGE_TYPE_OUTGOING_TEXT: + case MESSAGE_TYPE_OUTGOING_MULTIMEDIA: + case MESSAGE_TYPE_UPDATE: + ConversationViewHolder conversationViewHolder = (ConversationViewHolder) holder; + conversationViewHolder.getBindable().updateTimestamps(); + default: + return; + } + } else { + super.onBindViewHolder(holder, position, payloads); + } + } + @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { switch (getItemViewType(position)) { @@ -640,6 +660,10 @@ public class ConversationAdapter } } + public void updateTimestamps() { + notifyItemRangeChanged(0, getItemCount(), PAYLOAD_TIMESTAMP); + } + final static class ConversationViewHolder extends RecyclerView.ViewHolder implements GiphyMp4Playable, Colorizable { public ConversationViewHolder(final @NonNull View itemView) { super(itemView); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index afa90d91e5..d327308c32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -221,6 +221,7 @@ public class ConversationFragment extends LoggingFragment { private GiphyMp4ProjectionRecycler giphyMp4ProjectionRecycler; private Colorizer colorizer; + private ConversationUpdateTick conversationUpdateTick; public static void prepare(@NonNull Context context) { FrameLayout parent = new FrameLayout(context); @@ -332,6 +333,9 @@ public class ConversationFragment extends LoggingFragment { } }); + conversationUpdateTick = new ConversationUpdateTick(this::updateConversationItemTimestamps); + getViewLifecycleOwner().getLifecycle().addObserver(conversationUpdateTick); + return view; } @@ -387,6 +391,13 @@ public class ConversationFragment extends LoggingFragment { listener.onListVerticalTranslationChanged(list.getTranslationY() - offset); } + private void updateConversationItemTimestamps() { + ConversationAdapter conversationAdapter = getListAdapter(); + if (conversationAdapter != null) { + getListAdapter().updateTimestamps(); + } + } + @Override public void onActivityCreated(Bundle bundle) { super.onActivityCreated(bundle); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index d907a16b5b..8d865f91af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -323,6 +323,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper); } + @Override + public void updateTimestamps() { + getActiveFooter(messageRecord).setMessageRecord(messageRecord, locale); + } + @Override protected void onDetachedFromWindow() { ConversationSwipeAnimationHelper.update(this, 0f, 1f); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateTick.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateTick.kt new file mode 100644 index 0000000000..a2486d535f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateTick.kt @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.conversation + +import android.os.Handler +import android.os.Looper +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import java.util.concurrent.TimeUnit + +/** + * Lifecycle-aware class which will call onTick every 1 minute. + * Used to ensure that conversation timestamps are updated appropriately. + */ +class ConversationUpdateTick( + private val onTickListener: OnTickListener +) : DefaultLifecycleObserver { + + private val handler = Handler(Looper.getMainLooper()) + private var isResumed = false + + override fun onResume(owner: LifecycleOwner) { + isResumed = true + + handler.removeCallbacksAndMessages(null) + handler.postDelayed(this::onTick, TIMEOUT) + } + + override fun onPause(owner: LifecycleOwner) { + isResumed = false + + handler.removeCallbacksAndMessages(null) + } + + private fun onTick() { + if (isResumed) { + onTickListener.onTick() + + handler.removeCallbacksAndMessages(null) + handler.postDelayed(this::onTick, TIMEOUT) + } + } + + interface OnTickListener { + fun onTick() + } + + companion object { + @VisibleForTesting + val TIMEOUT = TimeUnit.MINUTES.toMillis(1) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/ConversationUpdateTickTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/ConversationUpdateTickTest.kt new file mode 100644 index 0000000000..7cf6ef0175 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/ConversationUpdateTickTest.kt @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.conversation + +import android.app.Application +import androidx.lifecycle.LifecycleOwner +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLooper +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class ConversationUpdateTickTest { + + private val lifecycleOwner = mock(LifecycleOwner::class.java) + private val listener = mock(ConversationUpdateTick.OnTickListener::class.java) + private val testSubject = ConversationUpdateTick(listener) + + private val timeoutMillis = ConversationUpdateTick.TIMEOUT + + @Test + fun `Given onResume not invoked, then I expect zero invocations of onTick`() { + // THEN + verify(listener, never()).onTick() + } + + @Test + fun `Given no time has passed after onResume is invoked, then I expect zero invocations of onTick`() { + // GIVEN + ShadowLooper.pauseMainLooper() + testSubject.onResume(lifecycleOwner) + + // THEN + verify(listener, never()).onTick() + } + + @Test + fun `Given onResume is invoked, when half timeout passes, then I expect zero invocations of onTick`() { + // GIVEN + testSubject.onResume(lifecycleOwner) + ShadowLooper.idleMainLooper(timeoutMillis / 2, TimeUnit.MILLISECONDS) + + // THEN + verify(listener, never()).onTick() + } + + @Test + fun `Given onResume is invoked, when timeout passes, then I expect one invocation of onTick`() { + // GIVEN + testSubject.onResume(lifecycleOwner) + + // WHEN + ShadowLooper.idleMainLooper(timeoutMillis, TimeUnit.MILLISECONDS) + + // THEN + verify(listener, times(1)).onTick() + } + + @Test + fun `Given onResume is invoked, when timeout passes five times, then I expect five invocations of onTick`() { + // GIVEN + testSubject.onResume(lifecycleOwner) + + // WHEN + ShadowLooper.idleMainLooper(timeoutMillis * 5, TimeUnit.MILLISECONDS) + + // THEN + verify(listener, times(5)).onTick() + } + + @Test + fun `Given onResume then onPause is invoked, when timeout passes, then I expect no invocations of onTick`() { + // GIVEN + testSubject.onResume(lifecycleOwner) + testSubject.onPause(lifecycleOwner) + + // WHEN + ShadowLooper.idleMainLooper(timeoutMillis, TimeUnit.MILLISECONDS) + + // THEN + verify(listener, never()).onTick() + } +}