Skip to content
Merged
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 @@ -2,8 +2,22 @@ package com.quran.shared.persistence.model

import com.quran.shared.persistence.util.PlatformDateTime

/**
* App-facing collection model.
*
* The default bookmark collection is virtual: it is identified by [DEFAULT_COLLECTION_ID] and is
* derived from bookmark membership state instead of a row in the collection table.
*/
data class Collection(
val name: String,
val lastUpdated: PlatformDateTime,
val localId: String
)
) {
/**
* True when this collection represents the virtual default bookmark collection.
*
* This value is derived from [localId] so callers do not pass or persist a separate flag.
*/
val isDefault: Boolean
get() = localId == DEFAULT_COLLECTION_ID
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,8 @@ class CollectionBookmarksRepositoryImpl(
override suspend fun getBookmarksForCollection(collectionLocalId: String): List<CollectionAyahBookmark> {
if (collectionLocalId == DEFAULT_COLLECTION_ID) {
return withContext(Dispatchers.IO) {
bookmarkQueries.value.getSavedAyahBookmarks()
bookmarkQueries.value.getDefaultCollectionAyahBookmarks()
.executeAsList()
.filter { it.is_in_default_collection == 1L }
.map { it.toDefaultCollectionBookmark() }
}
}
Expand All @@ -80,13 +79,10 @@ class CollectionBookmarksRepositoryImpl(

override fun getBookmarksForCollectionFlow(collectionLocalId: String): Flow<List<CollectionAyahBookmark>> {
return if (collectionLocalId == DEFAULT_COLLECTION_ID) {
bookmarkQueries.value.getSavedAyahBookmarks()
bookmarkQueries.value.getDefaultCollectionAyahBookmarks()
.asFlow()
.mapToList(Dispatchers.IO)
.map { list ->
list.filter { it.is_in_default_collection == 1L }
.map { it.toDefaultCollectionBookmark() }
}
.map { list -> list.map { it.toDefaultCollectionBookmark() } }
} else {
bookmarkCollectionQueries.value
.getCollectionBookmarksForCollectionWithDetails(collection_local_id = collectionLocalId.toLong())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ getSavedAyahBookmarks:
)
ORDER BY b.modified_at DESC;

getDefaultCollectionAyahBookmarks:
SELECT b.* FROM bookmark b
WHERE b.deleted = 0
AND b.bookmark_type = 'AYAH'
AND b.is_in_default_collection = 1
ORDER BY COALESCE(b.default_modified_at, b.modified_at) DESC;

getReadingBookmarks:
SELECT * FROM bookmark
WHERE deleted = 0 AND is_reading = 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,20 @@ class BookmarkSyncArchitectureTest {
assertEquals("CREATED", link.pending_op)
}

@Test
fun `default collection query returns only default memberships in default timestamp order`() = runTest {
val customCollectionId = createCollection("CustomOnly", "remote-custom-only")
bookmarksRepository.addBookmark(2, 10, listOf(customCollectionId), at(500))
bookmarksRepository.addBookmark(2, 11, listOf(DEFAULT_COLLECTION_ID), at(100))
bookmarksRepository.addBookmark(2, 12, listOf(DEFAULT_COLLECTION_ID), at(300))

val defaults = collectionBookmarksRepository.getBookmarksForCollection(DEFAULT_COLLECTION_ID)

assertEquals(listOf(12, 11), defaults.map { it.ayah })
assertEquals(listOf(300L, 100L), defaults.map { it.lastUpdated.fromPlatform().toEpochMilliseconds() })
assertTrue(defaults.all { it.collectionLocalId == DEFAULT_COLLECTION_ID })
}

@Test
fun `addBookmark supports default and custom membership together`() = runTest {
val collectionId = createCollection("Both", "remote-both")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import com.quran.shared.persistence.TestDatabaseDriver
import com.quran.shared.persistence.input.ImportCollection
import com.quran.shared.persistence.input.PersistenceImportData
import com.quran.shared.persistence.input.RemoteCollection
import com.quran.shared.persistence.model.Collection
import com.quran.shared.persistence.model.DEFAULT_COLLECTION_ID
import com.quran.shared.persistence.repository.collection.repository.CollectionsRepositoryImpl
import com.quran.shared.persistence.repository.importdata.PersistenceImportRepositoryImpl
import com.quran.shared.persistence.util.fromPlatform
Expand All @@ -20,6 +22,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant

class CollectionsRepositoryTest {
Expand All @@ -32,6 +35,12 @@ class CollectionsRepositoryTest {
repository = CollectionsRepositoryImpl(database)
}

@Test
fun `collection isDefault is derived from the virtual default local id`() {
assertTrue(Collection("Default", timestamp(1L), DEFAULT_COLLECTION_ID).isDefault)
assertFalse(Collection("Favorites", timestamp(1L), "1").isDefault)
}

@Test
fun `addCollection respects explicit timestamp`() = runTest {
val collection = repository.addCollection("Favorites", timestamp(1234L))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.quran.shared.persistence.model.ReadingSession
import com.quran.shared.persistence.input.PersistenceImportData
import com.quran.shared.persistence.input.PersistenceImportResult
import com.quran.shared.persistence.util.PlatformDateTime
import com.quran.shared.persistence.util.toPlatform
import com.quran.shared.persistence.repository.bookmark.repository.BookmarksRepository
import com.quran.shared.persistence.repository.PersistenceResetRepository
import com.quran.shared.persistence.repository.collection.repository.CollectionsRepository
Expand All @@ -39,6 +40,7 @@ import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlin.native.HiddenFromObjC
import kotlin.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
Expand All @@ -49,7 +51,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch

Expand Down Expand Up @@ -125,8 +126,16 @@ class QuranDataService internal constructor(
private val sessionLifecycleCoordinator: SessionLifecycleCoordinator,
private val syncClientFactory: QuranDataServiceSynchronizationClientFactory
) {
/**
* Stable local identifier for the virtual default bookmark collection.
*
* The default collection is not persisted as a collection row. Read APIs synthesize it from
* bookmark default-membership state so callers can treat it like other collections by local ID.
*/
val defaultCollectionId: String = DEFAULT_COLLECTION_ID

private val emptyDefaultCollectionTimestamp: PlatformDateTime =
Instant.fromEpochMilliseconds(0).toPlatform()

private val serviceJob: Job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Main + serviceJob)
Expand Down Expand Up @@ -168,22 +177,34 @@ class QuranDataService internal constructor(

/**
* Flow of all collections with their bookmarks for the UI to observe.
*
* The first entry is the virtual default collection, backed by
* [CollectionBookmarksRepository.getBookmarksForCollectionFlow] with [DEFAULT_COLLECTION_ID].
* Remaining entries are persisted custom collections.
*/
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
@NativeCoroutines
val collectionsWithBookmarks: Flow<List<CollectionWithAyahBookmarks>>
get() =
collectionsRepository.getCollectionsFlow().flatMapLatest { collections ->
if (collections.isEmpty()) {
flowOf(emptyList())
val defaultCollectionFlow =
collectionBookmarksRepository.getBookmarksForCollectionFlow(DEFAULT_COLLECTION_ID)
.map { bookmarks ->
CollectionWithAyahBookmarks(defaultCollection(bookmarks), bookmarks)
}
val customCollectionFlows =
collections
.filterNot { it.isDefault }
.map { collection ->
collectionBookmarksRepository.getBookmarksForCollectionFlow(collection.localId)
.map { bookmarks: List<CollectionAyahBookmark> ->
CollectionWithAyahBookmarks(collection, bookmarks)
}
}
if (customCollectionFlows.isEmpty()) {
defaultCollectionFlow.map { listOf(it) }
} else {
val flows = collections.map { collection ->
collectionBookmarksRepository.getBookmarksForCollectionFlow(collection.localId)
.map { bookmarks: List<CollectionAyahBookmark> ->
CollectionWithAyahBookmarks(collection, bookmarks)
}
}
combine(flows) { it.toList() }
combine(listOf(defaultCollectionFlow) + customCollectionFlows) { it.toList() }
}
}

Expand Down Expand Up @@ -615,10 +636,16 @@ class QuranDataService internal constructor(
* @param localId the local identifier of the collection to update.
* @param name the new collection name.
* @return the updated collection.
* Returns the unchanged virtual default collection when [localId] is [DEFAULT_COLLECTION_ID].
* @throws IllegalArgumentException when [localId] does not identify an active collection.
*/
@NativeCoroutines
suspend fun updateCollection(localId: String, name: String): Collection {
if (localId == DEFAULT_COLLECTION_ID) {
return mutatingCall("Failed to update collection", triggerAfter = false) {
defaultCollection(collectionBookmarksRepository.getBookmarksForCollection(DEFAULT_COLLECTION_ID))
}
}
return mutatingCall("Failed to update collection") {
collectionsRepository.updateCollection(localId, name)
}
Expand All @@ -631,17 +658,34 @@ class QuranDataService internal constructor(
* @param name the new collection name.
* @param timestamp the timestamp to persist for the mutation.
* @return the updated collection.
* Returns the unchanged virtual default collection when [localId] is [DEFAULT_COLLECTION_ID].
* @throws IllegalArgumentException when [localId] does not identify an active collection.
*/
@NativeCoroutines
suspend fun updateCollection(localId: String, name: String, timestamp: PlatformDateTime): Collection {
if (localId == DEFAULT_COLLECTION_ID) {
return mutatingCall("Failed to update collection", triggerAfter = false) {
defaultCollection(collectionBookmarksRepository.getBookmarksForCollection(DEFAULT_COLLECTION_ID))
}
}
return mutatingCall("Failed to update collection") {
collectionsRepository.updateCollection(localId, name, timestamp)
}
}

/**
* Deletes a custom collection and schedules sync when a row was removed.
*
* The virtual default collection cannot be deleted; passing [DEFAULT_COLLECTION_ID] returns
* `false` without touching persistence or scheduling sync.
*/
@NativeCoroutines
suspend fun deleteCollection(localId: String): Boolean {
if (localId == DEFAULT_COLLECTION_ID) {
return mutatingCall("Failed to delete collection", triggerAfter = false) {
false
}
}
return mutatingCall("Failed to delete collection", triggerAfter = false) {
val deleted = collectionsRepository.deleteCollection(localId)
if (deleted) {
Expand Down Expand Up @@ -761,8 +805,17 @@ class QuranDataService internal constructor(
@NativeCoroutines
fun getBookmarksForCollectionFlow(collectionLocalId: String): Flow<List<CollectionAyahBookmark>> =
collectionBookmarksRepository.getBookmarksForCollectionFlow(collectionLocalId)

private fun defaultCollection(bookmarks: List<CollectionAyahBookmark> = emptyList()): Collection =
Collection(
name = DEFAULT_COLLECTION_NAME,
lastUpdated = bookmarks.firstOrNull()?.lastUpdated ?: emptyDefaultCollectionTimestamp,
localId = DEFAULT_COLLECTION_ID
)
}

private const val DEFAULT_COLLECTION_NAME = "Default"

internal class SettingsSyncEngineCallback(
private val syncLocalModificationDateStore: SyncLocalModificationDateStore,
private val writeBoundaryGuard: SyncWriteBoundaryGuard = SyncWriteBoundaryGuard {}
Expand Down
Loading
Loading