Simplify and improve the JumpToDateValidator.

This commit is contained in:
Greyson Parrelli
2025-12-10 16:27:44 -05:00
committed by Alex Hart
parent d6ade56233
commit 657a7d2a6b
3 changed files with 292 additions and 66 deletions

View File

@@ -200,7 +200,7 @@ class ConversationViewModel(
private val startExpiration = BehaviorSubject.create<MessageTable.ExpirationInfo>()
private val _jumpToDateValidator: JumpToDateValidator by lazy { JumpToDateValidator(threadId) }
private val _jumpToDateValidator: JumpToDateValidator by lazy { JumpToDateValidator.create(threadId) }
val jumpToDateValidator: JumpToDateValidator
get() = _jumpToDateValidator

View File

@@ -9,8 +9,6 @@ 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
@@ -18,90 +16,136 @@ import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.TemporalAdjusters
import java.util.concurrent.locks.Condition
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import java.util.concurrent.Executor
import kotlin.time.Duration.Companion.days
/**
* Interface for looking up whether messages exist on given dates.
* Allows for easy testing without database dependencies.
*/
private typealias MessageDateLookup = (Collection<Long>) -> Map<Long, Boolean>
/**
* 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.
* If data is not yet cached, we return true to avoid blocking the main thread.
*/
@Parcelize
class JumpToDateValidator(val threadId: Long) : DateValidator {
class JumpToDateValidator private constructor(
private val threadId: Long,
@IgnoredOnParcel private val messageExistanceLookup: MessageDateLookup = createDefaultLookup(threadId),
@IgnoredOnParcel private val executor: Executor = SignalExecutors.BOUNDED,
private val zoneId: ZoneId = ZoneId.systemDefault()
) : DateValidator {
companion object {
private val TAG = Log.tag(JumpToDateValidator::class.java)
fun createDefaultLookup(threadId: Long): MessageDateLookup {
return { dayStarts -> SignalDatabase.messages.messageExistsOnDays(threadId, dayStarts) }
}
@JvmStatic
fun create(threadId: Long): JumpToDateValidator {
return JumpToDateValidator(
threadId = threadId,
messageExistanceLookup = createDefaultLookup(threadId),
executor = SignalExecutors.BOUNDED,
zoneId = ZoneId.systemDefault()
).also {
it.performInitialPrefetch()
}
}
@JvmStatic
fun createForTesting(lookup: MessageDateLookup, executor: Executor, zoneId: ZoneId): JumpToDateValidator {
return JumpToDateValidator(
threadId = -1,
messageExistanceLookup = lookup,
executor = executor,
zoneId = zoneId
).also {
it.performInitialPrefetch()
}
}
}
@IgnoredOnParcel
private val lock = ReentrantLock()
private val cachedDates: MutableMap<Long, LookupState> = LRUCache(1000)
@IgnoredOnParcel
private val condition: Condition = lock.newCondition()
@IgnoredOnParcel
private val cachedDates: MutableMap<Long, LookupState> = LRUCache(500)
init {
val startOfDay = LocalDate.now(ZoneId.systemDefault())
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
loadAround(startOfDay, allowPrefetch = true)
}
private val pendingMonths: MutableSet<Long> = mutableSetOf()
override fun isValid(dateStart: Long): Boolean {
return lock.withLock {
val localMidnightTimestamp = Instant.ofEpochMilli(dateStart)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
val localMidnightTimestamp = normalizeToLocalMidnight(dateStart)
var value = cachedDates[localMidnightTimestamp]
while (value == null || value == LookupState.PENDING) {
loadAround(localMidnightTimestamp, allowPrefetch = true)
condition.await()
value = cachedDates[localMidnightTimestamp]
return synchronized(this) {
when (cachedDates[localMidnightTimestamp]) {
LookupState.FOUND -> true
LookupState.NOT_FOUND -> false
LookupState.PENDING, null -> {
loadAround(localMidnightTimestamp, allowPrefetch = true)
true
}
}
cachedDates[localMidnightTimestamp] == LookupState.FOUND
}
}
private fun performInitialPrefetch() {
val today = LocalDate.now(zoneId)
val monthsToPrefetch = 6
for (i in 0 until monthsToPrefetch) {
val targetMonth = today.minusMonths(i.toLong()).atStartOfDay(zoneId).toInstant().toEpochMilli()
loadAround(targetMonth, allowPrefetch = false)
}
}
private fun normalizeToLocalMidnight(timestamp: Long): Long {
return Instant.ofEpochMilli(timestamp)
.atZone(zoneId)
.toLocalDate()
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli()
}
/**
* 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), ZoneId.systemDefault())
val startOfDay = LocalDateTime.ofInstant(Instant.ofEpochMilli(dateStart), zoneId)
val startOfMonth = startOfDay
.with(TemporalAdjusters.firstDayOfMonth())
.atZone(ZoneId.systemDefault())
.toLocalDate()
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
val startOfMonth = startOfDay
.with(TemporalAdjusters.firstDayOfMonth())
.atZone(zoneId)
.toLocalDate()
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli()
val endOfMonth = startOfDay
.with(TemporalAdjusters.lastDayOfMonth())
.atZone(ZoneId.systemDefault())
.toLocalDate()
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
val endOfMonth = startOfDay
.with(TemporalAdjusters.lastDayOfMonth())
.atZone(zoneId)
.toLocalDate()
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli()
synchronized(this) {
if (pendingMonths.contains(startOfMonth)) {
return
}
pendingMonths.add(startOfMonth)
}
executor.execute {
val daysOfMonth = (startOfMonth..endOfMonth step 1.days.inWholeMilliseconds).toSet() + dateStart
val lookupsNeeded = lock.withLock {
val lookupsNeeded = synchronized(this) {
daysOfMonth
.filter { !cachedDates.containsKey(it) }
.filter { cachedDates[it] == null }
.onEach { cachedDates[it] = LookupState.PENDING }
}
@@ -109,26 +153,18 @@ class JumpToDateValidator(val threadId: Long) : DateValidator {
return@execute
}
val existence = logTime(TAG, "query(${lookupsNeeded.size})", decimalPlaces = 2) {
SignalDatabase.messages.messageExistsOnDays(threadId, lookupsNeeded)
}
val existence = messageExistanceLookup.invoke(lookupsNeeded)
lock.withLock {
synchronized(this) {
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)
}
loadAround(dayInPreviousMonth, allowPrefetch = false)
val dayInNextMonth = endOfMonth + 1.days.inWholeMilliseconds
if (!cachedDates.containsKey(dayInNextMonth)) {
loadAround(dayInNextMonth, allowPrefetch = false)
}
loadAround(dayInNextMonth, allowPrefetch = false)
}
condition.signalAll()
}
}
}