Skip to content

Commit 278d585

Browse files
committed
Add iOS template Room builder
1 parent b37a76f commit 278d585

3 files changed

Lines changed: 166 additions & 0 deletions

File tree

shared/build.gradle.kts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,23 @@ kotlin {
8888
implementation(libs.sqlite.bundled)
8989
}
9090
}
91+
// S4d-231: bundled SQLite driver for the iOS targets, so Room can build AppDatabase on iOS
92+
// (BundledSQLiteDriver — like Desktop, iOS/Native has no Room compatibility mode, an explicit
93+
// driver is required). iOS-TARGET-ONLY: it must NOT reach `:app`, which consumes `:shared`'s
94+
// android variant (Android stays on framework SupportSQLite, no driver). Verified by the
95+
// :app:assembleDebug no-sqlite-leak check. Attached to each iOS target's main source set
96+
// (created eagerly with the target, like `desktopMain`) rather than the hierarchy-template
97+
// intermediate `iosMain`, which is not yet resolvable via eager `by getting` at this point.
98+
val iosArm64Main by getting {
99+
dependencies {
100+
implementation(libs.sqlite.bundled)
101+
}
102+
}
103+
val iosSimulatorArm64Main by getting {
104+
dependencies {
105+
implementation(libs.sqlite.bundled)
106+
}
107+
}
91108
// S4d-2: Skiko desktop runtime, TEST-SCOPE, so WatermarkCellComposerTest can RENDER the
92109
// commonMain Compose-graphics cell offscreen on the JVM host (ImageBitmap is Skia-backed on
93110
// desktop). `compose.ui` provides the API; this provides the backend. desktopTest already
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package me.rosuh.easywatermark.data.db
2+
3+
import androidx.room.Room
4+
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
5+
import kotlinx.cinterop.ExperimentalForeignApi
6+
import kotlinx.coroutines.Dispatchers
7+
import platform.Foundation.NSDocumentDirectory
8+
import platform.Foundation.NSFileManager
9+
import platform.Foundation.NSURL
10+
import platform.Foundation.NSUserDomainMask
11+
import kotlin.coroutines.CoroutineContext
12+
13+
/**
14+
* S4d-231: iOS creation of the commonMain [AppDatabase] — the iOS analogue of the desktopMain
15+
* `buildTemplateDatabase` (S4d-142) and androidMain `buildTemplateDatabase(context)`.
16+
*
17+
* Like Desktop (and unlike Android's framework SupportSQLite **compatibility mode**), Room on iOS/Native
18+
* **requires an explicit `SQLiteDriver`** — there is no compatibility mode off Android. This uses
19+
* [BundledSQLiteDriver] from `androidx.sqlite:sqlite-bundled`, declared **iOS-target-only** in
20+
* `shared/build.gradle.kts` so it never reaches `:app` (the Android consumer uses the android variant,
21+
* which keeps compatibility mode).
22+
*
23+
* Query coroutine context: injected as [queryContext], defaulting to `Dispatchers.Default`. Unlike the
24+
* Desktop builder, this does NOT use `Dispatchers.IO` because `Dispatchers.IO` is `internal` on the Native
25+
* target in kotlinx-coroutines 1.10.2 (the same reason the commonMain
26+
* [me.rosuh.easywatermark.data.repo.TemplateRepository] takes an injected `ioContext`). A real iOS consumer
27+
* may pass a dedicated dispatcher; the default keeps Room's query work off the calling coroutine.
28+
*
29+
* SCOPE (S4d-231): **empty-store** builder only — Room creates the schema on first open. Locale-aware
30+
* seeding from a bundled `.db` (the Desktop [TemplateDatabaseSeeds] analogue) is a deferred follow-up, as
31+
* it needs a bundled NSBundle seed asset + Xcode packaging. The DB file is `ewm-db` under the caller-
32+
* supplied [dir]. Schema (`Template`, version 1, `exportSchema=false`) is the unchanged commonMain one;
33+
* the per-target impl is the KSP-generated [AppDatabaseConstructor].
34+
*/
35+
fun buildTemplateDatabase(
36+
dir: String,
37+
queryContext: CoroutineContext = Dispatchers.Default,
38+
): AppDatabase =
39+
Room.databaseBuilder<AppDatabase>(
40+
name = "$dir/ewm-db",
41+
factory = { AppDatabaseConstructor.initialize() },
42+
)
43+
.setDriver(BundledSQLiteDriver())
44+
.setQueryCoroutineContext(queryContext)
45+
.build()
46+
47+
/**
48+
* S4d-231: production no-arg overload — builds the iOS template DB under the app's `NSDocumentDirectory`
49+
* (the same store-location convention as `CreateDataStore.ios.kt`). Single-instance-per-file: a real iOS
50+
* consumer retains one database. The parameterized [buildTemplateDatabase] above is the test seam (the
51+
* roundtrip test passes a unique temp dir); this overload is just the NSDocumentDirectory path resolution.
52+
*/
53+
@OptIn(ExperimentalForeignApi::class)
54+
fun buildTemplateDatabase(): AppDatabase = buildTemplateDatabase(iosDocumentsDirectory())
55+
56+
@OptIn(ExperimentalForeignApi::class)
57+
private fun iosDocumentsDirectory(): String {
58+
val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
59+
directory = NSDocumentDirectory,
60+
inDomain = NSUserDomainMask,
61+
appropriateForURL = null,
62+
create = false,
63+
error = null,
64+
)
65+
return requireNotNull(documentDirectory).path ?: error("NSDocumentDirectory path is null")
66+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package me.rosuh.easywatermark.data.db
2+
3+
import kotlinx.cinterop.ExperimentalForeignApi
4+
import kotlinx.coroutines.Dispatchers
5+
import kotlinx.coroutines.flow.first
6+
import kotlinx.coroutines.runBlocking
7+
import me.rosuh.easywatermark.data.repo.TemplateRepository
8+
import me.rosuh.easywatermark.domain.TemplateEditor
9+
import platform.Foundation.NSFileManager
10+
import platform.Foundation.NSTemporaryDirectory
11+
import platform.Foundation.NSUUID
12+
import kotlin.test.Test
13+
import kotlin.test.assertEquals
14+
import kotlin.test.assertFalse
15+
import kotlin.test.assertTrue
16+
import kotlin.time.Clock
17+
18+
/**
19+
* S4d-231: iOS runtime proof that the commonMain Room templates path RUNS on iOS via the new iosMain
20+
* [buildTemplateDatabase] (bundled SQLite driver). Empty store (no seeding). Exercises the existing
21+
* commonMain [TemplateRepository] (`checkIfIsDaoNull`/`getAllTemplate`) + [TemplateEditor]
22+
* (`isDaoNull`/`add`/`update`/`delete`), mirroring the desktopTest `TemplateRoundtripTest` with plain
23+
* `runBlocking` (no `kotlinx-coroutines-test`). RUNS on `iosSimulatorArm64Test`.
24+
*
25+
* A unique `NSUUID`-suffixed directory under `NSTemporaryDirectory()` avoids cross-run collisions in the
26+
* ephemeral simulator container (the builder hardcodes the DB file name `ewm-db`).
27+
*/
28+
class TemplateRoundtripTest {
29+
30+
@OptIn(ExperimentalForeignApi::class)
31+
private fun newEmptyDb(): AppDatabase {
32+
val dir = NSTemporaryDirectory() + "s4d231_template_" + NSUUID().UUIDString()
33+
NSFileManager.defaultManager.createDirectoryAtPath(
34+
path = dir,
35+
withIntermediateDirectories = true,
36+
attributes = null,
37+
error = null,
38+
)
39+
return buildTemplateDatabase(dir)
40+
}
41+
42+
@Test
43+
fun ios_empty_store_add_list_update_delete_roundtrip() = runBlocking {
44+
val db = newEmptyDb()
45+
try {
46+
val repo = TemplateRepository(db.templateDao(), Dispatchers.Default)
47+
val editor = TemplateEditor(repo)
48+
49+
// A real bundled-driver DB (not the null-DB fallback) that starts with zero templates.
50+
assertFalse(editor.isDaoNull(), "real DAO must be present (not the null-DB fallback)")
51+
assertTrue(
52+
repo.getAllTemplate().first().isEmpty(),
53+
"a freshly built empty store has no templates",
54+
)
55+
56+
editor.add("S4d-231 ios roundtrip")
57+
val afterAdd = repo.getAllTemplate().first()
58+
assertEquals(1, afterAdd.size, "exactly one template after add")
59+
assertEquals("S4d-231 ios roundtrip", afterAdd[0].content, "the inserted content round-trips")
60+
assertTrue(afterAdd[0].id != 0, "autoGenerate assigned a row id")
61+
62+
val original = afterAdd[0]
63+
editor.update(original.copy(content = "updated content", lastModifiedDate = Clock.System.now()))
64+
val afterUpdate = repo.getAllTemplate().first()
65+
assertEquals(1, afterUpdate.size, "still exactly one template after update")
66+
assertEquals(original.id, afterUpdate[0].id, "update preserves the row id")
67+
assertEquals("updated content", afterUpdate[0].content, "update changes the content")
68+
assertEquals(
69+
original.creationDate,
70+
afterUpdate[0].creationDate,
71+
"update preserves the creation date",
72+
)
73+
74+
editor.delete(afterUpdate[0])
75+
assertTrue(
76+
repo.getAllTemplate().first().isEmpty(),
77+
"store is empty again after delete",
78+
)
79+
} finally {
80+
db.close()
81+
}
82+
}
83+
}

0 commit comments

Comments
 (0)