From 7447ed2eac45ff2371cd5f6a154e86aa82409d05 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 4 Apr 2024 16:43:59 -0400 Subject: [PATCH] Add the ability to jump to a specific date in search. --- .../ConversationSearchBottomBar.java | 9 ++ .../conversation/v2/ConversationFragment.kt | 68 ++++++++-- .../conversation/v2/ConversationRepository.kt | 11 +- .../conversation/v2/ConversationViewModel.kt | 20 ++- .../conversation/v2/JumpToDateValidator.kt | 126 ++++++++++++++++++ .../securesms/database/MessageTable.kt | 35 +++-- .../drawable/symbol_calendar_search_24.xml | 12 ++ .../res/layout/conversation_search_nav.xml | 13 ++ .../java/org/signal/core/util/Stopwatch.kt | 12 ++ 9 files changed, 283 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/JumpToDateValidator.kt create mode 100644 app/src/main/res/drawable/symbol_calendar_search_24.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationSearchBottomBar.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationSearchBottomBar.java index 843dcd9312..0749791429 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationSearchBottomBar.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationSearchBottomBar.java @@ -22,6 +22,7 @@ public class ConversationSearchBottomBar extends ConstraintLayout { private View searchUp; private TextView searchPositionText; private View progressWheel; + private View jumpToDateButton; private EventListener eventListener; @@ -42,6 +43,7 @@ public class ConversationSearchBottomBar extends ConstraintLayout { this.searchDown = findViewById(R.id.conversation_search_down); this.searchPositionText = findViewById(R.id.conversation_search_position); this.progressWheel = findViewById(R.id.conversation_search_progress_wheel); + this.jumpToDateButton = findViewById(R.id.conversation_jump_to_date_button); } public void setData(int position, int count) { @@ -65,6 +67,12 @@ public class ConversationSearchBottomBar extends ConstraintLayout { searchPositionText.setText(R.string.ConversationActivity_no_results); } + jumpToDateButton.setOnClickListener(v -> { + if (eventListener != null) { + eventListener.onDatePickerSelected(); + } + }); + setViewEnabled(searchUp, position < (count - 1)); setViewEnabled(searchDown, position > 0); } @@ -85,5 +93,6 @@ public class ConversationSearchBottomBar extends ConstraintLayout { public interface EventListener { void onSearchMoveUpPressed(); void onSearchMoveDownPressed(); + void onDatePickerSelected(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 1b29b4fefe..416dd94718 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -67,6 +67,8 @@ import androidx.recyclerview.widget.ConversationLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide +import com.google.android.material.datepicker.CalendarConstraints +import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.BaseTransientBottomBar.Duration import com.google.android.material.snackbar.Snackbar @@ -305,6 +307,8 @@ import org.thoughtcrime.securesms.util.StorageUtil import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.WindowUtil +import org.thoughtcrime.securesms.util.atMidnight +import org.thoughtcrime.securesms.util.atUTC import org.thoughtcrime.securesms.util.createActivityViewModel import org.thoughtcrime.securesms.util.doAfterNextLayout import org.thoughtcrime.securesms.util.fragments.requireListener @@ -314,12 +318,14 @@ import org.thoughtcrime.securesms.util.hasGiftBadge import org.thoughtcrime.securesms.util.hasNonTextSlide import org.thoughtcrime.securesms.util.isValidReactionTarget import org.thoughtcrime.securesms.util.savedStateViewModel +import org.thoughtcrime.securesms.util.toMillis import org.thoughtcrime.securesms.util.viewModel import org.thoughtcrime.securesms.util.views.Stub import org.thoughtcrime.securesms.util.visible import org.thoughtcrime.securesms.verify.VerifyIdentityActivity import org.thoughtcrime.securesms.wallpaper.ChatWallpaper import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil +import java.time.LocalDateTime import java.util.Locale import java.util.Optional import java.util.concurrent.ExecutionException @@ -1616,7 +1622,7 @@ class ConversationFragment : if (result.results.isNotEmpty()) { val messageResult = result.results[result.position] disposables += viewModel - .moveToSearchResult(messageResult) + .moveToDate(messageResult.receivedTimestampMs) .observeOn(AndroidSchedulers.mainThread()) .subscribeBy { moveToPosition(it) @@ -2457,6 +2463,16 @@ class ConversationFragment : return isScrolledToBottom() || layoutManager.findFirstVisibleItemPosition() <= 0 } + private fun closeChatSearch() { + isSearchRequested = false + searchViewModel.onSearchClosed() + searchNav.visible = false + inputPanel.setHideForSearch(false) + viewModel.setSearchQuery(null) + binding.conversationDisabledInput.visible = true + invalidateOptionsMenu() + } + /** * Controls animation and visibility of the scrollDateHeader. */ @@ -3199,6 +3215,7 @@ class ConversationFragment : searchNav.visible = true searchNav.setData(0, 0) inputPanel.setHideForSearch(true) + viewModel.onChatSearchOpened() binding.conversationDisabledInput.visible = false (0 until menu.size()).forEach { @@ -3212,13 +3229,7 @@ class ConversationFragment : override fun onMenuItemActionCollapse(item: MenuItem): Boolean { searchView.setOnQueryTextListener(null) - isSearchRequested = false - searchViewModel.onSearchClosed() - searchNav.visible = false - inputPanel.setHideForSearch(false) - binding.conversationDisabledInput.visible = true - viewModel.setSearchQuery(null) - invalidateOptionsMenu() + closeChatSearch() return true } }) @@ -4153,6 +4164,47 @@ class ConversationFragment : override fun onSearchMoveDownPressed() { searchViewModel.onMoveDown() } + + override fun onDatePickerSelected() { + disposables += viewModel.getEarliestMessageDate().subscribe { earliestDate -> + val local = LocalDateTime.now() + .atMidnight() + .atUTC() + .toMillis() + val datePicker = + MaterialDatePicker.Builder + .datePicker() + .setTitleText(getString(R.string.ScheduleMessageTimePickerBottomSheet__select_date_title)) + .setSelection(local) + .setCalendarConstraints( + CalendarConstraints.Builder() + .setValidator(viewModel.jumpToDateValidator) + .setStart(earliestDate) + .setEnd(local) + .build() + ) + .build() + + datePicker.addOnDismissListener { + datePicker.clearOnDismissListeners() + datePicker.clearOnPositiveButtonClickListeners() + } + + datePicker.addOnPositiveButtonClickListener { selectedDate -> + if (selectedDate != null) { + disposables += viewModel + .moveToDate(selectedDate) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy { position -> + moveToPosition(position - 1) + closeChatSearch() + } + } + } + + datePicker.show(childFragmentManager, "DATE_PICKER") + } + } } private inner class ToolbarDependentMarginListener(private val toolbar: Toolbar) : ViewTreeObserver.OnGlobalLayoutListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index 896be87bbc..b0ee8e6ec1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -86,7 +86,6 @@ import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId -import org.thoughtcrime.securesms.search.MessageResult import org.thoughtcrime.securesms.sms.MessageSender import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult import org.thoughtcrime.securesms.util.BitmapUtil @@ -265,9 +264,9 @@ class ConversationRepository( }.subscribeOn(Schedulers.io()) } - fun getMessageResultPosition(threadId: Long, messageResult: MessageResult): Single { + fun getMessageResultPosition(threadId: Long, receivedTimestamp: Long): Single { return Single.fromCallable { - SignalDatabase.messages.getMessagePositionInConversation(threadId, messageResult.receivedTimestampMs) + SignalDatabase.messages.getMessagePositionInConversation(threadId, receivedTimestamp) }.subscribeOn(Schedulers.io()) } @@ -580,6 +579,12 @@ class ConversationRepository( } } + fun getEarliestMessageDate(threadId: Long): Single { + return Single + .fromCallable { SignalDatabase.messages.getEarliestMessageDate(threadId) } + .subscribeOn(Schedulers.io()) + } + /** * Glide target for a contact photo which expects an error drawable, and publishes * the result to the given emitter. diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 0814eba5ae..695744c056 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -64,7 +64,6 @@ import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId -import org.thoughtcrime.securesms.search.MessageResult import org.thoughtcrime.securesms.sms.MessageSender import org.thoughtcrime.securesms.util.BubbleUtil import org.thoughtcrime.securesms.util.ConversationUtil @@ -158,6 +157,10 @@ class ConversationViewModel( private val startExpiration = BehaviorSubject.create() + private val _jumpToDateValidator: JumpToDateValidator by lazy { JumpToDateValidator(threadId) } + val jumpToDateValidator: JumpToDateValidator + get() = _jumpToDateValidator + init { disposables += recipient .subscribeBy { @@ -312,8 +315,8 @@ class ConversationViewModel( return repository.getQuotedMessagePosition(threadId, quote) } - fun moveToSearchResult(messageResult: MessageResult): Single { - return repository.getMessageResultPosition(threadId, messageResult) + fun moveToDate(receivedTimestamp: Long): Single { + return repository.getMessageResultPosition(threadId, receivedTimestamp) } fun getNextMentionPosition(): Single { @@ -507,4 +510,15 @@ class ConversationViewModel( fun markLastSeen() { repository.markLastSeen(threadId) } + + fun onChatSearchOpened() { + // Trigger the lazy load, so we can race initialization of the validator + _jumpToDateValidator + } + + fun getEarliestMessageDate(): Single { + return repository + .getEarliestMessageDate(threadId) + .observeOn(AndroidSchedulers.mainThread()) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/JumpToDateValidator.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/JumpToDateValidator.kt new file mode 100644 index 0000000000..78c3c9aa81 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/JumpToDateValidator.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import com.google.android.material.datepicker.CalendarConstraints.DateValidator +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.logTime +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.util.LRUCache +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.temporal.TemporalAdjusters +import java.util.concurrent.locks.Condition +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlin.time.Duration.Companion.days + +/** + * A calendar validator for jumping to a specific date in a conversation. + * This is used to prevent the user from jumping to a date where there are no messages. + * + * [isValid] is called on the main thread, so we try to race it and fetch the data ahead of time, fetching data in bulk and caching it. + */ +@Parcelize +class JumpToDateValidator(val threadId: Long) : DateValidator { + + companion object { + private val TAG = Log.tag(JumpToDateValidator::class.java) + } + + @IgnoredOnParcel + private val lock = ReentrantLock() + + @IgnoredOnParcel + private val condition: Condition = lock.newCondition() + + @IgnoredOnParcel + private val cachedDates: MutableMap = LRUCache(500) + + init { + val startOfDay = LocalDateTime.now(ZoneOffset.UTC).withHour(0).withMinute(0).withSecond(0).withNano(0).toInstant(ZoneOffset.UTC).toEpochMilli() + loadAround(startOfDay, allowPrefetch = true) + } + + override fun isValid(dateStart: Long): Boolean { + return lock.withLock { + var value = cachedDates[dateStart] + + while (value == null || value == LookupState.PENDING) { + loadAround(dateStart, allowPrefetch = true) + condition.await() + value = cachedDates[dateStart] + } + + cachedDates[dateStart] == LookupState.FOUND + } + } + + /** + * Given a date, this will load all of the dates for entire month the date is in. + */ + private fun loadAround(dateStart: Long, allowPrefetch: Boolean) { + SignalExecutors.BOUNDED.execute { + val startOfDay = LocalDateTime.ofInstant(Instant.ofEpochMilli(dateStart), ZoneOffset.UTC) + + val startOfMonth = startOfDay + .with(TemporalAdjusters.firstDayOfMonth()) + .withHour(0).withMinute(0).withSecond(0).withNano(0) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + + val endOfMonth = startOfDay + .with(TemporalAdjusters.lastDayOfMonth()) + .withHour(0).withMinute(0).withSecond(0).withNano(0) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + + val daysOfMonth = (startOfMonth..endOfMonth step 1.days.inWholeMilliseconds).toSet() + dateStart + + val lookupsNeeded = lock.withLock { + daysOfMonth + .filter { !cachedDates.containsKey(it) } + .onEach { cachedDates[it] = LookupState.PENDING } + } + + if (lookupsNeeded.isEmpty()) { + return@execute + } + + val existence = logTime(TAG, "query(${lookupsNeeded.size})", decimalPlaces = 2) { + SignalDatabase.messages.messageExistsOnDays(threadId, lookupsNeeded) + } + + lock.withLock { + cachedDates.putAll(existence.mapValues { if (it.value) LookupState.FOUND else LookupState.NOT_FOUND }) + + if (allowPrefetch) { + val dayInPreviousMonth = startOfMonth - 1.days.inWholeMilliseconds + if (!cachedDates.containsKey(dayInPreviousMonth)) { + loadAround(dayInPreviousMonth, allowPrefetch = false) + } + + val dayInNextMonth = endOfMonth + 1.days.inWholeMilliseconds + if (!cachedDates.containsKey(dayInNextMonth)) { + loadAround(dayInNextMonth, allowPrefetch = false) + } + } + + condition.signalAll() + } + } + } + + private enum class LookupState { + FOUND, + NOT_FOUND, + PENDING + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index c90c9c671f..d78cbefbfc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -3959,6 +3959,29 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat return getMessagePositionInConversation(threadId, 0, receivedTimestamp) } + fun messageExistsOnDays(threadId: Long, dayStarts: Collection): Map { + if (dayStarts.isEmpty()) { + return emptyMap() + } + return dayStarts.associateWith { startOfDay -> + readableDatabase + .exists(TABLE_NAME) + .where("$THREAD_ID = $threadId AND $DATE_RECEIVED >= $startOfDay AND $DATE_RECEIVED < $startOfDay + 86400000 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0") + .run() + } + } + + fun getEarliestMessageDate(threadId: Long): Long { + return readableDatabase + .select(DATE_RECEIVED) + .from(TABLE_NAME) + .where("$THREAD_ID = $threadId AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0") + .orderBy("$DATE_RECEIVED ASC") + .limit(1) + .run() + .readToSingleLong(0) + } + /** * Retrieves the position of the message with the provided timestamp in the query results you'd * get from calling [.getConversation]. @@ -3970,22 +3993,16 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat * @param groupStoryId Ignored if passed value is <= 0 */ fun getMessagePositionInConversation(threadId: Long, groupStoryId: Long, receivedTimestamp: Long): Int { - val order: String - val selection: String - - if (groupStoryId > 0) { - order = "$DATE_RECEIVED ASC" - selection = "$THREAD_ID = $threadId AND $DATE_RECEIVED < $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID = $groupStoryId AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL" + val selection = if (groupStoryId > 0) { + "$THREAD_ID = $threadId AND $DATE_RECEIVED < $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID = $groupStoryId AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL" } else { - order = "$DATE_RECEIVED DESC" - selection = "$THREAD_ID = $threadId AND $DATE_RECEIVED > $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL" + "$THREAD_ID = $threadId AND $DATE_RECEIVED > $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL" } return readableDatabase .select("COUNT(*)") .from(TABLE_NAME) .where(selection) - .orderBy(order) .run() .readToSingleInt(-1) } diff --git a/app/src/main/res/drawable/symbol_calendar_search_24.xml b/app/src/main/res/drawable/symbol_calendar_search_24.xml new file mode 100644 index 0000000000..696eec47ab --- /dev/null +++ b/app/src/main/res/drawable/symbol_calendar_search_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/conversation_search_nav.xml b/app/src/main/res/layout/conversation_search_nav.xml index f85aa49931..c75a84744b 100644 --- a/app/src/main/res/layout/conversation_search_nav.xml +++ b/app/src/main/res/layout/conversation_search_nav.xml @@ -11,6 +11,19 @@ tools:visibility="visible" tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> + + logTime(tag: String, label: String, decimalPlaces: Int = 0, block: () -> T): T { + val result = measureTimedValue(block) + Log.d(tag, "$label: ${result.duration.toDouble(DurationUnit.MILLISECONDS).roundedString(decimalPlaces)}") + return result.value +}