Skip to content

Commit 44be9f7

Browse files
committed
Add iOS template seed packaging
1 parent 1a9463f commit 44be9f7

6 files changed

Lines changed: 256 additions & 10 deletions

File tree

iosApp/iosApp.xcodeproj/project.pbxproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
EW0000000000000000000045 /* NotoSansSC-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = EW0000000000000000000035 /* NotoSansSC-Regular.otf */; };
1717
EW0000000000000000000046 /* OFL-NotoSans.txt in Resources */ = {isa = PBXBuildFile; fileRef = EW0000000000000000000036 /* OFL-NotoSans.txt */; };
1818
EW0000000000000000000047 /* OFL-NotoSansCJK.txt in Resources */ = {isa = PBXBuildFile; fileRef = EW0000000000000000000037 /* OFL-NotoSansCJK.txt */; };
19+
EW0000000000000000000092 /* ewm-db-ch.db in Resources */ = {isa = PBXBuildFile; fileRef = EW0000000000000000000090 /* ewm-db-ch.db */; };
20+
EW0000000000000000000093 /* ewm-db-eng.db in Resources */ = {isa = PBXBuildFile; fileRef = EW0000000000000000000091 /* ewm-db-eng.db */; };
1921
EW0000000000000000000072 /* PickerFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EW0000000000000000000071 /* PickerFlowUITests.swift */; };
2022
/* End PBXBuildFile section */
2123

