mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-24 11:45:28 +00:00
Add the ability to jump to a specific date in search.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user