
Offline-capable FHIR R4 engine for on-device resource persistence, type-safe search API, configurable sync (download/upload, conflict resolution), plus demo app illustrating integration.
A Kotlin Multiplatform library for building offline-capable healthcare applications using the HL7 FHIR R4 standard. It provides on-device FHIR resource persistence, a type-safe search API, and synchronization with remote FHIR servers.
The library's support for different target platforms is listed in the following table:
| Target platform | Gradle target | Artifact suffix | Support |
|---|---|---|---|
| Kotlin/JVM (Desktop) | jvm("desktop") |
-desktop |
✅ |
| Kotlin/Wasm | wasmJs |
-wasm-js |
✅ |
| Android applications and libraries | android |
-android |
✅ |
| iOS (Apple silicon simulator) | iosSimulatorArm64 |
-iossimulatorarm64 |
✅ |
| iOS (device) | iosArm64 |
-iosarm64 |
✅ |
The engine-app module is a multiplatform demo application (Android, Desktop, iOS, and Web). Run it
with:
./gradlew :engine-app:run # Desktop
./gradlew :engine-app:wasmJsBrowserDevelopmentRun # Web
./gradlew :engine-app:installDebug # AndroidFor iOS, build the framework and run the demo from Xcode on a simulator.
To use the Kotlin FHIR Engine library in your project, you need to add the library dependency to your
project. To do that, first make sure to include the mavenCentral()1 repository in the
build.gradle.kts file in your project root.
// build.gradle.kts
repositories {
// Other repositories such as gradlePluginPortal() and google()
mavenCentral()
}
Next, follow the instructions for your specific project type.
For Kotlin Multiplatform projects, add the dependency to the shared commonMain source set within
the kotlin block of the module's build.gradle.kts file (e.g., composeApp/build.gradle.kts or
shared/build.gradle.kts). This makes the library available across all platforms in your project.
// e.g., composeApp/build.gradle.kts or shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.ohs.fhir:fhir-engine:2.0.0-alpha02")
}
}
}
For Android projects, add the dependency to the dependencies block in the module's
build.gradle.kts file (e.g., app/build.gradle.kts).
// e.g., app/build.gradle.kts
dependencies {
implementation("dev.ohs.fhir:fhir-engine:2.0.0-alpha02")
}
Initialize FhirEngineProvider once at startup, then use the FhirEngine instance for CRUD and
search. On Android pass the application Context; on other platforms pass Unit (the default).
FhirEngineProvider.init(
FhirEngineConfiguration(
// Optional: a remote server to sync against, and custom search parameters.
serverConfiguration = ServerConfiguration(baseUrl = "https://hapi.fhir.org/baseR4/"),
),
platformContext, // Android: applicationContext; other platforms: Unit
)
val fhirEngine = FhirEngineProvider.getInstance(platformContext)
// Create (returns the assigned ids)
val ids = fhirEngine.create(Patient(id = "patient-1"))
// Read
val patient = fhirEngine.get(ResourceType.Patient, "patient-1") as Patient
// Update / delete
fhirEngine.update(patient)
fhirEngine.delete(ResourceType.Patient, "patient-1")
// Search (an empty block matches all; add filters/sort inside the block)
val patients = fhirEngine.search<Patient> {}The engine synchronises with a remote FHIR server in two phases: download changed resources from the server, then upload local changes. You wire this up by implementing FhirSyncTask and scheduling it with a platform-appropriate mechanism.
FhirSyncTask defines what the sync job needs. Implement all four methods:
class MyFhirSyncTask : FhirSyncTask {
override fun getFhirEngine(): FhirEngine = /* your FhirEngine instance */
override fun getDownloadWorkManager(): DownloadWorkManager = MyDownloadWorkManager()
override fun getConflictResolver(): ConflictResolver = AcceptLocalConflictResolver
override fun getUploadStrategy(): UploadStrategy =
UploadStrategy.forBundleRequest(
methodForCreate = HttpCreateMethod.PUT,
methodForUpdate = HttpUpdateMethod.PATCH,
squash = true,
bundleSize = 500,
)
}getDownloadWorkManager() — controls what resources are requested and how responses are processed.getConflictResolver() — resolves conflicts between local and remote versions. Built-in options: AcceptLocalConflictResolver and AcceptRemoteConflictResolver.getUploadStrategy() — controls how local changes are sent to the server.DownloadWorkManager drives the download phase by generating requests and processing each response:
class MyDownloadWorkManager : DownloadWorkManager {
override suspend fun getNextRequest(): DownloadRequest? {
// Return the next request, or null when all resources have been downloaded
}
override suspend fun getSummaryRequestUrls(): Map<ResourceType, String> {
// Return URLs to fetch resource counts (used for progress display)
return emptyMap()
}
override suspend fun processResponse(response: Resource): Collection<Resource> {
// Extract and return resources to save from the server response
return (response as? Bundle)?.entry?.mapNotNull { it.resource } ?: emptyList()
}
}For a timestamp-based approach that only downloads resources modified since the last sync, see TimestampBasedDownloadWorkManagerImpl in the demo app (engine-app).
Android has first-class support via FhirSyncWorker (a CoroutineWorker that implements FhirSyncTask) and the Sync object. Extend FhirSyncWorker instead of implementing FhirSyncTask directly:
class MyFhirSyncWorker(appContext: Context, workerParams: WorkerParameters) :
FhirSyncWorker(appContext, workerParams) {
override fun getFhirEngine() = /* your FhirEngine instance */
override fun getDownloadWorkManager() = MyDownloadWorkManager()
override fun getConflictResolver() = AcceptLocalConflictResolver
override fun getUploadStrategy() = UploadStrategy.forBundleRequest(
methodForCreate = HttpCreateMethod.PUT,
methodForUpdate = HttpUpdateMethod.PATCH,
squash = true,
bundleSize = 500,
)
}Schedule it using Sync:
// One-time sync
val statusFlow = Sync.oneTimeSync<MyFhirSyncWorker>(context)
statusFlow.collect { status -> /* handle CurrentSyncJobStatus */ }
// Periodic sync every 15 minutes, requiring network connectivity
val periodicFlow = Sync.periodicSync<MyFhirSyncWorker>(
context,
PeriodicSyncConfiguration(
repeat = RepeatInterval(15.minutes),
syncConstraints = SyncConstraints(requiredNetworkType = NetworkType.CONNECTED),
),
)
periodicFlow.collect { status -> /* handle PeriodicSyncJobStatus */ }
// Cancel
Sync.cancelOneTimeSync<MyFhirSyncWorker>(context)
Sync.cancelPeriodicSync<MyFhirSyncWorker>(context)These platforms require platform-specific scheduling. Implement FhirSyncTask directly and invoke runSync() from within your scheduler. Sample implementations are provided in the demo app (engine-app):
BGProcessingTask via IosBgSyncScheduler to run sync in the background. See also DemoFhirSyncTask.ios.kt. Requires UIBackgroundModes → processing in your app's Info.plist.Sync. See also DemoFhirSyncTask.desktop.kt. Sync only runs while the JVM process is alive; there is no OS-level background scheduling.All scheduling paths emit a flow of status updates:
| Status | Description |
|---|---|
CurrentSyncJobStatus.Enqueued |
Job is queued, not yet started |
CurrentSyncJobStatus.Running |
Sync is in progress |
CurrentSyncJobStatus.Succeeded |
Sync completed successfully |
CurrentSyncJobStatus.Failed |
Sync failed (after configured retries) |
CurrentSyncJobStatus.Cancelled |
Sync was cancelled |
For periodic sync, PeriodicSyncJobStatus combines currentSyncJobStatus with lastSyncJobStatus, the terminal result of the most recently completed cycle.
Persistence uses Room. Web support
requires Room 3 (androidx.room3), Room 2 has no Wasm target, which is why the engine uses
androidx.room3.* on all platforms.
Android, iOS, and Desktop use the bundled native SQLite driver (BundledSQLiteDriver from
sqlite-bundled), which has no Wasm build. On Wasm the database instead uses WebWorkerSQLiteDriver,
backed by a SQLite-WASM Web Worker running in an
OPFS-persisted
Web Worker (engine/src/webMain/npm/sqlite-wasm-worker/worker.js, npm dependency
@sqlite.org/sqlite-wasm). createSqliteWasmDriver()
(engine/src/webMain/kotlin/dev/ohs/fhir/engine/wasm/worker/SqliteWasmWorker.kt) wires that worker to
WebWorkerSQLiteDriver, which the engine's Wasm DatabaseBuilder uses via .setDriver().
SQLite-WASM and OPFS need SharedArrayBuffer, which requires a
cross-origin-isolated page. The demo app sets the required
COOP/COEP headers in engine-app/webpack.config.d/coop-coep.js.
sqlite-wasm-worker is a local npm module, not a published one, so Gradle can't propagate it to
projects that depend on fhir-engine from Maven (unlike @sqlite.org/sqlite-wasm, a real npm
package, which does propagate automatically). Without this workaround, apps that depend on
fhir-engine will hit Module not found: Error: Can't resolve 'sqlite-wasm-worker/worker.js' when
building for js or wasmJs.
Fix: copy the worker module into your own project and declare a matching local npm dependency,
so your build resolves the same specifier the engine's compiled code looks for. The demo app
(engine-app) has this workaround wired up as a working, copy-pasteable reference — see
engine-app/src/webMain/npm/sqlite-wasm-worker/
and the webMain.dependencies block in
engine-app/build.gradle.kts.
Copy
package.json
and
worker.js
into your project, e.g. app/src/webMain/npm/sqlite-wasm-worker/.
Declare a matching npm dependency on the source set that compiles for your js/wasmJs targets
(Kotlin's default hierarchy template groups both under webMain once both targets are declared):
// e.g. app/build.gradle.kts
kotlin {
sourceSets {
webMain.dependencies {
implementation(
npm(
"sqlite-wasm-worker",
layout.projectDirectory.dir("src/webMain/npm/sqlite-wasm-worker").asFile,
),
)
}
}
}Copy
engine-app/webpack.config.d/coop-coep.js
into your own project's webpack.config.d/ directory. Every Kotlin/JS module builds its own
webpack config, so this doesn't propagate from :engine — without it, sqlite3.oo1.OpfsDb is
unavailable and every database operation fails. This only covers your local dev server; your
production host/CDN must also send the same Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp headers.
This is temporary. AndroidX's own
Room 3 release notes note that
WebWorkerSQLiteDriver doesn't yet ship a default worker and that "a future version of the Web
driver might contain a default worker published in NPM, making the web setup simpler." Once that
ships, this workaround — and the local sqlite-wasm-worker module in :engine itself — will no
longer be necessary.
To publish a new release, first update mavenVersion in gradle.properties to the new version. Then
follow one of the methods below.
To publish artifacts to your local Maven repository (~/.m2/repository) for local development and
testing, run:
./gradlew :engine:publishToMavenLocalPublishing to Maven Central requires two sets of credentials:
See the Kotlin Multiplatform Publishing Guide and the Maven Central Publishing Guide for more information on how to set up these credentials.
For manual publishing, store the credentials in the global ~/.gradle/gradle.properties in your
environment (not the project's gradle.properties) so they are never committed to the repository:
# Maven Central Credentials
mavenCentralUsername=YOUR_USERNAME_TOKEN
mavenCentralPassword=YOUR_PASSWORD_TOKEN
# GPG Signing (file-based)
signing.keyId=YOUR_KEY_ID
signing.password=YOUR_KEY_PASSWORD
signing.secretKeyRingFile=/path/to/secring.gpgThen run:
./gradlew :engine:publishToMavenCentralThe project includes a GitHub Actions workflow that publishes to Maven Central when a new GitHub release (or pre-release) is created.
The workflow requires the following GitHub organization or repository secrets (already set up):
| Secret | Description |
|---|---|
MAVEN_CENTRAL_USERNAME |
Same as mavenCentralUsername
|
MAVEN_CENTRAL_PASSWORD |
Same as mavenCentralPassword
|
GPG_KEY_CONTENTS |
Needs to be exported using the command gpg --armor --export-secret-keys YOUR_KEY_ID
|
SIGNING_PASSWORD |
Same as signing.password
|
Licensed under the Apache License, Version 2.0.
Early versions of this library were published under the group ID com.google.android.fhir and
artifact ID engine on Google Maven. ↩
A Kotlin Multiplatform library for building offline-capable healthcare applications using the HL7 FHIR R4 standard. It provides on-device FHIR resource persistence, a type-safe search API, and synchronization with remote FHIR servers.
The library's support for different target platforms is listed in the following table:
| Target platform | Gradle target | Artifact suffix | Support |
|---|---|---|---|
| Kotlin/JVM (Desktop) | jvm("desktop") |
-desktop |
✅ |
| Kotlin/Wasm | wasmJs |
-wasm-js |
✅ |
| Android applications and libraries | android |
-android |
✅ |
| iOS (Apple silicon simulator) | iosSimulatorArm64 |
-iossimulatorarm64 |
✅ |
| iOS (device) | iosArm64 |
-iosarm64 |
✅ |
The engine-app module is a multiplatform demo application (Android, Desktop, iOS, and Web). Run it
with:
./gradlew :engine-app:run # Desktop
./gradlew :engine-app:wasmJsBrowserDevelopmentRun # Web
./gradlew :engine-app:installDebug # AndroidFor iOS, build the framework and run the demo from Xcode on a simulator.
To use the Kotlin FHIR Engine library in your project, you need to add the library dependency to your
project. To do that, first make sure to include the mavenCentral()1 repository in the
build.gradle.kts file in your project root.
// build.gradle.kts
repositories {
// Other repositories such as gradlePluginPortal() and google()
mavenCentral()
}
Next, follow the instructions for your specific project type.
For Kotlin Multiplatform projects, add the dependency to the shared commonMain source set within
the kotlin block of the module's build.gradle.kts file (e.g., composeApp/build.gradle.kts or
shared/build.gradle.kts). This makes the library available across all platforms in your project.
// e.g., composeApp/build.gradle.kts or shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.ohs.fhir:fhir-engine:2.0.0-alpha02")
}
}
}
For Android projects, add the dependency to the dependencies block in the module's
build.gradle.kts file (e.g., app/build.gradle.kts).
// e.g., app/build.gradle.kts
dependencies {
implementation("dev.ohs.fhir:fhir-engine:2.0.0-alpha02")
}
Initialize FhirEngineProvider once at startup, then use the FhirEngine instance for CRUD and
search. On Android pass the application Context; on other platforms pass Unit (the default).
FhirEngineProvider.init(
FhirEngineConfiguration(
// Optional: a remote server to sync against, and custom search parameters.
serverConfiguration = ServerConfiguration(baseUrl = "https://hapi.fhir.org/baseR4/"),
),
platformContext, // Android: applicationContext; other platforms: Unit
)
val fhirEngine = FhirEngineProvider.getInstance(platformContext)
// Create (returns the assigned ids)
val ids = fhirEngine.create(Patient(id = "patient-1"))
// Read
val patient = fhirEngine.get(ResourceType.Patient, "patient-1") as Patient
// Update / delete
fhirEngine.update(patient)
fhirEngine.delete(ResourceType.Patient, "patient-1")
// Search (an empty block matches all; add filters/sort inside the block)
val patients = fhirEngine.search<Patient> {}The engine synchronises with a remote FHIR server in two phases: download changed resources from the server, then upload local changes. You wire this up by implementing FhirSyncTask and scheduling it with a platform-appropriate mechanism.
FhirSyncTask defines what the sync job needs. Implement all four methods:
class MyFhirSyncTask : FhirSyncTask {
override fun getFhirEngine(): FhirEngine = /* your FhirEngine instance */
override fun getDownloadWorkManager(): DownloadWorkManager = MyDownloadWorkManager()
override fun getConflictResolver(): ConflictResolver = AcceptLocalConflictResolver
override fun getUploadStrategy(): UploadStrategy =
UploadStrategy.forBundleRequest(
methodForCreate = HttpCreateMethod.PUT,
methodForUpdate = HttpUpdateMethod.PATCH,
squash = true,
bundleSize = 500,
)
}getDownloadWorkManager() — controls what resources are requested and how responses are processed.getConflictResolver() — resolves conflicts between local and remote versions. Built-in options: AcceptLocalConflictResolver and AcceptRemoteConflictResolver.getUploadStrategy() — controls how local changes are sent to the server.DownloadWorkManager drives the download phase by generating requests and processing each response:
class MyDownloadWorkManager : DownloadWorkManager {
override suspend fun getNextRequest(): DownloadRequest? {
// Return the next request, or null when all resources have been downloaded
}
override suspend fun getSummaryRequestUrls(): Map<ResourceType, String> {
// Return URLs to fetch resource counts (used for progress display)
return emptyMap()
}
override suspend fun processResponse(response: Resource): Collection<Resource> {
// Extract and return resources to save from the server response
return (response as? Bundle)?.entry?.mapNotNull { it.resource } ?: emptyList()
}
}For a timestamp-based approach that only downloads resources modified since the last sync, see TimestampBasedDownloadWorkManagerImpl in the demo app (engine-app).
Android has first-class support via FhirSyncWorker (a CoroutineWorker that implements FhirSyncTask) and the Sync object. Extend FhirSyncWorker instead of implementing FhirSyncTask directly:
class MyFhirSyncWorker(appContext: Context, workerParams: WorkerParameters) :
FhirSyncWorker(appContext, workerParams) {
override fun getFhirEngine() = /* your FhirEngine instance */
override fun getDownloadWorkManager() = MyDownloadWorkManager()
override fun getConflictResolver() = AcceptLocalConflictResolver
override fun getUploadStrategy() = UploadStrategy.forBundleRequest(
methodForCreate = HttpCreateMethod.PUT,
methodForUpdate = HttpUpdateMethod.PATCH,
squash = true,
bundleSize = 500,
)
}Schedule it using Sync:
// One-time sync
val statusFlow = Sync.oneTimeSync<MyFhirSyncWorker>(context)
statusFlow.collect { status -> /* handle CurrentSyncJobStatus */ }
// Periodic sync every 15 minutes, requiring network connectivity
val periodicFlow = Sync.periodicSync<MyFhirSyncWorker>(
context,
PeriodicSyncConfiguration(
repeat = RepeatInterval(15.minutes),
syncConstraints = SyncConstraints(requiredNetworkType = NetworkType.CONNECTED),
),
)
periodicFlow.collect { status -> /* handle PeriodicSyncJobStatus */ }
// Cancel
Sync.cancelOneTimeSync<MyFhirSyncWorker>(context)
Sync.cancelPeriodicSync<MyFhirSyncWorker>(context)These platforms require platform-specific scheduling. Implement FhirSyncTask directly and invoke runSync() from within your scheduler. Sample implementations are provided in the demo app (engine-app):
BGProcessingTask via IosBgSyncScheduler to run sync in the background. See also DemoFhirSyncTask.ios.kt. Requires UIBackgroundModes → processing in your app's Info.plist.Sync. See also DemoFhirSyncTask.desktop.kt. Sync only runs while the JVM process is alive; there is no OS-level background scheduling.All scheduling paths emit a flow of status updates:
| Status | Description |
|---|---|
CurrentSyncJobStatus.Enqueued |
Job is queued, not yet started |
CurrentSyncJobStatus.Running |
Sync is in progress |
CurrentSyncJobStatus.Succeeded |
Sync completed successfully |
CurrentSyncJobStatus.Failed |
Sync failed (after configured retries) |
CurrentSyncJobStatus.Cancelled |
Sync was cancelled |
For periodic sync, PeriodicSyncJobStatus combines currentSyncJobStatus with lastSyncJobStatus, the terminal result of the most recently completed cycle.
Persistence uses Room. Web support
requires Room 3 (androidx.room3), Room 2 has no Wasm target, which is why the engine uses
androidx.room3.* on all platforms.
Android, iOS, and Desktop use the bundled native SQLite driver (BundledSQLiteDriver from
sqlite-bundled), which has no Wasm build. On Wasm the database instead uses WebWorkerSQLiteDriver,
backed by a SQLite-WASM Web Worker running in an
OPFS-persisted
Web Worker (engine/src/webMain/npm/sqlite-wasm-worker/worker.js, npm dependency
@sqlite.org/sqlite-wasm). createSqliteWasmDriver()
(engine/src/webMain/kotlin/dev/ohs/fhir/engine/wasm/worker/SqliteWasmWorker.kt) wires that worker to
WebWorkerSQLiteDriver, which the engine's Wasm DatabaseBuilder uses via .setDriver().
SQLite-WASM and OPFS need SharedArrayBuffer, which requires a
cross-origin-isolated page. The demo app sets the required
COOP/COEP headers in engine-app/webpack.config.d/coop-coep.js.
sqlite-wasm-worker is a local npm module, not a published one, so Gradle can't propagate it to
projects that depend on fhir-engine from Maven (unlike @sqlite.org/sqlite-wasm, a real npm
package, which does propagate automatically). Without this workaround, apps that depend on
fhir-engine will hit Module not found: Error: Can't resolve 'sqlite-wasm-worker/worker.js' when
building for js or wasmJs.
Fix: copy the worker module into your own project and declare a matching local npm dependency,
so your build resolves the same specifier the engine's compiled code looks for. The demo app
(engine-app) has this workaround wired up as a working, copy-pasteable reference — see
engine-app/src/webMain/npm/sqlite-wasm-worker/
and the webMain.dependencies block in
engine-app/build.gradle.kts.
Copy
package.json
and
worker.js
into your project, e.g. app/src/webMain/npm/sqlite-wasm-worker/.
Declare a matching npm dependency on the source set that compiles for your js/wasmJs targets
(Kotlin's default hierarchy template groups both under webMain once both targets are declared):
// e.g. app/build.gradle.kts
kotlin {
sourceSets {
webMain.dependencies {
implementation(
npm(
"sqlite-wasm-worker",
layout.projectDirectory.dir("src/webMain/npm/sqlite-wasm-worker").asFile,
),
)
}
}
}Copy
engine-app/webpack.config.d/coop-coep.js
into your own project's webpack.config.d/ directory. Every Kotlin/JS module builds its own
webpack config, so this doesn't propagate from :engine — without it, sqlite3.oo1.OpfsDb is
unavailable and every database operation fails. This only covers your local dev server; your
production host/CDN must also send the same Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp headers.
This is temporary. AndroidX's own
Room 3 release notes note that
WebWorkerSQLiteDriver doesn't yet ship a default worker and that "a future version of the Web
driver might contain a default worker published in NPM, making the web setup simpler." Once that
ships, this workaround — and the local sqlite-wasm-worker module in :engine itself — will no
longer be necessary.
To publish a new release, first update mavenVersion in gradle.properties to the new version. Then
follow one of the methods below.
To publish artifacts to your local Maven repository (~/.m2/repository) for local development and
testing, run:
./gradlew :engine:publishToMavenLocalPublishing to Maven Central requires two sets of credentials:
See the Kotlin Multiplatform Publishing Guide and the Maven Central Publishing Guide for more information on how to set up these credentials.
For manual publishing, store the credentials in the global ~/.gradle/gradle.properties in your
environment (not the project's gradle.properties) so they are never committed to the repository:
# Maven Central Credentials
mavenCentralUsername=YOUR_USERNAME_TOKEN
mavenCentralPassword=YOUR_PASSWORD_TOKEN
# GPG Signing (file-based)
signing.keyId=YOUR_KEY_ID
signing.password=YOUR_KEY_PASSWORD
signing.secretKeyRingFile=/path/to/secring.gpgThen run:
./gradlew :engine:publishToMavenCentralThe project includes a GitHub Actions workflow that publishes to Maven Central when a new GitHub release (or pre-release) is created.
The workflow requires the following GitHub organization or repository secrets (already set up):
| Secret | Description |
|---|---|
MAVEN_CENTRAL_USERNAME |
Same as mavenCentralUsername
|
MAVEN_CENTRAL_PASSWORD |
Same as mavenCentralPassword
|
GPG_KEY_CONTENTS |
Needs to be exported using the command gpg --armor --export-secret-keys YOUR_KEY_ID
|
SIGNING_PASSWORD |
Same as signing.password
|
Licensed under the Apache License, Version 2.0.
Early versions of this library were published under the group ID com.google.android.fhir and
artifact ID engine on Google Maven. ↩