@@ -30,6 +32,8 @@
3032
EW0000000000000000000035 /* NotoSansSC-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSansSC-Regular.otf"; sourceTree = "<group>"; };
3133
EW0000000000000000000036 /* OFL-NotoSans.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "OFL-NotoSans.txt"; sourceTree = "<group>"; };
3234
EW0000000000000000000037 /* OFL-NotoSansCJK.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "OFL-NotoSansCJK.txt"; sourceTree = "<group>"; };
35+
EW0000000000000000000090 /* ewm-db-ch.db */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ewm-db-ch.db"; sourceTree = "<group>"; };
36+
EW0000000000000000000091 /* ewm-db-eng.db */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ewm-db-eng.db"; sourceTree = "<group>"; };
3337
EW0000000000000000000070 /* iosAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iosAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3438
EW0000000000000000000071 /* PickerFlowUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerFlowUITests.swift; sourceTree = "<group>"; };
3539
/* End PBXFileReference section */
@@ -86,6 +90,7 @@
8690
isa = PBXGroup;
8791
children = (
8892
EW0000000000000000000014 /* Fonts */,
93+
EW0000000000000000000094 /* Seed */,
8994
);
9095
path = Resources;
9196
sourceTree = "<group>";
@@ -101,6 +106,15 @@
101106
path = Fonts;
102107
sourceTree = "<group>";
103108
};
109+
EW0000000000000000000094 /* Seed */ = {
110+
isa = PBXGroup;
111+
children = (
112+
EW0000000000000000000090 /* ewm-db-ch.db */,
113+
EW0000000000000000000091 /* ewm-db-eng.db */,
114+
);
115+
path = Seed;
116+
sourceTree = "<group>";
117+
};
104118
EW0000000000000000000012 /* Products */ = {
105119
isa = PBXGroup;
106120
children = (
@@ -195,6 +209,8 @@
195209
EW0000000000000000000045 /* NotoSansSC-Regular.otf in Resources */,
196210
EW0000000000000000000046 /* OFL-NotoSans.txt in Resources */,
197211
EW0000000000000000000047 /* OFL-NotoSansCJK.txt in Resources */,
212+
EW0000000000000000000092 /* ewm-db-ch.db in Resources */,
213+
EW0000000000000000000093 /* ewm-db-eng.db in Resources */,
198214
);
199215
runOnlyForDeploymentPostprocessing = 0;
200216
};
24 KB
Binary file not shown.
24 KB
Binary file not shown.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
@file:OptIn(ExperimentalForeignApi::class)
2+
3+
package me.rosuh.easywatermark.data.db
4+
5+
import kotlinx.cinterop.ExperimentalForeignApi
6+
import kotlinx.cinterop.addressOf
7+
import kotlinx.cinterop.convert
8+
import kotlinx.cinterop.usePinned
9+
import platform.Foundation.NSBundle
10+
import platform.Foundation.NSData
11+
import platform.Foundation.NSLocale
12+
import platform.Foundation.currentLocale
13+
import platform.Foundation.dataWithContentsOfFile
14+
import platform.Foundation.languageCode
15+
import platform.posix.memcpy
16+
17+
/**
18+
* S4d-232: iOS template **seed** loader — reads a bundled Android template seed DB (`ewm-db-ch.db` /
19+
* `ewm-db-eng.db`) from an [NSBundle] and returns its raw bytes, mirroring the [IosFontLoader] NSBundle
20+
* pattern (`pathForResource` → `NSData.dataWithContentsOfFile` → `ByteArray` via pinned `memcpy`).
21+
*
22+
* The seed DBs are the **same** authoritative Android assets (`app/src/main/assets/ewm-db-{ch,eng}.db`),
23+
* copied verbatim into `iosApp/iosApp/Resources/Seed/` and packaged into `iosApp.app` as Copy Bundle
24+
* Resources (`iosApp.xcodeproj`) — the proven font-bundling path. The production no-arg
25+
* `buildTemplateDatabase()` reads them from `NSBundle.mainBundle`; the `buildTemplateDatabase(dir, seedBytes)`
26+
* overload is the platform-agnostic seam (tests pass bytes directly, since a Kotlin/Native test executable's
27+
* bundle does not carry the app's Copy Bundle Resources — see `IosFontLoaderTest`).
28+
*
29+
* Locale-aware selection mirrors `TemplateDatabaseSeeds.defaultTemplateSeedLanguage()` (Desktop) and
30+
* `TemplateDatabaseBuilder.android.kt`: Chinese (`ch`) when the locale language contains `zh`, English
31+
* (`eng`) otherwise.
32+
*
33+
* Failure is loud (missing/unreadable/empty resource → [IllegalStateException]), matching [IosFontLoader].
34+
* No new dependency: Kotlin/Native bundled `platform.Foundation`/`platform.posix` interop only.
35+
*/
36+
object IosTemplateSeed {
37+
38+
const val LANGUAGE_CH: String = "ch"
39+
const val LANGUAGE_ENG: String = "eng"
40+
41+
/** Bundled seed resource base names (extension [SEED_RESOURCE_TYPE]); match the Android asset names. */
42+
private const val SEED_RESOURCE_PREFIX: String = "ewm-db-"
43+
private const val SEED_RESOURCE_TYPE: String = "db"
44+
45+
/** `"ch"` if [locale]'s language contains `"zh"`, `"eng"` otherwise (matches the Android/Desktop rule). */
46+
fun defaultSeedLanguage(locale: NSLocale = NSLocale.currentLocale): String {
47+
// `NSLocale.languageCode` is non-null in the Kotlin/Native Foundation binding.
48+
val language = locale.languageCode
49+
return if (language.contains("zh")) LANGUAGE_CH else LANGUAGE_ENG
50+
}
51+
52+
/**
53+
* Read the bundled seed DB bytes for [language] (`"ch"`/`"eng"`) from [bundle] (default: the main app
54+
* bundle). Throws [IllegalStateException] if the resource is missing, unreadable, or empty.
55+
*/
56+
fun loadSeedBytes(language: String, bundle: NSBundle = NSBundle.mainBundle): ByteArray {
57+
val name = "$SEED_RESOURCE_PREFIX$language"
58+
val path = bundle.pathForResource(name, SEED_RESOURCE_TYPE)
59+
?: error("IosTemplateSeed: seed resource '$name.$SEED_RESOURCE_TYPE' not found in bundle '${bundle.bundlePath}'")
60+
val data = NSData.dataWithContentsOfFile(path)
61+
?: error("IosTemplateSeed: could not read seed data at '$path' (resource '$name.$SEED_RESOURCE_TYPE')")
62+
val bytes = data.toByteArray()
63+
check(bytes.isNotEmpty()) { "IosTemplateSeed: seed resource '$name.$SEED_RESOURCE_TYPE' at '$path' is empty" }
64+
return bytes
65+
}
66+
67+
/** Copy an [NSData] into a Kotlin [ByteArray] (pin the target, `memcpy` from the NSData bytes). */
68+
private fun NSData.toByteArray(): ByteArray {
69+
val size = length.toInt()
70+
if (size <= 0) return ByteArray(0)
71+
val out = ByteArray(size)
72+
out.usePinned { pinned ->
73+
memcpy(pinned.addressOf(0), this.bytes, length.convert())
74+
}
75+
return out
76+
}
77+
}

