Skip to content

ohs-foundation/kotlin-fhir-engine

Repository files navigation

Kotlin FHIR Engine

Release Release Release Release Release Release License

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.

Supported platforms

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

Demo app

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                     # Android

For iOS, build the framework and run the demo from Xcode on a simulator.

User guide

Adding the library dependency to your project

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.

Kotlin Multiplatform Projects

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")
        }
    }
}

Android projects

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")
}

Persisting and searching resources

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> {}

Synchronizing with a FHIR server

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.

1. Implement FhirSyncTask

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.

2. Implement DownloadWorkManager

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).

3. Schedule Sync

Android — WorkManager (built-in API)

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)
iOS and Desktop

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):

  • iOS — uses BGProcessingTask via IosBgSyncScheduler to run sync in the background. See also DemoFhirSyncTask.ios.kt. Requires UIBackgroundModesprocessing in your app's Info.plist.
  • Desktop (JVM) — coroutine-based foreground scheduling via Sync. See also DemoFhirSyncTask.desktop.kt. Sync only runs while the JVM process is alive; there is no OS-level background scheduling.

Sync Status

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.

Web (Wasm)

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.

Required workaround for consumers targeting js/wasmJs

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.

  1. Copy package.json and worker.js into your project, e.g. app/src/webMain/npm/sqlite-wasm-worker/.

  2. 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,
            ),
          )
        }
      }
    }
  3. 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.

Developer guide

Publishing

To publish a new release, first update mavenVersion in gradle.properties to the new version. Then follow one of the methods below.

Maven Local

To publish artifacts to your local Maven repository (~/.m2/repository) for local development and testing, run:

./gradlew :engine:publishToMavenLocal

Maven Central

Publishing to Maven Central requires two sets of credentials:

  1. Maven Central credentials: your Sonatype portal username and password tokens.
  2. GPG signing: a GPG key and its passphrase, used to sign all published artifacts.

See the Kotlin Multiplatform Publishing Guide and the Maven Central Publishing Guide for more information on how to set up these credentials.

Publishing to Maven Central manually

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.gpg

Then run:

./gradlew :engine:publishToMavenCentral
Publishing to Maven Central using GitHub Actions

The 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

License

Licensed under the Apache License, Version 2.0.

Footnotes

  1. Early versions of this library were published under the group ID com.google.android.fhir and artifact ID engine on Google Maven.