Skip to content

Commit 7066bd9

Browse files
Bookmarks Repository (#5)
* Define the public Bookmark model * Update gradle to handle tests * Create BookmarksRepositoryImpl and BookmarkRepositoryTest * Add to local mutations and read * Merge the streams of persisted and mutated bookmarks * Guard against duplicating bookmarks in mutated tables * Check duplications in persisted bookmarks as well * Delete bookmarks from mutated * Delete persisted bookmarks * Test adding a bookmark after it was deleted * Implement public functions related to mutations in the sync-process * Handle persistRemoteUpdates * Handle migrateBookmarks * Some cleanup * Add a test that the steam captures the mutations as they happen * Make sure that Bookmarks are initialized with valid data * Add check constraints to the SQL tables * Add some documentation * Rename BookmarkRepository to BookmarksRepository * Fix some failing tests regarding persist remote updates * Ensure DB actions are handled on an IO context * Split Bookmark into PageBookmark and ayahBookmark * Add ayah and page bookmarks stream functions * Extract synchronization functions to a new interface and a new model BookmarkMutation * Prepare for refactoring remote ID * Make remoteID internal in Bookmark * A minor fix * Remove the confusion on generated tables' names * Add logging * Add Xcode and macos's related ignorables to .gitignore * Move some hardcoded settings from build.gradle.kts to libs.versions.toml * Refactor the tests to delegate the DB conn to a platform-specific class * Switch to a single-table structure * Fix fetch mutations not retuning deleted bookmarks * Add setToSyncedState instead of both clear mutations and persist remote updates * Update the interface to remove ayah bookmarks from the interface * Remove ayah and sura from the DB * Remove ayah and sura from BookmarkMutation * Rename types after page bookmarks * Fix: adding a bookmark after deleting a remote one should reset delete flag * Refactor deletions queries * Refactor queries used for syncrhonization * Enforce naming consistency for types * Condense adding logic into a single SQL transaction * Rename bookmarks table to page_bookmarks * Remove existence checking done before deletion * Add localID propert to PageBookmarkMutation * Introduce the new sync function: accepts a list of processed local mutations * Add documentation
1 parent 55ea341 commit 7066bd9

File tree

15 files changed

+872
-16
lines changed

15 files changed

+872
-16
lines changed

.gitignore

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,35 @@ build
2020

2121
# Android
2222
local.properties
23+
24+
# Xcode
25+
#
26+
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
27+
28+
## User settings
29+
xcuserdata/
30+
31+
## Obj-C/Swift specific
32+
*.hmap
33+
34+
## App packaging
35+
*.ipa
36+
*.dSYM.zip
37+
*.dSYM
38+
39+
# Swift Package Manager
40+
#
41+
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
42+
# Packages/
43+
# Package.pins
44+
# Package.resolved
45+
# *.xcodeproj
46+
#
47+
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
48+
# hence it is not needed unless you have added a package configuration file to your project
49+
# .swiftpm
50+
51+
.build/
52+
53+
# macOS
54+
.DS_Store

demo/apple/QuranSyncDemo/QuranSyncDemo/DatabaseManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class DatabaseManager {
3030

3131
// Add a new bookmark
3232
func addBookmark(sura: Int64, ayah: Int64) {
33-
bookmarkQueries.addBookmark(sura: sura, ayah: ayah)
33+
bookmarkQueries.addBookmark(remote_id: nil, sura: sura, ayah: ayah, page: nil)
3434
}
3535

3636
// Add a random bookmark

gradle/libs.versions.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,39 @@
11
[versions]
22
agp = "8.11.0"
33
kotlin = "2.2.0"
4+
kermit = "2.0.0"
45

56
maven-publish = "0.33.0"
67

78
compose = "2025.06.01"
89
coroutines = "1.10.2"
910
sqldelight = "2.1.0"
1011

12+
# Android configuration
13+
android-compile-sdk = "35"
14+
android-min-sdk = "21"
15+
android-java-version = "17"
16+
17+
# Project metadata
18+
project-group = "com.quran"
19+
project-version = "1.0.0"
20+
project-inception-year = "2025"
21+
project-url = "https://github.com/quran/mobile-data"
22+
1123
[libraries]
1224
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
25+
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
26+
27+
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
28+
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
29+
30+
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
31+
sqldelight-jdbc-driver = { module = "app.cash.sqldelight:jdbc-driver", version.ref = "sqldelight" }
1332

1433
sqldelight-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
1534
sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
1635
sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
36+
sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
1737

1838
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" }
1939
compose-foundation = { module = "androidx.compose.foundation:foundation" }

persistence/build.gradle.kts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,29 @@ kotlin {
2424
commonMain.dependencies {
2525
implementation(libs.kotlinx.coroutines.core)
2626
implementation(libs.sqldelight.extensions)
27+
implementation(libs.kermit)
28+
}
29+
30+
commonTest.dependencies {
31+
implementation(libs.kotlin.test)
32+
implementation(libs.kotlinx.coroutines.test)
33+
}
34+
35+
androidUnitTest.dependencies {
36+
implementation(libs.sqldelight.sqlite.driver)
37+
implementation(libs.sqldelight.jdbc.driver)
38+
}
39+
40+
iosX64Test.dependencies {
41+
implementation(libs.sqldelight.native.driver)
42+
}
43+
44+
iosArm64Test.dependencies {
45+
implementation(libs.sqldelight.native.driver)
46+
}
47+
48+
iosSimulatorArm64Test.dependencies {
49+
implementation(libs.sqldelight.native.driver)
2750
}
2851

2952
androidMain.dependencies {
@@ -47,15 +70,21 @@ kotlin {
4770

4871
android {
4972
namespace = "com.quran.shared.persistence"
50-
compileSdk = 35
73+
compileSdk = libs.versions.android.compile.sdk.get().toInt()
5174

5275
defaultConfig {
53-
minSdk = 21
76+
minSdk = libs.versions.android.min.sdk.get().toInt()
5477
}
5578

5679
compileOptions {
57-
sourceCompatibility = JavaVersion.VERSION_17
58-
targetCompatibility = JavaVersion.VERSION_17
80+
sourceCompatibility = JavaVersion.valueOf("VERSION_${libs.versions.android.java.version.get()}")
81+
targetCompatibility = JavaVersion.valueOf("VERSION_${libs.versions.android.java.version.get()}")
82+
}
83+
84+
testOptions {
85+
unitTests {
86+
isIncludeAndroidResources = true
87+
}
5988
}
6089
}
6190

@@ -71,12 +100,12 @@ sqldelight {
71100
mavenPublishing {
72101
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
73102
signAllPublications()
74-
coordinates(group.toString(), "persistence", version.toString())
103+
coordinates(libs.versions.project.group.get(), "persistence", libs.versions.project.version.get())
75104

76105
pom {
77106
name = "Quran.com Persistence Layer"
78107
description = "A library for sharing data between iOS and Android mobile apps"
79-
inceptionYear = "2025"
80-
url = "https://github.com/quran/mobile-data"
108+
inceptionYear = libs.versions.project.inception.year.get()
109+
url = libs.versions.project.url.get()
81110
}
82111
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.quran.shared.persistence
2+
3+
import app.cash.sqldelight.db.SqlDriver
4+
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
5+
6+
actual class TestDatabaseDriver {
7+
actual fun createDriver(): SqlDriver {
8+
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
9+
QuranDatabase.Schema.create(driver)
10+
return driver
11+
}
12+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.quran.shared.persistence.model
2+
3+
import com.quran.shared.persistence.Page_bookmark
4+
5+
typealias DatabasePageBookmark = Page_bookmark
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.quran.shared.persistence.model
2+
3+
data class PageBookmark(
4+
val page: Int,
5+
val lastUpdated: Long,
6+
val remoteId: String? = null
7+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.quran.shared.persistence.model
2+
3+
enum class PageBookmarkMutationType {
4+
CREATED,
5+
DELETED
6+
}
7+
8+
data class PageBookmarkMutation internal constructor(
9+
val page: Int,
10+
internal val localId: Long?,
11+
val remoteId: String? = null,
12+
val mutationType: PageBookmarkMutationType,
13+
val lastUpdated: Long
14+
) {
15+
16+
companion object {
17+
fun createRemoteMutation(
18+
page: Int,
19+
remoteId: String? = null,
20+
mutationType: PageBookmarkMutationType,
21+
lastUpdated: Long
22+
): PageBookmarkMutation = PageBookmarkMutation(page, null, remoteId, mutationType, lastUpdated)
23+
}
24+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.quran.shared.persistence.repository
2+
3+
import com.quran.shared.persistence.model.PageBookmark
4+
import com.quran.shared.persistence.model.PageBookmarkMutation
5+
import com.quran.shared.persistence.model.PageBookmarkMutationType
6+
import com.quran.shared.persistence.model.DatabasePageBookmark
7+
8+
fun DatabasePageBookmark.toBookmark(): PageBookmark {
9+
return PageBookmark(
10+
page = page.toInt(),
11+
lastUpdated = created_at,
12+
remoteId = remote_id
13+
)
14+
}
15+
16+
fun DatabasePageBookmark.toBookmarkMutation(): PageBookmarkMutation = PageBookmarkMutation(
17+
page = page.toInt(),
18+
localId = local_id,
19+
remoteId = remote_id,
20+
mutationType = if (deleted == 1L) PageBookmarkMutationType.DELETED else PageBookmarkMutationType.CREATED,
21+
lastUpdated = created_at
22+
)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.quran.shared.persistence.repository
2+
3+
import com.quran.shared.persistence.model.PageBookmarkMutation
4+
import com.quran.shared.persistence.model.PageBookmark
5+
import kotlinx.coroutines.flow.Flow
6+
7+
class DuplicatePageBookmarkException(message: String) : Exception(message)
8+
class PageBookmarkNotFoundException(message: String) : Exception(message)
9+
10+
interface PageBookmarksSynchronizationRepository {
11+
/**
12+
* Returns a list of bookmarks that have been mutated locally (created or deleted)
13+
* and need to be synchronized with the remote server.
14+
*/
15+
suspend fun fetchMutatedBookmarks(): List<PageBookmarkMutation>
16+
17+
/**
18+
* Persists the remote state of bookmarks after a successful synchronization operation.
19+
* This method should be called after the remote server has confirmed the changes.
20+
*
21+
* @param updatesToPersist List of bookmarks with their remote IDs and mutation states to be
22+
* persisted. These must have a remoteID setup. Make sure to call
23+
* `PageBookmarkMutation.createRemoteMutation`.
24+
* @param localMutationsToClear List of local mutations to be cleared. An item of this list
25+
* denotes either a mutation that was committed remotely, or a mutation that overridden. If it
26+
* was committed, a counterpart is expected in `updatesToPersists` to persist it as a remote
27+
* bookmark. These must be input from the list returned by `fetchMutatedBookmarks`.
28+
*/
29+
suspend fun applyRemoteChanges(updatesToPersist: List<PageBookmarkMutation>,
30+
localMutationsToClear: List<PageBookmarkMutation>)
31+
}
32+
33+
interface PageBookmarksRepository {
34+
/**
35+
* Returns a Flow of all page bookmarks, reflecting the latest state of the data.
36+
* The Flow will emit new values whenever the underlying data changes.
37+
*
38+
* @return Flow<List<PageBookmark>> A flow that emits the current list of bookmarks
39+
*/
40+
fun getAllBookmarks(): Flow<List<PageBookmark>>
41+
42+
/**
43+
* Adds a bookmark for a specific page.
44+
* @throws DuplicatePageBookmarkException if a bookmark for this page already exists
45+
*/
46+
suspend fun addPageBookmark(page: Int)
47+
48+
/**
49+
* Deletes a bookmark for a specific page.
50+
* @throws PageBookmarkNotFoundException if no bookmark exists for this page
51+
*/
52+
suspend fun deletePageBookmark(page: Int)
53+
54+
/**
55+
* Migrates existing bookmarks to the new storage format.
56+
* This method should only be called once during app initialization, after
57+
* bookmarks are added and before any changes by the user are handled.
58+
*
59+
* @param bookmarks List of page bookmarks to migrate
60+
* @throws IllegalStateException if either bookmarks or mutations tables are not empty
61+
* @throws IllegalArgumentException if any bookmark has a remote ID or is marked as deleted
62+
*/
63+
suspend fun migrateBookmarks(bookmarks: List<PageBookmark>)
64+
}

0 commit comments

Comments
 (0)