Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,7 @@ object HighlightsEndpoints : HighlightsApi {
color: String,
): Boolean =
httpClient
.put {
highlightsUrl()
.put(highlightsUrl()) {
contentType(ContentType.Application.Json)
buildJsonObject {
put("version_id", versionId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ package com.youversion.platform.core.highlights.domain

import com.youversion.platform.core.bibles.domain.BibleReference
import com.youversion.platform.core.highlights.models.BibleHighlight
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.util.Date
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes

Expand All @@ -24,28 +29,38 @@ object BibleHighlightCache {
)

// ----- Observable State
private val cachedHighlights = mutableListOf<CachedHighlight>()
val highlights: List<CachedHighlight>
get() = cachedHighlights.toList()
private val _highlights = MutableStateFlow<List<CachedHighlight>>(emptyList())
val highlights: StateFlow<List<CachedHighlight>> = _highlights.asStateFlow()

// ----- Throttling and Loading
private var recentChapterFetches = mutableMapOf<BibleReference, Date>()
private var currentlyLoadingChapters = mutableSetOf<BibleReference>()
private val recentChapterFetches = ConcurrentHashMap<BibleReference, Date>()
private val currentlyLoadingChapters = ConcurrentHashMap.newKeySet<BibleReference>()
private val throttlingInterval: Duration = 5.minutes

// ----- Public API - State Management
fun clear() {
cachedHighlights.clear()
_highlights.value = emptyList()
recentChapterFetches.clear()
currentlyLoadingChapters.clear()
}

// ----- Public API - Queries
fun highlights(overlapping: BibleReference): List<BibleHighlight> =
cachedHighlights
_highlights.value
.filter { it.highlight.bibleReference.overlaps(otherReference = overlapping) }
.map { it.highlight }

/**
* Whether the server is known to hold a highlight for [reference], i.e. the cache has an entry for it in any state
* other than [CachedHighlightState.LOCAL_PENDING_CREATE]. Callers use this to tell a recolor of a server-backed
* highlight (which should sync as an update) apart from a recolor of one the server has never seen (which should
* sync as a create).
*/
fun isHighlightServerBacked(reference: BibleReference): Boolean =
_highlights.value.any {
it.highlight.bibleReference == reference && it.state != CachedHighlightState.LOCAL_PENDING_CREATE
}

fun hasRecentlyLoadedChapter(chapter: BibleReference): Boolean {
val chapterKey = normalizeToChapter(chapter)
val lastFetch = recentChapterFetches[chapterKey] ?: return false
Expand All @@ -55,14 +70,8 @@ object BibleHighlightCache {
fun isChapterLoading(chapter: BibleReference): Boolean =
currentlyLoadingChapters.contains(normalizeToChapter(chapter))

fun markChapterAsLoading(chapter: BibleReference): Boolean {
val normalized = normalizeToChapter(chapter)
if (currentlyLoadingChapters.contains(normalized)) {
return false
}
currentlyLoadingChapters.add(normalized)
return true
}
fun markChapterAsLoading(chapter: BibleReference): Boolean =
currentlyLoadingChapters.add(normalizeToChapter(chapter))

fun unmarkChapterAsLoading(chapter: BibleReference) {
currentlyLoadingChapters.remove(normalizeToChapter(chapter))
Expand All @@ -77,65 +86,78 @@ object BibleHighlightCache {

// ----- Public API - Mutations (write APIs preserved)
fun addHighlights(highlights: List<BibleHighlight>) {
for (highlight in highlights) {
// Remove any existing for same exact reference; then append as pending create
cachedHighlights.removeAll { it.highlight.bibleReference == highlight.bibleReference }
cachedHighlights.add(
CachedHighlight(highlight = highlight, state = CachedHighlightState.LOCAL_PENDING_CREATE),
)
_highlights.update { current ->
current.toMutableList().apply {
for (highlight in highlights) {
// Remove any existing for same exact reference; then append as pending create
removeAll { it.highlight.bibleReference == highlight.bibleReference }
add(
CachedHighlight(highlight = highlight, state = CachedHighlightState.LOCAL_PENDING_CREATE),
)
}
}
}
}

fun removeHighlights(references: List<BibleReference>) {
for (reference in references) {
// If there is a pending create for this reference, just drop it; otherwise mark pending delete
val pendingCreateIdx =
cachedHighlights.indexOfFirst {
it.highlight.bibleReference == reference && it.state == CachedHighlightState.LOCAL_PENDING_CREATE
}
if (pendingCreateIdx != -1) {
cachedHighlights.removeAt(pendingCreateIdx)
} else {
val idx = cachedHighlights.indexOfFirst { it.highlight.bibleReference == reference }
if (idx != -1) {
cachedHighlights[idx] =
cachedHighlights[idx].copy(
state = CachedHighlightState.LOCAL_PENDING_DELETE,
lastModifiedAt = Date(),
)
_highlights.update { current ->
current.toMutableList().apply {
for (reference in references) {
// If there is a pending create for this reference, just drop it; otherwise mark pending delete
val pendingCreateIndex =
indexOfFirst {
it.highlight.bibleReference == reference &&
it.state == CachedHighlightState.LOCAL_PENDING_CREATE
}
if (pendingCreateIndex != -1) {
removeAt(pendingCreateIndex)
} else {
val index = indexOfFirst { it.highlight.bibleReference == reference }
if (index != -1) {
this[index] =
this[index].copy(
state = CachedHighlightState.LOCAL_PENDING_DELETE,
lastModifiedAt = Date(),
)
}
}
}
// Physically remove deletes so the visible list hides them
removeAll { it.state == CachedHighlightState.LOCAL_PENDING_DELETE }
}
}
// Optionally, physically remove localPendingDelete from visible list here if UI should hide deletes
cachedHighlights.removeAll { it.state == CachedHighlightState.LOCAL_PENDING_DELETE }
}

fun updateHighlightColors(
references: List<BibleReference>,
newColor: String,
) {
for (reference in references) {
val idx = cachedHighlights.indexOfFirst { it.highlight.bibleReference == reference }
if (idx != -1) {
cachedHighlights[idx] =
cachedHighlights[idx].copy(
highlight = BibleHighlight(bibleReference = reference, hexColor = newColor),
state =
if (cachedHighlights[idx].state != CachedHighlightState.LOCAL_PENDING_CREATE) {
CachedHighlightState.LOCAL_PENDING_UPDATE
} else {
cachedHighlights[idx].state
},
lastModifiedAt = Date(),
)
} else {
// Create if not exists, pending create
cachedHighlights.add(
CachedHighlight(
highlight = BibleHighlight(bibleReference = reference, hexColor = newColor),
state = CachedHighlightState.LOCAL_PENDING_CREATE,
),
)
_highlights.update { current ->
current.toMutableList().apply {
for (reference in references) {
val index = indexOfFirst { it.highlight.bibleReference == reference }
if (index != -1) {
this[index] =
this[index].copy(
highlight = BibleHighlight(bibleReference = reference, hexColor = newColor),
state =
if (this[index].state != CachedHighlightState.LOCAL_PENDING_CREATE) {
CachedHighlightState.LOCAL_PENDING_UPDATE
} else {
this[index].state
},
lastModifiedAt = Date(),
)
} else {
// Create if not exists, pending create
add(
CachedHighlight(
highlight = BibleHighlight(bibleReference = reference, hexColor = newColor),
state = CachedHighlightState.LOCAL_PENDING_CREATE,
),
)
}
}
}
}
}
Expand All @@ -147,22 +169,80 @@ object BibleHighlightCache {
) {
val chapterRef = normalizeToChapter(chapter)

// Remove existing remote-synced highlights for this chapter
cachedHighlights.removeAll { ch ->
ch.state == CachedHighlightState.REMOTE_SYNCED &&
ch.highlight.bibleReference.bookUSFM == chapterRef.bookUSFM &&
ch.highlight.bibleReference.chapter == chapterRef.chapter &&
ch.highlight.bibleReference.versionId == chapterRef.versionId
_highlights.update { current ->
current.toMutableList().apply {
// Remove existing remote-synced highlights for this chapter
removeAll { cached ->
cached.state == CachedHighlightState.REMOTE_SYNCED &&
cached.highlight.bibleReference.bookUSFM == chapterRef.bookUSFM &&
cached.highlight.bibleReference.chapter == chapterRef.chapter &&
cached.highlight.bibleReference.versionId == chapterRef.versionId
}

// Append server highlights as remote-synced, but never alongside a still-pending local write for the
// same reference: keep the optimistic local entry so its edit is not lost, and if it was a pending
// create, mark it a pending update so the queued write now knows the server holds the highlight.
for (highlight in highlights) {
val localIndex =
indexOfFirst {
it.highlight.bibleReference == highlight.bibleReference &&
it.state != CachedHighlightState.REMOTE_SYNCED
}
when {
localIndex == -1 ->
add(
CachedHighlight(
highlight = highlight,
state = CachedHighlightState.REMOTE_SYNCED,
),
)
this[localIndex].state == CachedHighlightState.LOCAL_PENDING_CREATE ->
this[localIndex] = this[localIndex].copy(state = CachedHighlightState.LOCAL_PENDING_UPDATE)
}
}
}
}
}

/**
* Promotes the cached highlights for [references] to [CachedHighlightState.REMOTE_SYNCED] once their write has
* reached the server. Without this a synced highlight stays pending, and the next [applyServerHighlights] merge
* leaves the stale pending row alongside the fresh server copy, so the verse appears twice.
*
* A row is only promoted when it has not been modified after [notModifiedAfter]; a newer local edit (which carries
* its own pending write) is left untouched so its change is not overwritten by this older write's completion.
*/
fun markHighlightsAsSynced(
references: List<BibleReference>,
notModifiedAfter: Date,
) {
val referenceSet = references.toSet()
_highlights.update { current ->
current.map { cached ->
if (cached.highlight.bibleReference in referenceSet &&
cached.state != CachedHighlightState.REMOTE_SYNCED &&
!cached.lastModifiedAt.after(notModifiedAfter)
) {
cached.copy(state = CachedHighlightState.REMOTE_SYNCED)
} else {
cached
}
}
}
}

// Append server highlights as remoteSynced
for (h in highlights) {
cachedHighlights.add(
CachedHighlight(
highlight = h,
state = CachedHighlightState.REMOTE_SYNCED,
),
)
/**
* Drops any remote-synced cached highlight for each of [references] once their deletion has reached the server. A
* delete removes the local entry immediately, so this only matters when a concurrent chapter load re-added the
* highlight as remote-synced before the delete synced; without it that stale row would linger until the next
* reload. Local-pending entries are left in place so a re-add made after the delete is not lost.
*/
fun removeSyncedHighlights(references: List<BibleReference>) {
val referenceSet = references.toSet()
_highlights.update { current ->
current.filterNot {
it.highlight.bibleReference in referenceSet && it.state == CachedHighlightState.REMOTE_SYNCED
}
}
}

Expand Down
Loading
Loading