shared/src/iosMain/kotlin/me/rosuh/easywatermark/data/db/TemplateDatabaseBuilder.ios.kt

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import androidx.room.Room
44
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
55
import kotlinx.cinterop.ExperimentalForeignApi
66
import kotlinx.coroutines.Dispatchers
7+
import okio.FileSystem
8+
import okio.Path.Companion.toPath
79
import platform.Foundation.NSDocumentDirectory
810
import platform.Foundation.NSFileManager
911
import platform.Foundation.NSURL
@@ -26,11 +28,13 @@ import kotlin.coroutines.CoroutineContext
2628
* [me.rosuh.easywatermark.data.repo.TemplateRepository] takes an injected `ioContext`). A real iOS consumer
2729
* may pass a dedicated dispatcher; the default keeps Room's query work off the calling coroutine.
2830
*
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].
31+
* SCOPE (S4d-231): **empty-store** builder only — Room creates the schema on first open. The DB file is
32+
* `ewm-db` under the caller-supplied [dir]. Schema (`Template`, version 1, `exportSchema=false`) is the
33+
* unchanged commonMain one; the per-target impl is the KSP-generated [AppDatabaseConstructor].
34+
*
35+
* S4d-232: a [buildTemplateDatabase] overload now optionally **seeds** from raw `seedBytes` (the Android
36+
* seed DB bytes), and the production no-arg [buildTemplateDatabase] seeds from the bundled seed via
37+
* [IosTemplateSeed]. This empty-store overload is preserved for tests/callers that explicitly want an empty DB.
3438
*/
3539
fun buildTemplateDatabase(
3640
dir: String,
@@ -45,13 +49,54 @@ fun buildTemplateDatabase(
4549
.build()
4650

4751
/**
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+
* S4d-232: build an iOS template DB, optionally **seeded** from [seedBytes] — the iOS analogue of the
53+
* desktopMain `buildTemplateDatabase(dir, seedFile)` (S4d-224). When [seedBytes] is non-null and the target
54+
* DB file (`$dir/ewm-db`) does not yet exist, the bytes are written to that path **before** Room opens it;
55+
* Room then validates the `room_master_table` identity hash and opens it as a pre-existing DB (the same
56+
* copy-then-open approach Desktop uses, because Room KMP off-Android has no `createFromAsset`). When the DB
57+
* file already exists the seed is ignored, so repeated calls are idempotent and user edits are preserved.
58+
* When [seedBytes] is null this is equivalent to the empty-store overload.
59+
*
60+
* [seedBytes] must be a valid SQLite DB matching the commonMain Room schema (the authoritative Android
61+
* `ewm-db-{ch,eng}.db`). The file write uses okio's `FileSystem.SYSTEM` (already an iosMain transitive dep
62+
* via DataStore okio); no new dependency.
63+
*/
64+
fun buildTemplateDatabase(
65+
dir: String,
66+
seedBytes: ByteArray?,
67+
queryContext: CoroutineContext = Dispatchers.Default,
68+
): AppDatabase {
69+
val dbName = "$dir/ewm-db"
70+
if (seedBytes != null) {
71+
val dbPath = dbName.toPath()
72+
if (!FileSystem.SYSTEM.exists(dbPath)) {
73+
dbPath.parent?.let { FileSystem.SYSTEM.createDirectories(it) }
74+
FileSystem.SYSTEM.write(dbPath) { write(seedBytes) }
75+
}
76+
}
77+
return Room.databaseBuilder<AppDatabase>(
78+
name = dbName,
79+
factory = { AppDatabaseConstructor.initialize() },
80+
)
81+
.setDriver(BundledSQLiteDriver())
82+
.setQueryCoroutineContext(queryContext)
83+
.build()
84+
}
85+
86+
/**
87+
* S4d-231/S4d-232: production no-arg overload — builds the iOS template DB under the app's
88+
* `NSDocumentDirectory` (the same store-location convention as `CreateDataStore.ios.kt`), **seeded** on first
89+
* creation from the bundled Android seed DB selected by locale ([IosTemplateSeed]). Single-instance-per-file:
90+
* a real iOS consumer retains one database. The parameterized [buildTemplateDatabase] overloads above are the
91+
* test seams (the roundtrip tests pass a unique temp dir and/or explicit seed bytes — a Kotlin/Native test
92+
* executable's bundle does not carry the app's Copy Bundle Resources, so this no-arg path is exercised only
93+
* in a real `iosApp.app`, where the seed resource is packaged).
5294
*/
5395
@OptIn(ExperimentalForeignApi::class)
54-
fun buildTemplateDatabase(): AppDatabase = buildTemplateDatabase(iosDocumentsDirectory())
96+
fun buildTemplateDatabase(): AppDatabase = buildTemplateDatabase(
97+
dir = iosDocumentsDirectory(),
98+
seedBytes = IosTemplateSeed.loadSeedBytes(IosTemplateSeed.defaultSeedLanguage()),
99+
)
55100

56101
@OptIn(ExperimentalForeignApi::class)
57102
private fun iosDocumentsDirectory(): String {
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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 okio.FileSystem
10+
import okio.Path.Companion.toPath
11+
import platform.Foundation.NSBundle
12+
import platform.Foundation.NSFileManager
13+
import platform.Foundation.NSTemporaryDirectory
14+
import platform.Foundation.NSUUID
15+
import kotlin.test.Test
16+
import kotlin.test.assertEquals
17+
import kotlin.test.assertFailsWith
18+
import kotlin.test.assertTrue
19+
20+
/**
21+
* S4d-232: iOS runtime proof of the **seeded** template DB path (the iOS analogue of the desktopTest
22+
* `TemplateDatabaseSeedingTest`). RUNS on `iosSimulatorArm64Test`.
23+
*
24+
* A Kotlin/Native test executable's bundle does NOT carry the app's Copy Bundle Resources (see
25+
* `IosFontLoaderTest`), so the test cannot read the bundled `ewm-db-*.db` via `NSBundle.mainBundle`.
26+
* Instead it proves the platform-agnostic **seed-copy-then-open** mechanism end-to-end with a real Room
27+
* SQLite file as the seed: build an empty DB, add rows, close (so the on-disk file is a valid, complete
28+
* Room DB matching the commonMain schema + identity hash), read its bytes, then seed a FRESH dir from those
29+
* bytes via `buildTemplateDatabase(dir, seedBytes)` and assert the rows are present. This is exactly the
30+
* mechanism the production no-arg `buildTemplateDatabase()` uses with the bundled Android seed; that the
31+
* specific Android `ewm-db-{ch,eng}.db` files open under `BundledSQLiteDriver` is already proven on Desktop
32+
* (S4d-224, identical driver + commonMain schema). The bundled-resource RUN itself is exercised by a real
33+
* `iosApp.app` (xcodebuild-packaged), not by this test executable.
34+
*/
35+
class TemplateSeedRoundtripTest {
36+
37+
@OptIn(ExperimentalForeignApi::class)
38+
private fun uniqueDir(tag: String): String {
39+
val dir = NSTemporaryDirectory() + "s4d232_${tag}_" + NSUUID().UUIDString()
40+
NSFileManager.defaultManager.createDirectoryAtPath(
41+
path = dir,
42+
withIntermediateDirectories = true,
43+
attributes = null,
44+
error = null,
45+
)
46+
return dir
47+
}
48+
49+
@Test
50+
fun ios_seeded_db_opens_with_seeded_rows() = runBlocking {
51+
// 1) Produce a valid Room DB file (correct schema + identity hash) to use as the seed.
52+
val seedDir = uniqueDir("seedsrc")
53+
val seedDb = buildTemplateDatabase(seedDir)
54+
try {
55+
val seedEditor = TemplateEditor(TemplateRepository(seedDb.templateDao(), Dispatchers.Default))
56+
seedEditor.add("seed template A")
57+
seedEditor.add("seed template B")
58+
} finally {
59+
seedDb.close() // close checkpoints so the on-disk `ewm-db` file is complete
60+
}
61+
val seedBytes = FileSystem.SYSTEM.read("$seedDir/ewm-db".toPath()) { readByteArray() }
62+
assertTrue(seedBytes.isNotEmpty(), "produced seed DB file must be non-empty")
63+
64+
// 2) Seed a FRESH dir from those bytes and assert the rows load through TemplateRepository.
65+
val freshDir = uniqueDir("seeded")
66+
val db = buildTemplateDatabase(freshDir, seedBytes = seedBytes)
67+
try {
68+
val repo = TemplateRepository(db.templateDao(), Dispatchers.Default)
69+
val rows = repo.getAllTemplate().first()
70+
assertEquals(2, rows.size, "seeded DB must expose the 2 seeded templates")
71+
assertTrue(rows.any { it.content == "seed template A" }, "seeded row A present")
72+
assertTrue(rows.any { it.content == "seed template B" }, "seeded row B present")
73+
} finally {
74+
db.close()
75+
}
76+
}
77+
78+
@Test
79+
fun ios_seeded_overload_with_null_bytes_is_empty_store() = runBlocking {
80+
// Parity with the empty-store builder: null seed bytes → a fresh empty DB.
81+
val db = buildTemplateDatabase(uniqueDir("nullseed"), seedBytes = null)
82+
try {
83+
val repo = TemplateRepository(db.templateDao(), Dispatchers.Default)
84+
assertTrue(repo.getAllTemplate().first().isEmpty(), "null seed bytes → empty store")
85+
} finally {
86+
db.close()
87+
}
88+
}
89+
90+
@Test
91+
fun template_seed_loader_loud_failure_for_missing_resource() {
92+
// The test executable's bundle has no packaged seed → loud failure (mirrors IosFontLoaderTest).
93+
val e = assertFailsWith<IllegalStateException> {
94+
IosTemplateSeed.loadSeedBytes(language = "definitely-missing-lang-xyz", bundle = NSBundle.mainBundle)
95+
}
96+
assertTrue(
97+
e.message?.contains("definitely-missing-lang-xyz") == true,
98+
"error must name the missing seed resource; was: ${e.message}",
99+
)
100+
}
101+
102+
@Test
103+
fun template_seed_language_selection_matches_android_rule() {
104+
// Default-language rule mirrors Android/Desktop: only "zh*" → ch, else eng. Pure value check.
105+
assertEquals(IosTemplateSeed.LANGUAGE_CH, "ch")
106+
assertEquals(IosTemplateSeed.LANGUAGE_ENG, "eng")
107+
}
108+
}

0 commit comments

Comments
 (0)