Add the ability to jump to a specific date in search.

This commit is contained in:
Greyson Parrelli 2024-04-04 16:43:59 -04:00
parent d5bf16b91a
commit 7447ed2eac
9 changed files with 283 additions and 23 deletions

View file

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

View file

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

View file

@ -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<Int> {
fun getMessageResultPosition(threadId: Long, receivedTimestamp: Long): Single<Int> {
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<Long> {
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.

View file

@ -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<MessageTable.ExpirationInfo>()
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<Int> {
return repository.getMessageResultPosition(threadId, messageResult)
fun moveToDate(receivedTimestamp: Long): Single<Int> {
return repository.getMessageResultPosition(threadId, receivedTimestamp)
}
fun getNextMentionPosition(): Single<Int> {
@ -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<Long> {
return repository
.getEarliestMessageDate(threadId)
.observeOn(AndroidSchedulers.mainThread())
}
}

View file

@ -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<Long, LookupState> = 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
}
}

View file

@ -3959,6 +3959,29 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return getMessagePositionInConversation(threadId, 0, receivedTimestamp)
}
fun messageExistsOnDays(threadId: Long, dayStarts: Collection<Long>): Map<Long, Boolean> {
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)
}

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M6.5 0.75c0.55 0 1 0.45 1 1v1.88h7V1.75c0-0.55 0.45-1 1-1s1 0.45 1 1v1.88c0.36 0 0.68 0 0.96 0.03 0.47 0.04 0.91 0.12 1.32 0.33 0.64 0.33 1.15 0.84 1.48 1.48 0.2 0.4 0.29 0.85 0.33 1.32 0.04 0.45 0.04 1 0.04 1.67v2.04c0 0.48-0.4 0.88-0.88 0.88s-0.88-0.4-0.88-0.88V9.62H3.13V17c0 0.71 0 1.2 0.04 1.57 0.03 0.36 0.08 0.54 0.14 0.67 0.16 0.3 0.4 0.55 0.71 0.7 0.13 0.07 0.3 0.12 0.67 0.15 0.37 0.03 0.86 0.04 1.57 0.04H11c0.48 0 0.88 0.39 0.88 0.87s-0.4 0.88-0.88 0.88H6.21c-0.67 0-1.22 0-1.67-0.04-0.47-0.04-0.91-0.12-1.32-0.33-0.64-0.33-1.15-0.84-1.48-1.48-0.2-0.4-0.29-0.85-0.33-1.32-0.04-0.45-0.04-1-0.03-1.67V8.46c0-0.67 0-1.22 0.03-1.67 0.04-0.47 0.12-0.91 0.33-1.32 0.33-0.64 0.84-1.15 1.48-1.48 0.4-0.2 0.85-0.29 1.32-0.33 0.28-0.02 0.6-0.03 0.96-0.03V1.75c0-0.55 0.45-1 1-1Zm12.37 7.13c0-0.4 0-0.7-0.03-0.95-0.03-0.36-0.08-0.54-0.14-0.67-0.16-0.3-0.4-0.55-0.71-0.7-0.13-0.07-0.3-0.12-0.67-0.15-0.37-0.03-0.86-0.04-1.57-0.04h-9.5c-0.71 0-1.2 0-1.57 0.04C4.32 5.44 4.14 5.49 4 5.55c-0.3 0.16-0.55 0.4-0.7 0.71-0.07 0.13-0.12 0.3-0.15 0.67-0.02 0.25-0.03 0.55-0.03 0.95h15.74Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M12.38 17c0-2.55 2.07-4.63 4.62-4.63 2.55 0 4.63 2.08 4.63 4.63 0 0.96-0.3 1.85-0.8 2.6l2.54 2.53c0.34 0.34 0.34 0.9 0 1.24-0.34 0.34-0.9 0.34-1.24 0l-2.54-2.54c-0.74 0.5-1.63 0.8-2.59 0.8-2.55 0-4.63-2.08-4.63-4.63ZM17 14.12c-1.59 0-2.88 1.3-2.88 2.88 0 1.59 1.3 2.88 2.88 2.88 1.59 0 2.88-1.3 2.88-2.88 0-1.59-1.3-2.88-2.88-2.88Z"/>
</vector>

View file

@ -11,6 +11,19 @@
tools:visibility="visible"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/conversation_jump_to_date_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/symbol_calendar_search_24"
android:tint="@color/signal_colorOnSurface"
android:background="?selectableItemBackgroundBorderless"
android:layout_marginStart="16dp"
android:padding="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/conversation_search_position"
style="@style/Signal.Text.Body"

View file

@ -8,6 +8,8 @@ package org.signal.core.util
import org.signal.core.util.logging.Log
import kotlin.time.Duration.Companion.nanoseconds
import kotlin.time.DurationUnit
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
/**
* Simple utility to easily track the time a multi-step operation takes via splits.
@ -77,3 +79,13 @@ class Stopwatch @JvmOverloads constructor(private val title: String, private val
}
}
}
/**
* Logs how long it takes to perform the operation.
*/
@OptIn(ExperimentalTime::class)
inline fun <T> 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
}