diff --git a/README.md b/README.md index 6464c889..35b37343 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,51 @@ Code generation is part of compilation. The `ig-codegen` Gradle plugin runs its For iOS, open [`iosApp/`](./iosApp) in Xcode and run, or use the run-configuration widget in a Kotlin Multiplatform IDE. +## Identity provider (OIDC) configuration + +Sign-in is provider-agnostic. The client speaks standard OpenID Connect and resolves every endpoint — authorization, token, userinfo, and end-session — at runtime from the provider's discovery document at `{issuer}/.well-known/openid-configuration`. Nothing is hardcoded per provider, so switching from one OAuth2/OIDC provider to another is a configuration change, not a code change: point `OAUTH_ISSUER` at the new provider (and update the client id, scopes, and redirect registration to match). See [`OidcAuthApi`](./ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/OidcAuthApi.kt) and [`OAuthConfig`](./ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/OAuthConfig.kt). + +The flow is Authorization Code with PKCE and a `state` check. The provider must therefore expose the client as a **public client** (no client secret) that permits PKCE, and the app's redirect URIs must be registered as allowed redirects. Any standards-compliant OIDC provider — Keycloak, Okta, Zitadel, and others — works under these constraints. + +### Configure a provider + +1. Register a public (PKCE) client at your provider and note its client id. +2. Register the per-platform redirect URIs below as allowed redirect URIs. +3. Copy `local.properties.sample` to `local.properties` and fill in the keys below. The file is git-ignored; CI may override any key with an environment variable of the same name. + +Configuration is read at build time by the `generateAuthConfig` task and baked into `GeneratedAuthConfig`, which backs `OAuthConfig.Default`. + +| Key | Meaning | Example | +| --- | --- | --- | +| `OAUTH_ISSUER` | OIDC issuer; discovery is performed against `{issuer}/.well-known/openid-configuration` | `https://keycloak.example.org/realms/ohs-player` | +| `OAUTH_CLIENT_ID` | Public client id (PKCE, no secret) | `ohs-player-reference-app` | +| `OAUTH_SCOPES` | Space-separated scopes; `offline_access` yields a refresh token | `openid profile email offline_access` | +| `OAUTH_REDIRECT_SCHEME` | Custom URI scheme for the mobile deep-link redirect | `dev.ohs.player.reference.app` | +| `OAUTH_REDIRECT_HOST` | Host component of the mobile redirect | `auth` | +| `OAUTH_DESKTOP_REDIRECT_PORT` | Localhost loopback port for the desktop (JVM) redirect | `8765` | +| `OAUTH_WEB_REDIRECT_URL` | Full-page redirect URL for the web (JS/Wasm) build | `http://localhost:8080/callback` | +| `FHIR_BASE_URL` | Base URL of the FHIR server; requests carry the session Bearer token | `https://hapi.fhir.org/baseR4` | + +### Issuer examples + +The issuer is the only value that identifies the provider. Note that for Keycloak the realm is part of the issuer. + +| Provider | `OAUTH_ISSUER` | +| --- | --- | +| Keycloak | `https://host/realms/` | +| Okta | `https://.okta.com` (or a custom authorization server, `https://.okta.com/oauth2/`) | +| Zitadel | `https://.zitadel.cloud` | + +### Redirect URIs to register + +Each platform completes the authorization redirect differently, so register all of the ones you build for: + +| Platform | Redirect URI | Derived from | +| --- | --- | --- | +| Android / iOS | `{OAUTH_REDIRECT_SCHEME}://{OAUTH_REDIRECT_HOST}` | e.g. `dev.ohs.player.reference.app://auth` | +| Desktop (JVM) | `http://127.0.0.1:{OAUTH_DESKTOP_REDIRECT_PORT}/callback` (also register the `http://localhost:...` form) | loopback port | +| Web (JS/Wasm) | `OAUTH_WEB_REDIRECT_URL` | e.g. `http://localhost:8080/callback` | + ## From FHIR data to view state A screen never consumes a raw FHIR resource. It consumes a typed *view-state* — a flat, serializable data class containing exactly the fields the screen needs. View-state is produced by a configuration-driven pipeline: diff --git a/gradle.properties b/gradle.properties index 8ce6b587..f735ca87 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,3 +15,6 @@ android.useAndroidX=true #MPP kotlin.mpp.enableCInteropCommonization=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 587ee760..1586e90f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ androidx-core = "1.18.0" androidx-espresso = "3.7.0" androidx-lifecycle = "2.10.0" androidx-testExt = "1.3.0" +androidx-work = "2.8.1" composeHotReload = "1.1.1" composeMultiplatform = "1.11.0" composeMaterialIcons = "1.7.3" @@ -16,6 +17,11 @@ fhirDataCapture = "2.0.0-alpha02" junit = "4.13.2" kermit = "2.1.0" koinBom = "4.1.1" +ktor = "3.2.3" +ksafe = "2.1.3" +kotlincryptoHash = "0.8.0" +androidxBrowser = "1.8.0" +kotlinxBrowser = "0.3" kotlin = "2.3.21" kotlinx-coroutines = "1.11.0" kotlinpoet = "2.3.0" @@ -24,9 +30,10 @@ ktfmt = "0.54" ktlint = "1.5.0" navigation-compose = "2.9.2" material3 = "1.10.0-alpha05" +composeAdaptive = "1.2.0" kotlinxSerializationJson = "1.11.0" spotless = "8.6.0" -ohsFhirEngine = "2.0.0-alpha01" +ohsFhirEngine = "2.0.0-alpha02" ohsFhirModel = "1.0.0-beta05" ohsFhirPath = "1.0.0-beta03" kotlinxDatetime = "0.8.0" @@ -36,6 +43,7 @@ ionspin-bignum = "0.3.10" fhir-data-capture = { module = "dev.ohs.fhir:fhir-data-capture", version.ref = "fhirDataCapture" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koinBom" } +koin-test = { module = "io.insert-koin:koin-test" } koin-core = { module = "io.insert-koin:koin-core" } koin-compose = { module = "io.insert-koin:koin-compose" } koin-composeViewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } @@ -54,6 +62,10 @@ compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.re compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } compose-material = { module = "org.jetbrains.compose.material:material", version.ref = "composeMultiplatform" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } +compose-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "composeAdaptive" } +compose-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "composeAdaptive" } +compose-adaptive-navigation = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation", version.ref = "composeAdaptive" } +compose-material3-adaptive-navigation-suite = { module = "org.jetbrains.compose.material3:material3-adaptive-navigation-suite", version.ref = "material3" } compose-materialIconsCore = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "composeMaterialIcons" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } @@ -70,6 +82,21 @@ ohs-fhir-model = { module = "dev.ohs.fhir:fhir-model", version.ref = "ohsFhirMod ohs-fhir-path = { module = "dev.ohs.fhir:fhir-path", version.ref = "ohsFhirPath" } ionspin-bignum = { module = "com.ionspin.kotlin:bignum", version.ref = "ionspin-bignum" } spotless = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } +ksafe = { module = "eu.anifantakis:ksafe", version.ref = "ksafe" } +androidx-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" } +androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } +kotlincrypto-hash-bom = { module = "org.kotlincrypto.hash:bom", version.ref = "kotlincryptoHash" } +kotlincrypto-hash-sha2 = { module = "org.kotlincrypto.hash:sha2" } +kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinxBrowser" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png index 53fc536f..d89b4124 100644 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png differ diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 11845e1d..ba74a8db 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -2,7 +2,17 @@ + CFBundleDisplayName + Player Reference CADisableMinimumFrameDurationOnPhone + BGTaskSchedulerPermittedIdentifiers + + dev.ohs.player.reference.app.sync.periodic + + UIBackgroundModes + + processing + diff --git a/local.properties.sample b/local.properties.sample new file mode 100644 index 00000000..21418fd5 --- /dev/null +++ b/local.properties.sample @@ -0,0 +1,33 @@ +# --------------------------------------------------------------------------- +# local.properties.sample — copy to `local.properties` and fill in your values. +# `local.properties` is git-ignored. These are read at build time by the +# :ohs-player-reference-app `generateAuthConfig` task. CI may override any of +# these with environment variables of the same name. +# +# PKCE uses a PUBLIC client, so there is NO client secret here. +# --------------------------------------------------------------------------- + +# --- OAuth / OIDC provider --------------------------------------------------- +# Provider-agnostic: the app resolves all endpoints via OIDC discovery at +# {OAUTH_ISSUER}/.well-known/openid-configuration. +# Keycloak: https://host/realms/ (the realm IS part of the issuer) +OAUTH_ISSUER=https://keycloak.example.org/realms/ohs-player +# Public client id (PKCE / public client — no secret). +OAUTH_CLIENT_ID=ohs-player-reference-app +# OAuth scopes (space separated). offline_access yields a refresh token. +OAUTH_SCOPES=openid profile email offline_access + +# --- Redirect URIs (must be registered as Valid Redirect URIs at the provider) --- +# Android + iOS use a custom URI scheme deep link: {SCHEME}://{HOST} +OAUTH_REDIRECT_SCHEME=dev.ohs.player.reference.app +OAUTH_REDIRECT_HOST=auth +# Desktop (JVM) uses a localhost loopback redirect on this port. +# Register as: http://127.0.0.1:8765/callback (and http://localhost:8765/callback) +OAUTH_DESKTOP_REDIRECT_PORT=8765 +# Web (JS/Wasm) uses a full-page redirect back to the app origin. +OAUTH_WEB_REDIRECT_URL=http://localhost:8080/callback + +# --- FHIR server -------------------------------------------------------------- +# Base URL of the remote FHIR server. Requests are authenticated with the +# signed-in session's Bearer token (see FhirBearerAuthenticator). +FHIR_BASE_URL=https://hapi.fhir.org/baseR4 diff --git a/ohs-player-reference-app/build.gradle.kts b/ohs-player-reference-app/build.gradle.kts index 470f3c57..58475318 100644 --- a/ohs-player-reference-app/build.gradle.kts +++ b/ohs-player-reference-app/build.gradle.kts @@ -13,8 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:OptIn(ExperimentalKotlinGradlePluginApi::class) + import java.util.Properties import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -30,7 +33,26 @@ plugins { } kotlin { - androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } + // Desktop, js and wasmJs lack a native OS background scheduler, so they share a "foregroundSync" + // source set (see `foregroundSyncMain/.../data/sync/Sync.kt`) letting one coroutine-based + // scheduler serve all three instead of a separate implementation per platform. js and wasmJs + // further share a nested "foregroundSyncWeb", kept apart from the default `webMain` group, which + // holds web code unrelated to sync. + applyDefaultHierarchyTemplate { + common { + group("foregroundSync") { + withJvm() + group("foregroundSyncWeb") { + withJs() + withWasmJs() + } + } + } + } + + // fhir-engine's Android artifact ships inline functions (e.g. Sync.oneTimeSync) compiled at JVM + // target 21 — inlining them requires this target to be at least as high. + androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget -> iosTarget.binaries.framework { @@ -52,10 +74,16 @@ kotlin { binaries.executable() } + // expect/actual classes (AuthorizationLauncher) are stable enough for our use. + compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") } + sourceSets { androidMain.dependencies { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) + implementation(libs.ktor.client.okhttp) + implementation(libs.androidx.browser) + implementation(libs.androidx.work.runtime) } commonMain.dependencies { implementation(project(":ohs-player-library")) @@ -63,6 +91,10 @@ kotlin { implementation(libs.compose.foundation) implementation(libs.compose.material) implementation(libs.compose.material3) + implementation(libs.compose.adaptive) + implementation(libs.compose.adaptive.layout) + implementation(libs.compose.adaptive.navigation) + implementation(libs.compose.material3.adaptive.navigation.suite) implementation(libs.compose.materialIconsCore) implementation(libs.compose.ui) implementation(libs.compose.components.resources) @@ -81,17 +113,48 @@ kotlin { implementation(libs.ohs.fhir.model) implementation(libs.ohs.fhir.path) implementation(libs.fhir.data.capture) + // Auth: shared OAuth2/PKCE client, secure session storage, SHA-256 for PKCE. + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.auth) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ksafe) + implementation(project.dependencies.platform(libs.kotlincrypto.hash.bom)) + implementation(libs.kotlincrypto.hash.sha2) } commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.compose.uiTest) implementation(libs.kotlinx.coroutines.test) + implementation(libs.ktor.client.mock) + } + iosMain.dependencies { implementation(libs.ktor.client.darwin) } + getByName("foregroundSyncWebMain").dependencies { implementation(libs.kotlinx.browser) } + webMain.dependencies { + // :engine's WebWorkerSQLiteDriver worker (androidx.sqlite:sqlite-web) is loaded via + // `new Worker(new URL("sqlite-wasm-worker/worker.js", import.meta.url), { type: "module" })` + // — a bare npm specifier that :engine's own local file: npm dependency can't propagate to + // consumers. Vendoring a matching "sqlite-wasm-worker" npm module here (copied from + // kotlin-fhir-engine's engine/src/webMain/npm/sqlite-wasm-worker/) makes that specifier + // resolve in this app's own build too. + implementation( + npm( + "sqlite-wasm-worker", + layout.projectDirectory.dir("src/webMain/npm/sqlite-wasm-worker").asFile, + ) + ) + implementation(libs.ktor.client.js) + implementation(libs.kotlinx.browser) } jvmMain.dependencies { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutinesSwing) + implementation(libs.ktor.client.cio) + } + jvmTest.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.koin.test) } - jvmTest.dependencies { implementation(compose.desktop.currentOs) } } } @@ -142,6 +205,98 @@ val hasReleaseSigning: Boolean = !keystoreKeyPassword.isNullOrBlank() && !keystoreStorePassword.isNullOrBlank() +// --- OAuth / OIDC config ------------------------------------------------------ +// Provider-agnostic: the app resolves endpoints via OIDC discovery +// ({issuer}/.well-known/openid-configuration). +fun authProp(key: String, default: String): String = + nonBlankEnv(key).orNull ?: localProperties[key]?.takeIf { it.isNotBlank() } ?: default + +// Auth/deployment settings live in local.properties (git-ignored), with env-var overrides for CI. +val localProperties: Map = + providers + .fileContents(rootProject.layout.projectDirectory.file("local.properties")) + .asText + .map { text -> + val props = Properties().apply { load(text.reader()) } + props.stringPropertyNames().associateWith(props::getProperty) + } + .getOrElse(emptyMap()) + +val authConfigOutputDir = layout.buildDirectory.dir("generated/authconfig/commonMain/kotlin") + +val generateAuthConfig = + tasks.register("generateAuthConfig") { + val issuer = authProp("OAUTH_ISSUER", "https://keycloak.example.org/realms/ohs-player") + val clientId = authProp("OAUTH_CLIENT_ID", "ohs-player-reference-app") + val redirectScheme = authProp("OAUTH_REDIRECT_SCHEME", "dev.ohs.player.reference.app") + val redirectHost = authProp("OAUTH_REDIRECT_HOST", "auth") + val webRedirectUrl = authProp("OAUTH_WEB_REDIRECT_URL", "http://localhost:8080/callback") + val desktopPort = authProp("OAUTH_DESKTOP_REDIRECT_PORT", "8765") + val scopes = authProp("OAUTH_SCOPES", "openid profile email offline_access") + val fhirBaseUrl = authProp("FHIR_BASE_URL", "https://hapi.fhir.org/baseR4") + val versionName = releaseVersionName + val versionCode = releaseVersionCode + val outDir = authConfigOutputDir + inputs.property("issuer", issuer) + inputs.property("clientId", clientId) + inputs.property("redirectScheme", redirectScheme) + inputs.property("redirectHost", redirectHost) + inputs.property("webRedirectUrl", webRedirectUrl) + inputs.property("desktopPort", desktopPort) + inputs.property("scopes", scopes) + inputs.property("fhirBaseUrl", fhirBaseUrl) + inputs.property("versionName", versionName) + inputs.property("versionCode", versionCode) + outputs.dir(outDir) + doLast { + val pkgDir = + outDir.get().asFile.resolve("dev/ohs/player/reference/app/auth").apply { mkdirs() } + pkgDir + .resolve("GeneratedAuthConfig.kt") + .writeText( + """ + |// Generated by the :ohs-player-reference-app generateAuthConfig task. Do not edit. + |// Values come from local.properties / env vars; see local.properties.sample. + |package dev.ohs.player.reference.app.auth + | + |internal object GeneratedAuthConfig { + | const val ISSUER: String = "$issuer" + | const val CLIENT_ID: String = "$clientId" + | const val REDIRECT_SCHEME: String = "$redirectScheme" + | const val REDIRECT_HOST: String = "$redirectHost" + | const val WEB_REDIRECT_URL: String = "$webRedirectUrl" + | const val DESKTOP_REDIRECT_PORT: Int = $desktopPort + | const val SCOPES: String = "$scopes" + | const val FHIR_BASE_URL: String = "$fhirBaseUrl" + |} + | + """ + .trimMargin() + ) + pkgDir + .resolve("GeneratedAppInfo.kt") + .writeText( + """ + |// Generated by the :ohs-player-reference-app generateAuthConfig task. Do not edit. + |package dev.ohs.player.reference.app.auth + | + |internal object GeneratedAppInfo { + | const val VERSION_NAME: String = "$versionName" + | const val VERSION_CODE: Int = $versionCode + |} + | + """ + .trimMargin() + ) + } + } + +kotlin.sourceSets.named("commonMain") { kotlin.srcDir(authConfigOutputDir) } + +tasks.withType>().configureEach { + dependsOn(generateAuthConfig) +} + android { namespace = "dev.ohs.player.reference.app" compileSdk = libs.versions.android.compileSdk.get().toInt() @@ -178,8 +333,8 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } } @@ -251,8 +406,13 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) - packageName = "dev.ohs.player.reference.app" + packageName = "PlayerReference" packageVersion = composePackageVersion + + val iconsDir = project.layout.projectDirectory.dir("desktop-icons") + macOS { iconFile.set(iconsDir.file("app-icon.icns")) } + windows { iconFile.set(iconsDir.file("app-icon.ico")) } + linux { iconFile.set(iconsDir.file("app-icon.png")) } } } } diff --git a/ohs-player-reference-app/desktop-icons/app-icon.icns b/ohs-player-reference-app/desktop-icons/app-icon.icns new file mode 100644 index 00000000..855b8469 Binary files /dev/null and b/ohs-player-reference-app/desktop-icons/app-icon.icns differ diff --git a/ohs-player-reference-app/desktop-icons/app-icon.ico b/ohs-player-reference-app/desktop-icons/app-icon.ico new file mode 100644 index 00000000..ccd8f2d4 Binary files /dev/null and b/ohs-player-reference-app/desktop-icons/app-icon.ico differ diff --git a/ohs-player-reference-app/desktop-icons/app-icon.png b/ohs-player-reference-app/desktop-icons/app-icon.png new file mode 100644 index 00000000..855b8469 Binary files /dev/null and b/ohs-player-reference-app/desktop-icons/app-icon.png differ diff --git a/ohs-player-reference-app/src/androidMain/AndroidManifest.xml b/ohs-player-reference-app/src/androidMain/AndroidManifest.xml index a36bd34e..4f9a0f2c 100644 --- a/ohs-player-reference-app/src/androidMain/AndroidManifest.xml +++ b/ohs-player-reference-app/src/androidMain/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/OhsPlayerApplication.kt b/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/OhsPlayerApplication.kt index d99eb15e..e171d3d8 100644 --- a/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/OhsPlayerApplication.kt +++ b/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/OhsPlayerApplication.kt @@ -16,18 +16,51 @@ package dev.ohs.player.reference.app import android.app.Application -import dev.ohs.fhir.FhirEngine -import dev.ohs.fhir.FhirEngineConfiguration -import dev.ohs.fhir.FhirEngineProvider import dev.ohs.fhir.datacapture.DataCapture +import dev.ohs.fhir.engine.FhirEngine +import dev.ohs.fhir.engine.FhirEngineConfiguration +import dev.ohs.fhir.engine.FhirEngineProvider +import dev.ohs.fhir.engine.NetworkConfiguration +import dev.ohs.fhir.engine.ServerConfiguration +import dev.ohs.fhir.engine.sync.remote.HttpLogger +import dev.ohs.player.reference.app.auth.AndroidAppContext +import dev.ohs.player.reference.app.auth.FhirBearerAuthenticator +import dev.ohs.player.reference.app.auth.GeneratedAuthConfig import dev.ohs.player.reference.app.data.di.initKoin +import dev.ohs.player.reference.app.data.sync.SYNC_TIMEOUT_DURATION +import dev.ohs.player.reference.app.data.sync.SyncManager +import dev.ohs.player.reference.app.data.sync.WorkManagerSyncManager import org.koin.dsl.module class OhsPlayerApplication : Application() { override fun onCreate() { super.onCreate() - FhirEngineProvider.init(FhirEngineConfiguration(), applicationContext) - initKoin(module { single { FhirEngineProvider.getInstance(applicationContext) } }) + // Before FhirEngineProvider.init: creating KSafe / the sync-timestamp DataStore reaches for + // this, and a headless WorkManager launch never goes through MainActivity. + AndroidAppContext.init(this) + FhirEngineProvider.init( + FhirEngineConfiguration( + serverConfiguration = + ServerConfiguration( + baseUrl = GeneratedAuthConfig.FHIR_BASE_URL, + networkConfiguration = + NetworkConfiguration( + connectionTimeOut = SYNC_TIMEOUT_DURATION, + readTimeOut = SYNC_TIMEOUT_DURATION, + writeTimeOut = SYNC_TIMEOUT_DURATION, + ), + httpLogger = HttpLogger(level = HttpLogger.Level.HEADERS), + authenticator = FhirBearerAuthenticator, + ) + ), + applicationContext, + ) + initKoin( + module { + single { FhirEngineProvider.getInstance(applicationContext) } + single { WorkManagerSyncManager(applicationContext) } + } + ) DataCapture.initialize(applicationContext) } } diff --git a/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/auth/Platform.android.kt b/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/auth/Platform.android.kt new file mode 100644 index 00000000..a7a3a2f3 --- /dev/null +++ b/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/auth/Platform.android.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import dev.ohs.player.reference.app.MainActivity +import eu.anifantakis.lib.ksafe.KSafe +import java.security.SecureRandom +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext + +/** Holds the Application context so KSafe (and others) can be created from common code. */ +internal object AndroidAppContext { + lateinit var application: Application + private set + + fun init(app: Application) { + application = app + } +} + +internal actual fun createKSafe(): KSafe = KSafe(AndroidAppContext.application) + +internal actual fun secureRandomBytes(size: Int): ByteArray = + ByteArray(size).also { SecureRandom().nextBytes(it) } + +/** + * Bridges the deep-link redirect (a separate Activity) back to the awaiting coroutine. A null + * completion means the user dismissed the browser without finishing. + */ +internal object AuthRedirectBus { + @Volatile var pending: CompletableDeferred? = null +} + +actual class AuthorizationLauncher( + private val activity: Activity, + actual override val redirectUri: String, +) : AuthorizationLauncherApi { + actual override suspend fun authorize(authUrl: String): AuthResult { + val deferred = CompletableDeferred() + AuthRedirectBus.pending = deferred + val lifecycle = (activity as? LifecycleOwner)?.lifecycle + val cancelOnReturn = + object : DefaultLifecycleObserver { + private var leftForBrowser = false + + override fun onStop(owner: LifecycleOwner) { + leftForBrowser = true + } + + override fun onResume(owner: LifecycleOwner) { + if (leftForBrowser && !deferred.isCompleted) deferred.complete(null) + } + } + return try { + lifecycle?.let { withContext(Dispatchers.Main) { it.addObserver(cancelOnReturn) } } + CustomTabsIntent.Builder().build().launchUrl(activity, Uri.parse(authUrl)) + val callbackUrl = deferred.await() + if (callbackUrl == null) AuthResult.Canceled else AuthResult.Success(callbackUrl) + } catch (t: Throwable) { + AuthResult.Failure(t.message ?: "Authorization failed") + } finally { + AuthRedirectBus.pending = null + lifecycle?.let { + withContext(NonCancellable + Dispatchers.Main) { it.removeObserver(cancelOnReturn) } + } + } + } + + actual override fun consumeRedirectCallback(): String? = null +} + +@Composable +actual fun rememberAuthorizationLauncher(): AuthorizationLauncher { + val activity = LocalContext.current.findActivity() + return remember(activity) { + AuthorizationLauncher( + activity = activity, + redirectUri = "${GeneratedAuthConfig.REDIRECT_SCHEME}://${GeneratedAuthConfig.REDIRECT_HOST}", + ) + } +} + +private tailrec fun Context.findActivity(): Activity = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> error("rememberAuthorizationLauncher must be called from an Activity context") + } + +/** + * Receives the redirect (custom scheme deep link) and hands the callback URL to the awaiting + * [AuthorizationLauncher]. Registered in the manifest with an intent-filter for + * `${SCHEME}://${HOST}`. + */ +class LoginRedirectActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + intent?.data?.let { AuthRedirectBus.pending?.complete(it.toString()) } + AuthRedirectBus.pending = null + startActivity( + Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + ) + finish() + } +} diff --git a/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/data/sync/AppFhirSyncWorker.kt b/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/data/sync/AppFhirSyncWorker.kt new file mode 100644 index 00000000..a6b35e5c --- /dev/null +++ b/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/data/sync/AppFhirSyncWorker.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import android.content.Context +import androidx.work.WorkerParameters +import dev.ohs.fhir.engine.FhirEngineProvider +import dev.ohs.fhir.engine.sync.ConflictResolver +import dev.ohs.fhir.engine.sync.DownloadWorkManager +import dev.ohs.fhir.engine.sync.FhirSyncWorker +import dev.ohs.fhir.engine.sync.upload.UploadStrategy +import dev.ohs.player.reference.app.auth.ensureFreshSessionForSync +import dev.ohs.player.reference.app.data.DataChangeSignal + +/** + * WorkManager entry point for this app's sync, enqueued via [dev.ohs.fhir.engine.sync.Sync]. Built + * by WorkManager's default factory (reflection on the `(Context, WorkerParameters)` constructor), + * so it constructs its own [AppFhirSyncTask] rather than resolving one through Koin. + */ +class AppFhirSyncWorker(appContext: Context, workerParams: WorkerParameters) : + FhirSyncWorker(appContext, workerParams) { + private val syncTask = AppFhirSyncTask(FhirEngineProvider.getInstance(appContext)) + + /** + * WorkManager can relaunch this worker in a fresh process after the app was killed, where the UI + * bootstrap never ran and [dev.ohs.player.reference.app.auth.SessionRepository] is empty. Hydrate + * and refresh the session first so the sync's requests carry a valid Bearer token. + */ + @Suppress("RestrictedApi") + override suspend fun doWork(): Result { + ensureFreshSessionForSync() + return super.doWork().also { if (it is Result.Success) DataChangeSignal.notifyChanged() } + } + + override fun getFhirEngine() = syncTask.getFhirEngine() + + override fun getDownloadWorkManager(): DownloadWorkManager = syncTask.getDownloadWorkManager() + + override fun getConflictResolver(): ConflictResolver = syncTask.getConflictResolver() + + override fun getUploadStrategy(): UploadStrategy = syncTask.getUploadStrategy() +} diff --git a/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncTimestampDataStore.android.kt b/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncTimestampDataStore.android.kt new file mode 100644 index 00000000..90a1c995 --- /dev/null +++ b/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncTimestampDataStore.android.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import dev.ohs.fhir.engine.sync.createDataStore +import dev.ohs.player.reference.app.auth.AndroidAppContext + +private val dataStore: DataStore by lazy { + createDataStore { + AndroidAppContext.application.filesDir.resolve(SYNC_TIMESTAMP_DATASTORE_FILE_NAME).absolutePath + } +} + +internal actual fun createSyncTimestampDataStore(): DataStore = dataStore diff --git a/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/data/sync/WorkManagerSyncManager.kt b/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/data/sync/WorkManagerSyncManager.kt new file mode 100644 index 00000000..63a8934e --- /dev/null +++ b/ohs-player-reference-app/src/androidMain/kotlin/dev/ohs/player/reference/app/data/sync/WorkManagerSyncManager.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import android.content.Context +import dev.ohs.fhir.engine.sync.CurrentSyncJobStatus +import dev.ohs.fhir.engine.sync.PeriodicSyncConfiguration +import dev.ohs.fhir.engine.sync.RepeatInterval +import dev.ohs.fhir.engine.sync.Sync +import dev.ohs.fhir.engine.sync.SyncJobStatus +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.flow.first + +/** + * Android [SyncManager]: runs sync through WorkManager (via [AppFhirSyncWorker]) rather than in + * process, so both one-time and periodic sync survive the triggering screen being backgrounded or + * the process dying mid-sync. [PeriodicSyncConfiguration]'s default `SyncConstraints` already + * requires `NetworkType.CONNECTED`, so WorkManager itself defers periodic runs until the device is + * online. + */ +class WorkManagerSyncManager(private val context: Context) : SyncManager { + override suspend fun syncNow(): SyncJobStatus { + val terminalStatus = + Sync.oneTimeSync(context).first { + it is CurrentSyncJobStatus.Succeeded || + it is CurrentSyncJobStatus.Failed || + it is CurrentSyncJobStatus.Cancelled + } + return when (terminalStatus) { + is CurrentSyncJobStatus.Succeeded -> SyncJobStatus.Succeeded() + else -> SyncJobStatus.Failed() + } + } + + override suspend fun cancelSyncNow() { + Sync.cancelOneTimeSync(context) + } + + override suspend fun startPeriodicSync() { + Sync.periodicSync( + context, + periodicSyncConfiguration = + PeriodicSyncConfiguration(repeat = RepeatInterval(interval = 15.minutes)), + ) + } + + override suspend fun cancelPeriodicSync() { + Sync.cancelPeriodicSync(context) + } +} diff --git a/ohs-player-reference-app/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/ohs-player-reference-app/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d11..00000000 --- a/ohs-player-reference-app/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/ohs-player-reference-app/src/androidMain/res/drawable/ic_launcher_background.xml b/ohs-player-reference-app/src/androidMain/res/drawable/ic_launcher_background.xml index e93e11ad..4eb5b929 100644 --- a/ohs-player-reference-app/src/androidMain/res/drawable/ic_launcher_background.xml +++ b/ohs-player-reference-app/src/androidMain/res/drawable/ic_launcher_background.xml @@ -5,166 +5,6 @@ android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/ohs-player-reference-app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml index eca70cfe..3ba4e35c 100644 --- a/ohs-player-reference-app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/ohs-player-reference-app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + + diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/ohs-player-reference-app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml index eca70cfe..3ba4e35c 100644 --- a/ohs-player-reference-app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/ohs-player-reference-app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,5 @@ - - \ No newline at end of file + + diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/ohs-player-reference-app/src/androidMain/res/mipmap-hdpi/ic_launcher.png index a571e600..5884014f 100644 Binary files a/ohs-player-reference-app/src/androidMain/res/mipmap-hdpi/ic_launcher.png and b/ohs-player-reference-app/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png b/ohs-player-reference-app/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..a3f3e8d5 Binary files /dev/null and b/ohs-player-reference-app/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/ohs-player-reference-app/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png index 61da551c..8d57df0e 100644 Binary files a/ohs-player-reference-app/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png and b/ohs-player-reference-app/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/ohs-player-reference-app/src/androidMain/res/mipmap-mdpi/ic_launcher.png index c41dd285..30e9cebc 100644 Binary files a/ohs-player-reference-app/src/androidMain/res/mipmap-mdpi/ic_launcher.png and b/ohs-player-reference-app/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png b/ohs-player-reference-app/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..33e3bc66 Binary files /dev/null and b/ohs-player-reference-app/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/ohs-player-reference-app/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png index db5080a7..cbe2fa5e 100644 Binary files a/ohs-player-reference-app/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png and b/ohs-player-reference-app/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/ohs-player-reference-app/src/androidMain/res/mipmap-xhdpi/ic_launcher.png index 6dba46da..baf6924c 100644 Binary files a/ohs-player-reference-app/src/androidMain/res/mipmap-xhdpi/ic_launcher.png and b/ohs-player-reference-app/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png b/ohs-player-reference-app/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..cadeb098 Binary files /dev/null and b/ohs-player-reference-app/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/ohs-player-reference-app/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png index da31a871..03aa9da9 100644 Binary files a/ohs-player-reference-app/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png and b/ohs-player-reference-app/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/ohs-player-reference-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png index 15ac6817..b266ed8a 100644 Binary files a/ohs-player-reference-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png and b/ohs-player-reference-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png b/ohs-player-reference-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..8e5a46ed Binary files /dev/null and b/ohs-player-reference-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/ohs-player-reference-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png index b216f2d3..994dea58 100644 Binary files a/ohs-player-reference-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png and b/ohs-player-reference-app/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/ohs-player-reference-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png index f25a4197..4529a854 100644 Binary files a/ohs-player-reference-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png and b/ohs-player-reference-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/ohs-player-reference-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..b661eb18 Binary files /dev/null and b/ohs-player-reference-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/ohs-player-reference-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png index e96783cc..592aa840 100644 Binary files a/ohs-player-reference-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png and b/ohs-player-reference-app/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/ohs-player-reference-app/src/androidMain/res/values/strings.xml b/ohs-player-reference-app/src/androidMain/res/values/strings.xml index d789456c..d18a0426 100644 --- a/ohs-player-reference-app/src/androidMain/res/values/strings.xml +++ b/ohs-player-reference-app/src/androidMain/res/values/strings.xml @@ -1,3 +1,3 @@ - OHS Player + Player Reference \ No newline at end of file diff --git a/ohs-player-reference-app/src/commonMain/composeResources/drawable/app_logo.png b/ohs-player-reference-app/src/commonMain/composeResources/drawable/app_logo.png new file mode 100644 index 00000000..448f2915 Binary files /dev/null and b/ohs-player-reference-app/src/commonMain/composeResources/drawable/app_logo.png differ diff --git a/ohs-player-reference-app/src/commonMain/composeResources/values/strings.xml b/ohs-player-reference-app/src/commonMain/composeResources/values/strings.xml index a8868bea..906ae8ee 100644 --- a/ohs-player-reference-app/src/commonMain/composeResources/values/strings.xml +++ b/ohs-player-reference-app/src/commonMain/composeResources/values/strings.xml @@ -6,4 +6,81 @@ Guardian Other relative Non-relative + + + Player Reference + Sign in + We'll hand you over to your organization's sign-in page, then bring you straight back. + Continue to sign in + Sign-in failed + Dismiss + + + Households + Registers + Signed in + Last synced: %1$s + Sync now + Cancel sync + Sync in progress + Sign out + Open navigation menu + Sync failed. Please try again. + Sync cancelled. + Select a household to view details + + + Setting up your data… + This may take a moment the first time. + Couldn't sync your data + Sync failed. Please check your connection and try again. + Retry + Continue without syncing + + + Add household + No households + + + Household + Back + Add members + Add household members + + + Patients + No patients + + + Patient + Back + Add clinical data + Patient not found + + + Questionnaire + Close + Back + Retry + + + Age %1$s + MRN %1$s + Unknown + + Collapse + Expand + + Household + Unknown Household + Head: %1$s + %1$s member + %1$s members + + Unknown condition + Since %1$s + Unknown medication + Unknown substance + Unknown vaccine + Given %1$s \ No newline at end of file diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/App.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/App.kt index cd57fc18..1f8c2b5a 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/App.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/App.kt @@ -15,9 +15,17 @@ */ package dev.ohs.player.reference.app +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -25,13 +33,21 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.savedstate.read import dev.ohs.player.library.registry.LocalViewRegistry -import dev.ohs.player.reference.app.feature.group.list.GroupListScreen +import dev.ohs.player.reference.app.auth.AuthState +import dev.ohs.player.reference.app.auth.AuthViewModel +import dev.ohs.player.reference.app.auth.rememberAuthorizationLauncher import dev.ohs.player.reference.app.feature.group.profile.GroupProfileScreen +import dev.ohs.player.reference.app.feature.home.HomeScreen +import dev.ohs.player.reference.app.feature.login.LoginScreen import dev.ohs.player.reference.app.feature.patient.profile.PatientProfileScreen import dev.ohs.player.reference.app.feature.questionnaire.QuestionnaireHostScreen import dev.ohs.player.reference.app.feature.questionnaire.QuestionnaireIds +import dev.ohs.player.reference.app.feature.sync.InitialSyncGateState +import dev.ohs.player.reference.app.feature.sync.InitialSyncScreen +import dev.ohs.player.reference.app.feature.sync.InitialSyncViewModel import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid +import org.koin.compose.viewmodel.koinViewModel @Composable fun App() { @@ -39,89 +55,152 @@ fun App() { CompositionLocalProvider(LocalViewRegistry provides registry) { OhsPlayerTheme { - val navController = rememberNavController() - NavHost(navController = navController, startDestination = "groupList") { + val authViewModel: AuthViewModel = koinViewModel() + val launcher = rememberAuthorizationLauncher() + val authState by authViewModel.state.collectAsStateWithLifecycle() + val signingIn by authViewModel.signingIn.collectAsStateWithLifecycle() + val authError by authViewModel.error.collectAsStateWithLifecycle() - // Screen 1: Household list - composable("groupList") { - GroupListScreen( - onGroupClick = { id -> navController.navigate("groupProfile/$id") }, - onDataCaptureClick = { - navController.navigate( - questionnaireHostRoute(questionnaireId = QuestionnaireIds.HOUSEHOLD_REGISTRATION) - ) - }, - ) - } + LaunchedEffect(launcher) { authViewModel.bootstrap(launcher) } - composable( - route = "questionnaireHost/{questionnaireId}?patientId={patientId}&groupId={groupId}", - arguments = - listOf( - navArgument("questionnaireId") { type = NavType.StringType }, - navArgument("patientId") { - type = NavType.StringType - nullable = true - defaultValue = null - }, - navArgument("groupId") { - type = NavType.StringType - nullable = true - defaultValue = null - }, - ), - ) { back -> - val questionnaireId = - back.arguments?.read { getStringOrNull("questionnaireId") }.orEmpty() - val patientId = back.arguments?.read { getStringOrNull("patientId") } - val groupId = back.arguments?.read { getStringOrNull("groupId") } - QuestionnaireHostScreen( - questionnaireId = questionnaireId, - patientId = patientId, - groupId = groupId, - onBack = { navController.popBackStack() }, + when (authState) { + is AuthState.Loading -> + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + is AuthState.Unauthenticated -> + LoginScreen( + signingIn = signingIn, + error = authError, + onSignIn = { authViewModel.login(launcher) }, + onErrorDismiss = { authViewModel.clearError() }, ) - } + is AuthState.Authenticated -> { + val session = (authState as AuthState.Authenticated).session + val userName = + session.user.fullName.ifBlank { session.user.username }.ifBlank { session.user.email } + val initialSyncViewModel: InitialSyncViewModel = koinViewModel() + val gateState by initialSyncViewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { initialSyncViewModel.start() } - // Screen 2: Household profile (head + members) - composable( - route = "groupProfile/{groupId}", - arguments = listOf(navArgument("groupId") { type = NavType.StringType }), - ) { back -> - val groupId = back.arguments?.read { getStringOrNull("groupId") }.orEmpty() - GroupProfileScreen( - groupId = groupId, - onBack = { navController.popBackStack() }, - onMemberClick = { id -> navController.navigate("patientProfile/$id") }, - onAddMembers = { - navController.navigate( - questionnaireHostRoute( - questionnaireId = QuestionnaireIds.HOUSEHOLD_MEMBERS, - groupId = groupId, - ) + when (gateState) { + InitialSyncGateState.Checking, + InitialSyncGateState.Syncing, + is InitialSyncGateState.Failed -> + InitialSyncScreen( + state = gateState, + onRetry = { initialSyncViewModel.retry() }, + onContinueAnyway = { initialSyncViewModel.continueAnyway() }, ) - }, - ) - } + InitialSyncGateState.Passed -> { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = "home") { - // Screen 3: Patient IPS summary - composable( - route = "patientProfile/{patientId}", - arguments = listOf(navArgument("patientId") { type = NavType.StringType }), - ) { back -> - val patientId = back.arguments?.read { getStringOrNull("patientId") }.orEmpty() - PatientProfileScreen( - patientId = patientId, - onBack = { navController.popBackStack() }, - onAddClinicalData = { - navController.navigate( - questionnaireHostRoute( - questionnaireId = QuestionnaireIds.PATIENT_CLINICAL_DATA, - patientId = patientId, - ) - ) - }, - ) + // Screen 1: Home (adaptive navigation drawer shell around the household list) + composable("home") { + HomeScreen( + userName = userName, + onGroupClick = { id -> navController.navigate("groupProfile/$id") }, + onDataCaptureClick = { + navController.navigate( + questionnaireHostRoute( + questionnaireId = QuestionnaireIds.HOUSEHOLD_REGISTRATION + ) + ) + }, + onAddMembers = { groupId -> + navController.navigate( + questionnaireHostRoute( + questionnaireId = QuestionnaireIds.HOUSEHOLD_MEMBERS, + groupId = groupId, + ) + ) + }, + onAddClinicalData = { patientId -> + navController.navigate( + questionnaireHostRoute( + questionnaireId = QuestionnaireIds.PATIENT_CLINICAL_DATA, + patientId = patientId, + ) + ) + }, + onSignOut = { authViewModel.logout() }, + ) + } + + composable( + route = + "questionnaireHost/{questionnaireId}?patientId={patientId}&groupId={groupId}", + arguments = + listOf( + navArgument("questionnaireId") { type = NavType.StringType }, + navArgument("patientId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument("groupId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { back -> + val questionnaireId = + back.arguments?.read { getStringOrNull("questionnaireId") }.orEmpty() + val patientId = back.arguments?.read { getStringOrNull("patientId") } + val groupId = back.arguments?.read { getStringOrNull("groupId") } + QuestionnaireHostScreen( + questionnaireId = questionnaireId, + patientId = patientId, + groupId = groupId, + onBack = { navController.popBackStack() }, + ) + } + + // Screen 2: Household profile (head + members) + composable( + route = "groupProfile/{groupId}", + arguments = listOf(navArgument("groupId") { type = NavType.StringType }), + ) { back -> + val groupId = back.arguments?.read { getStringOrNull("groupId") }.orEmpty() + GroupProfileScreen( + groupId = groupId, + onBack = { navController.popBackStack() }, + onMemberClick = { id -> navController.navigate("patientProfile/$id") }, + onAddMembers = { + navController.navigate( + questionnaireHostRoute( + questionnaireId = QuestionnaireIds.HOUSEHOLD_MEMBERS, + groupId = groupId, + ) + ) + }, + ) + } + + // Screen 3: Patient IPS summary + composable( + route = "patientProfile/{patientId}", + arguments = listOf(navArgument("patientId") { type = NavType.StringType }), + ) { back -> + val patientId = back.arguments?.read { getStringOrNull("patientId") }.orEmpty() + PatientProfileScreen( + patientId = patientId, + onBack = { navController.popBackStack() }, + onAddClinicalData = { + navController.navigate( + questionnaireHostRoute( + questionnaireId = QuestionnaireIds.PATIENT_CLINICAL_DATA, + patientId = patientId, + ) + ) + }, + ) + } + } + } + } } } } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/OhsPlayerTheme.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/OhsPlayerTheme.kt index 8c3f5ae6..30e8c086 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/OhsPlayerTheme.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/OhsPlayerTheme.kt @@ -22,32 +22,32 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -private val OhsPrimary = Color(0xFF0B57D0) +private val OhsPrimary = Color(0xFF5A44C4) private val OhsOnPrimary = Color.White -private val OhsPrimaryContainer = Color(0xFFD3E3FD) -private val OhsOnPrimaryContainer = Color(0xFF041E49) +private val OhsPrimaryContainer = Color(0xFFE5DEFF) +private val OhsOnPrimaryContainer = Color(0xFF190261) -private val OhsSecondary = Color(0xFF00639B) +private val OhsSecondary = Color(0xFF2D86C4) private val OhsOnSecondary = Color.White -private val OhsSecondaryContainer = Color(0xFFC2E7FF) -private val OhsOnSecondaryContainer = Color(0xFF001E31) +private val OhsSecondaryContainer = Color(0xFFD1E4FF) +private val OhsOnSecondaryContainer = Color(0xFF001C38) -private val OhsTertiary = Color(0xFF146C2E) +private val OhsTertiary = Color(0xFF7158C9) private val OhsOnTertiary = Color.White -private val OhsTertiaryContainer = Color(0xFFC4EED0) -private val OhsOnTertiaryContainer = Color(0xFF072711) +private val OhsTertiaryContainer = Color(0xFFEADDFF) +private val OhsOnTertiaryContainer = Color(0xFF230A5E) private val OhsError = Color(0xFFB3261E) private val OhsOnError = Color.White private val OhsErrorContainer = Color(0xFFF9DEDC) private val OhsOnErrorContainer = Color(0xFF601410) -private val OhsBackground = Color(0xFFFFFFFF) -private val OhsSurface = Color(0xFFFFFFFF) -private val OhsOnSurface = Color(0xFF1F1F1F) -private val OhsSurfaceVariant = Color(0xFFE1E3F8) -private val OhsOnSurfaceVariant = Color(0xFF45464F) -private val OhsOutline = Color(0xFF757680) +private val OhsBackground = Color(0xFFFFFBFF) +private val OhsSurface = Color(0xFFFFFBFF) +private val OhsOnSurface = Color(0xFF1C1B1F) +private val OhsSurfaceVariant = Color(0xFFE5E0EC) +private val OhsOnSurfaceVariant = Color(0xFF48454E) +private val OhsOutline = Color(0xFF79767F) private val OhsLightColorScheme = lightColorScheme( @@ -78,29 +78,29 @@ private val OhsLightColorScheme = private val OhsDarkColorScheme = darkColorScheme( - primary = Color(0xFFA8C7FA), - onPrimary = Color(0xFF0842A0), - primaryContainer = Color(0xFF0842A0), - onPrimaryContainer = Color(0xFFD3E3FD), - secondary = Color(0xFF7FCFFF), - onSecondary = Color(0xFF004A77), - secondaryContainer = Color(0xFF004A77), - onSecondaryContainer = Color(0xFFC2E7FF), - tertiary = Color(0xFF91D5A3), - onTertiary = Color(0xFF0F5223), - tertiaryContainer = Color(0xFF0F5223), - onTertiaryContainer = Color(0xFFC4EED0), + primary = Color(0xFFC9BFFF), + onPrimary = Color(0xFF2A1478), + primaryContainer = Color(0xFF422F91), + onPrimaryContainer = Color(0xFFE5DEFF), + secondary = Color(0xFF9FCAFF), + onSecondary = Color(0xFF00325B), + secondaryContainer = Color(0xFF004A80), + onSecondaryContainer = Color(0xFFD1E4FF), + tertiary = Color(0xFFD3BBFF), + onTertiary = Color(0xFF3A1D8F), + tertiaryContainer = Color(0xFF5840B0), + onTertiaryContainer = Color(0xFFEADDFF), error = Color(0xFFF2B8B5), onError = Color(0xFF601410), errorContainer = Color(0xFF8C1D18), onErrorContainer = Color(0xFFF9DEDC), - background = Color(0xFF1F1F1F), - onBackground = Color(0xFFE3E3E3), - surface = Color(0xFF1F1F1F), - onSurface = Color(0xFFE3E3E3), - surfaceVariant = Color(0xFF45464F), - onSurfaceVariant = Color(0xFFC5C6D0), - outline = Color(0xFF8F9099), + background = Color(0xFF1C1B1F), + onBackground = Color(0xFFE6E1E9), + surface = Color(0xFF1C1B1F), + onSurface = Color(0xFFE6E1E9), + surfaceVariant = Color(0xFF48454E), + onSurfaceVariant = Color(0xFFC9C5D0), + outline = Color(0xFF938F99), ) @Composable diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/AuthModels.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/AuthModels.kt new file mode 100644 index 00000000..ff651187 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/AuthModels.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Subset of the OIDC discovery document ({issuer}/.well-known/openid-configuration) we use. Lets + * the app stay provider-agnostic — endpoints are read from here rather than hardcoded per provider. + */ +@Serializable +internal data class OpenIdConfiguration( + @SerialName("authorization_endpoint") val authorizationEndpoint: String, + @SerialName("token_endpoint") val tokenEndpoint: String, + @SerialName("userinfo_endpoint") val userInfoEndpoint: String = "", + @SerialName("end_session_endpoint") val endSessionEndpoint: String = "", +) + +/** Raw token endpoint response from the OIDC provider. */ +@Serializable +internal data class TokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String? = null, + @SerialName("id_token") val idToken: String? = null, + @SerialName("token_type") val tokenType: String = "Bearer", + @SerialName("expires_in") val expiresIn: Long = 0, + @SerialName("refresh_expires_in") val refreshExpiresIn: Long = 0, + @SerialName("scope") val scope: String? = null, +) + +/** Subset of the provider's `userinfo` response we care about. */ +@Serializable +data class UserInfo( + @SerialName("sub") val subject: String = "", + @SerialName("preferred_username") val username: String = "", + @SerialName("name") val fullName: String = "", + @SerialName("email") val email: String = "", +) + +/** + * The persisted session — tokens plus user identity. Stored encrypted in KSafe. + * [obtainedAtEpochSeconds] + [expiresInSeconds] let us know when to refresh. + */ +@Serializable +data class Session( + val accessToken: String, + val refreshToken: String?, + val idToken: String?, + val expiresInSeconds: Long, + val obtainedAtEpochSeconds: Long, + val user: UserInfo, +) { + /** True when the access token has expired (with a small safety skew). */ + fun isAccessTokenExpired(nowEpochSeconds: Long, skewSeconds: Long = 30): Boolean = + nowEpochSeconds >= (obtainedAtEpochSeconds + expiresInSeconds - skewSeconds) +} + +/** + * Short-lived state we must remember across the authorization round-trip (especially on web, where + * the page reloads). Stored encrypted in KSafe. + */ +@Serializable internal data class PendingAuth(val codeVerifier: String, val state: String) + +/** Result of an [AuthorizationLauncher.authorize] call. */ +sealed interface AuthResult { + /** Web only: the browser is navigating away; the result arrives on reload. */ + data object Redirecting : AuthResult + + /** The full callback URL (contains `code` + `state`, or `error`). */ + data class Success(val callbackUrl: String) : AuthResult + + /** The user dismissed the browser/auth sheet. */ + data object Canceled : AuthResult + + data class Failure(val message: String) : AuthResult +} + +/** High-level outcome of a login attempt, surfaced to the UI. */ +sealed interface LoginOutcome { + data class Authenticated(val session: Session) : LoginOutcome + + /** Web: page is unloading to the provider; nothing more to do this load. */ + data object Redirecting : LoginOutcome + + data object Canceled : LoginOutcome + + data class Error(val message: String) : LoginOutcome +} + +/** + * Server-side liveness of a session. [Unknown] means the provider could not be reached (offline); + * callers MUST treat it as "keep the session" so connectivity loss never logs a field user out. + */ +enum class SessionStatus { + Active, + Revoked, + Unknown, +} + +/** Auth state the UI renders against. */ +sealed interface AuthState { + data object Loading : AuthState + + data object Unauthenticated : AuthState + + data class Authenticated(val session: Session) : AuthState +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/AuthService.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/AuthService.kt new file mode 100644 index 00000000..dcf95a34 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/AuthService.kt @@ -0,0 +1,182 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** + * Drives the Authorization-Code-with-PKCE flow against the configured OIDC provider and keeps the + * [SessionStore] up to date. UI-agnostic; [AuthViewModel] adapts it to Compose state. + * + * Unlike opensrp's version, this constructor has NO default parameter values — Koin always supplies + * [config], [repository], and [api] (see `authModule` in `AppModule.kt`), so self-constructing + * defaults would be dead code here. + */ +internal class AuthService( + private val config: OAuthConfig, + private val repository: SessionStore, + private val api: OidcAuthApi, +) { + + /** Starts an interactive login. */ + suspend fun login(launcher: AuthorizationLauncherApi): LoginOutcome { + val pkce = Pkce.generate() + val state = Pkce.randomState() + repository.savePending(PendingAuth(codeVerifier = pkce.verifier, state = state)) + + val authUrl = + runCatching { buildAuthorizationUrl(launcher.redirectUri, pkce, state) } + .getOrElse { + return LoginOutcome.Error(it.message ?: "Could not reach the sign-in provider") + } + return when (val result = launcher.authorize(authUrl)) { + is AuthResult.Redirecting -> LoginOutcome.Redirecting + is AuthResult.Success -> completeLogin(result.callbackUrl, launcher.redirectUri) + is AuthResult.Canceled -> LoginOutcome.Canceled + is AuthResult.Failure -> LoginOutcome.Error(result.message) + } + } + + /** + * Web: on app start, finishes a login if this page load is a redirect back from the provider. + * Returns null when there is nothing to complete. + */ + suspend fun completeRedirectLoginIfPresent(launcher: AuthorizationLauncherApi): LoginOutcome? { + val callbackUrl = launcher.consumeRedirectCallback() ?: return null + return completeLogin(callbackUrl, launcher.redirectUri) + } + + /** Exchanges the callback's `code` for tokens and persists the session. */ + private suspend fun completeLogin(callbackUrl: String, redirectUri: String): LoginOutcome { + val params = Url(callbackUrl).parameters + params["error"]?.let { error -> + return LoginOutcome.Error(params["error_description"] ?: error) + } + val code = params["code"] ?: return LoginOutcome.Error("Missing authorization code") + val returnedState = params["state"] + + val pending = repository.takePending() + if (pending == null || pending.state != returnedState) { + return LoginOutcome.Error("Invalid state — possible CSRF, please try again") + } + + return runCatching { + val tokens = api.exchangeCode(code, pending.codeVerifier, redirectUri) + val session = tokens.toSession(api.fetchUserInfo(tokens.accessToken)) + repository.save(session) + LoginOutcome.Authenticated(session) + } + .getOrElse { LoginOutcome.Error(it.message ?: "Sign-in failed") } + } + + /** + * Returns a usable session at startup, refreshing an expired access token when possible. + * + * Offline-safe: the session is cleared (→ login) ONLY on a definitive provider rejection + * ([AuthException]). A transient/offline failure keeps the stored session. Server-side revocation + * while the access token is still locally valid is caught separately (and non-blockingly) by + * [revalidateSession]. + */ + suspend fun ensureFreshSession(): Session? { + val session = repository.load() ?: return null + if (!session.isAccessTokenExpired(now())) return session + + val refreshed = tryRefresh(session) + return refreshed ?: repository.session.value + } + + /** + * Confirms a locally-valid session is still alive server-side via the userinfo probe. Clears it + * only on a definitive revoke; offline is a no-op. Meant to run in the background after startup + * so it never blocks an offline launch. + */ + suspend fun revalidateSession(): Boolean { + val session = repository.session.value ?: repository.load() ?: return false + return when (api.sessionStatus(session.accessToken)) { + SessionStatus.Revoked -> { + repository.clear() + false + } + SessionStatus.Active, + SessionStatus.Unknown -> true + } + } + + /** Refresh hook for [FhirBearerAuthenticator]'s eventual retry path, and internal use. */ + internal suspend fun refreshTokensForRequest(): Session? { + val session = repository.session.value ?: repository.load() ?: return null + return tryRefresh(session) + } + + private suspend fun tryRefresh(session: Session): Session? { + val refreshToken = + session.refreshToken + ?: run { + repository.clear() + return null + } + return try { + val tokens = api.refresh(refreshToken) + tokens.toSession(session.user).also { repository.save(it) } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (rejected: AuthException) { + repository.clear() + null + } catch (offline: Throwable) { + null + } + } + + suspend fun logout() { + repository.session.value?.refreshToken?.let { api.logout(it) } + repository.clear() + } + + private suspend fun buildAuthorizationUrl( + redirectUri: String, + pkce: PkcePair, + state: String, + ): String = + URLBuilder(api.endpoints().authorizationEndpoint) + .apply { + parameters.append("client_id", config.clientId) + parameters.append("response_type", "code") + parameters.append("scope", config.scopes) + parameters.append("redirect_uri", redirectUri) + parameters.append("code_challenge", pkce.challenge) + parameters.append("code_challenge_method", pkce.method) + parameters.append("state", state) + } + .buildString() + + private fun TokenResponse.toSession(user: UserInfo): Session = + Session( + accessToken = accessToken, + refreshToken = refreshToken, + idToken = idToken, + expiresInSeconds = expiresIn, + obtainedAtEpochSeconds = now(), + user = user, + ) + + @OptIn(ExperimentalTime::class) + private fun now(): Long = Clock.System.now().toEpochMilliseconds() / 1000 +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/AuthViewModel.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/AuthViewModel.kt new file mode 100644 index 00000000..d152e2d5 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/AuthViewModel.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.ohs.player.reference.app.data.sync.SyncManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * Adapts [AuthService] to Compose. Deviates from opensrp: [service] is a constructor param + * (Koin-injected via `viewModel { AuthViewModel(get()) }`) instead of self-constructed. + * [AuthorizationLauncher] is platform UI, so it's passed in from the composable rather than + * constructed here. + */ +internal class AuthViewModel( + private val service: AuthService, + private val syncManager: SyncManager, +) : ViewModel() { + + private val _state = MutableStateFlow(AuthState.Loading) + val state: StateFlow = _state.asStateFlow() + + private val _signingIn = MutableStateFlow(false) + val signingIn: StateFlow = _signingIn.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private var bootstrapped = false + + /** Restores any saved session and completes a web redirect login. Runs once. */ + fun bootstrap(launcher: AuthorizationLauncher) { + if (bootstrapped) return + bootstrapped = true + viewModelScope.launch { runBootstrap(launcher) } + } + + /** + * Test seam: same bootstrap logic, but against [AuthorizationLauncherApi] so tests can fake it. + */ + internal suspend fun bootstrapForTest(launcher: AuthorizationLauncherApi) = runBootstrap(launcher) + + private suspend fun runBootstrap(launcher: AuthorizationLauncherApi) { + when (val redirect = service.completeRedirectLoginIfPresent(launcher)) { + is LoginOutcome.Authenticated -> { + _state.value = AuthState.Authenticated(redirect.session) + return + } + is LoginOutcome.Error -> _error.value = redirect.message + else -> Unit + } + val session = service.ensureFreshSession() + if (session != null) { + _state.value = AuthState.Authenticated(session) + revalidateInBackground() + } else { + _state.value = AuthState.Unauthenticated + } + } + + private fun revalidateInBackground() { + viewModelScope.launch { if (!service.revalidateSession()) signOut() } + } + + fun login(launcher: AuthorizationLauncher) { + viewModelScope.launch { runLogin(launcher) } + } + + /** Test seam: same login logic, but against [AuthorizationLauncherApi] so tests can fake it. */ + internal suspend fun loginForTest(launcher: AuthorizationLauncherApi) = runLogin(launcher) + + private suspend fun runLogin(launcher: AuthorizationLauncherApi) { + _signingIn.value = true + _error.value = null + when (val outcome = service.login(launcher)) { + is LoginOutcome.Authenticated -> _state.value = AuthState.Authenticated(outcome.session) + is LoginOutcome.Redirecting -> Unit // web: page is unloading to the provider + is LoginOutcome.Canceled -> Unit + is LoginOutcome.Error -> _error.value = outcome.message + } + _signingIn.value = false + } + + fun clearError() { + _error.value = null + } + + fun logout() { + viewModelScope.launch { runLogout() } + } + + /** Test seam: same logout logic, runnable directly without `viewModelScope.launch`. */ + internal suspend fun logoutForTest() = runLogout() + + private suspend fun runLogout() { + service.logout() + signOut() + } + + /** + * Ends the local session: stops any in-flight and recurring sync before dropping to the login + * screen, so neither can keep firing with a stale/absent token after a logout or a server-side + * revoke. Local FHIR data is intentionally left on disk — logout clears the session, not the + * data. + */ + private suspend fun signOut() { + syncManager.cancelSyncNow() + syncManager.cancelPeriodicSync() + _state.value = AuthState.Unauthenticated + } +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/AuthorizationLauncher.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/AuthorizationLauncher.kt new file mode 100644 index 00000000..f4b98f68 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/AuthorizationLauncher.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import androidx.compose.runtime.Composable + +/** + * Opens the system browser to the identity provider's authorization page and returns the redirect + * callback. Each platform uses its idiomatic mechanism: + * - Android: Chrome Custom Tabs + a custom-scheme deep link. + * - iOS: ASWebAuthenticationSession (returns the callback URL directly). + * - Desktop: a localhost loopback HTTP server + the system browser. + * - Web: a full-page redirect; the callback is read on the next page load via + * [consumeRedirectCallback]. + * + * [redirectUri] is the platform-correct value and must be registered at the identity provider. It + * is used in BOTH the authorize request and the token exchange, so the launcher owns it. + */ +/** The subset of [AuthorizationLauncher] that [AuthService] depends on — lets tests fake it. */ +internal interface AuthorizationLauncherApi { + val redirectUri: String + + suspend fun authorize(authUrl: String): AuthResult + + /** + * Web only: if this page load is a redirect back from the provider, returns the callback URL (and + * clears it from the address bar). Returns null otherwise and on every non-web platform. + */ + fun consumeRedirectCallback(): String? +} + +expect class AuthorizationLauncher : AuthorizationLauncherApi { + override val redirectUri: String + + /** + * Launches the auth flow. Suspends until the callback is received ([AuthResult.Success]) on + * platforms that can await it; returns [AuthResult.Redirecting] on web, where the page unloads. + */ + override suspend fun authorize(authUrl: String): AuthResult + + override fun consumeRedirectCallback(): String? +} + +/** Obtains a launcher, wiring in any platform context (e.g. the Android Activity). */ +@Composable expect fun rememberAuthorizationLauncher(): AuthorizationLauncher diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/FhirBearerAuthenticator.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/FhirBearerAuthenticator.kt new file mode 100644 index 00000000..3ce74e4a --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/FhirBearerAuthenticator.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import dev.ohs.fhir.engine.sync.HttpAuthenticationMethod +import dev.ohs.fhir.engine.sync.HttpAuthenticator + +/** + * Feeds the signed-in session's access token to `ohs-fhir-engine`'s HTTP layer. Reads + * [SessionRepository] directly (not through Koin) — this is constructed at each platform's + * `FhirEngineProvider.init()` call site, which runs before `initKoin()`. + * + * [getAuthenticationMethod] is called by the engine on every request, so this always reads the + * *current* token — there's no ordering dependency on when a session becomes available. + */ +internal object FhirBearerAuthenticator : HttpAuthenticator { + override fun getAuthenticationMethod(): HttpAuthenticationMethod = + HttpAuthenticationMethod.Bearer(SessionRepository.session.value?.accessToken.orEmpty()) +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/HeadlessSessionRefresh.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/HeadlessSessionRefresh.kt new file mode 100644 index 00000000..702cc849 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/HeadlessSessionRefresh.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +/** + * Loads the persisted session and refreshes an expired access token, so a headless/background sync + * (Android WorkManager, iOS `BGProcessingTask`) hands [FhirBearerAuthenticator] a valid Bearer + * token even when the UI bootstrap that normally hydrates [SessionRepository] never ran. + * + * Builds its own [AuthService] over the baked [OAuthConfig.Default] and the singleton + * [SessionRepository] rather than resolving one from Koin: iOS background launches don't call + * `initKoin`, so nothing is registered there. Offline-safe — a transient/offline failure keeps the + * stored session (see [AuthService.ensureFreshSession]); it only returns null when there is no + * usable session at all, in which case the sync will simply 401 and be retried later. + */ +internal suspend fun ensureFreshSessionForSync() { + headlessAuthService.ensureFreshSession() +} + +/** + * One instance per process (like the foreground Koin singleton) so repeated syncs don't leak an + * [OidcAuthApi]'s underlying HTTP client. + */ +private val headlessAuthService: AuthService by lazy { + AuthService(OAuthConfig.Default, SessionRepository, OidcAuthApi(OAuthConfig.Default)) +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/OAuthConfig.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/OAuthConfig.kt new file mode 100644 index 00000000..12386b43 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/OAuthConfig.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +/** + * Provider-agnostic OAuth2/OIDC settings. + * + * Only the [issuer] (plus client id and scopes) is configured; the concrete endpoints are resolved + * at runtime via OIDC discovery ([OidcEndpoints]) from `{issuer}/.well-known/openid-configuration`. + * The redirect URI is intentionally NOT part of this object — it is platform specific and provided + * by the [AuthorizationLauncher]. [Default] is the single instance, baked in from + * `local.properties` via [GeneratedAuthConfig]. + */ +data class OAuthConfig( + /** + * OIDC issuer, e.g. a Keycloak realm `https://host/realms/ohs-player` or + * `https://x.zitadel.cloud`. + */ + val issuer: String, + val clientId: String, + /** Space-separated OAuth scopes, e.g. "openid profile email offline_access". */ + val scopes: String, +) { + /** Standard OIDC discovery document URL for this issuer. */ + val discoveryUrl: String + get() = "${issuer.trimEnd('/')}/.well-known/openid-configuration" + + companion object { + val Default: OAuthConfig = + OAuthConfig( + issuer = GeneratedAuthConfig.ISSUER, + clientId = GeneratedAuthConfig.CLIENT_ID, + scopes = GeneratedAuthConfig.SCOPES, + ) + } +} + +/** OAuth/OIDC endpoints resolved from the provider's discovery document. */ +data class OidcEndpoints( + val authorizationEndpoint: String, + val tokenEndpoint: String, + val userInfoEndpoint: String, + /** May be blank: not every provider advertises an end-session endpoint. */ + val endSessionEndpoint: String, +) diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/OidcAuthApi.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/OidcAuthApi.kt new file mode 100644 index 00000000..8dae6397 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/OidcAuthApi.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.forms.submitForm +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.parameters +import io.ktor.serialization.kotlinx.json.json +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Provider-agnostic OpenID Connect client (discovery, token exchange, refresh, userinfo, logout). + * Endpoints are resolved from the provider's discovery document rather than hardcoded, so any + * standards-compliant OIDC provider works by changing only [OAuthConfig.issuer]. The Ktor engine is + * selected automatically from each platform's classpath (OkHttp / CIO / Darwin / JS). + */ +internal class OidcAuthApi( + private val config: OAuthConfig, + private val httpClient: HttpClient = defaultHttpClient(), +) { + + private val endpointsMutex = Mutex() + private var cachedEndpoints: OidcEndpoints? = null + + /** Resolves and caches the provider endpoints via OIDC discovery. */ + suspend fun endpoints(): OidcEndpoints = + cachedEndpoints + ?: endpointsMutex.withLock { cachedEndpoints ?: discover().also { cachedEndpoints = it } } + + private suspend fun discover(): OidcEndpoints { + val response = httpClient.get(config.discoveryUrl) + check(response.status.isSuccess()) { + "Could not reach the sign-in provider (HTTP ${response.status.value} from ${config.discoveryUrl})" + } + val doc: OpenIdConfiguration = response.body() + return OidcEndpoints( + authorizationEndpoint = doc.authorizationEndpoint, + tokenEndpoint = doc.tokenEndpoint, + userInfoEndpoint = doc.userInfoEndpoint, + endSessionEndpoint = doc.endSessionEndpoint, + ) + } + + /** Exchanges an authorization `code` (+ PKCE verifier) for tokens. */ + suspend fun exchangeCode(code: String, codeVerifier: String, redirectUri: String): TokenResponse = + httpClient + .submitForm( + url = endpoints().tokenEndpoint, + formParameters = + parameters { + append("grant_type", "authorization_code") + append("client_id", config.clientId) + append("code", code) + append("redirect_uri", redirectUri) + append("code_verifier", codeVerifier) + }, + ) + .requireTokens() + + /** Refreshes an expired access token using the refresh token. */ + suspend fun refresh(refreshToken: String): TokenResponse = + httpClient + .submitForm( + url = endpoints().tokenEndpoint, + formParameters = + parameters { + append("grant_type", "refresh_token") + append("client_id", config.clientId) + append("refresh_token", refreshToken) + }, + ) + .requireTokens() + + private suspend fun HttpResponse.requireTokens(): TokenResponse { + if (!status.isSuccess()) { + val raw = runCatching { bodyAsText() }.getOrNull().orEmpty() + throw AuthException(oauthErrorMessage(raw, status.value)) + } + return body() + } + + suspend fun fetchUserInfo(accessToken: String): UserInfo = + httpClient + .get(endpoints().userInfoEndpoint) { + header(HttpHeaders.Authorization, "Bearer $accessToken") + } + .body() + + /** + * Liveness check via the userinfo endpoint. Fails OPEN: unreachable provider / timeout / + * discovery failure returns [SessionStatus.Unknown] so the caller keeps the local session + * (offline must never force a logout). + */ + suspend fun sessionStatus(accessToken: String): SessionStatus = + try { + val response = + httpClient.get(endpoints().userInfoEndpoint) { + header(HttpHeaders.Authorization, "Bearer $accessToken") + } + when { + response.status.isSuccess() -> SessionStatus.Active + response.status == HttpStatusCode.Unauthorized -> SessionStatus.Revoked + else -> SessionStatus.Unknown + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (unreachable: Throwable) { + SessionStatus.Unknown + } + + /** Best-effort server-side logout; failures are ignored (local clear still wins). */ + suspend fun logout(refreshToken: String) { + runCatching { + val endSession = endpoints().endSessionEndpoint + if (endSession.isBlank()) return@runCatching + val response = + httpClient.submitForm( + url = endSession, + formParameters = + parameters { + append("client_id", config.clientId) + append("refresh_token", refreshToken) + }, + ) + if (response.status != HttpStatusCode.NoContent && !response.status.isSuccess()) { + response.bodyAsText() + } + } + } + + companion object { + fun defaultHttpClient(): HttpClient = HttpClient { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + isLenient = true + } + ) + } + install(HttpTimeout) { + requestTimeoutMillis = 15_000 + connectTimeoutMillis = 10_000 + } + } + } +} + +private fun HttpStatusCode.isSuccess(): Boolean = value in 200..299 + +/** A failed auth/token request, carrying a human-readable reason for the UI. */ +internal class AuthException(message: String) : Exception(message) + +private fun oauthErrorMessage(body: String, statusCode: Int): String { + val parsed = + runCatching { + val obj = Json.parseToJsonElement(body).jsonObject + val error = obj["error"]?.jsonPrimitive?.contentOrNull + val description = obj["error_description"]?.jsonPrimitive?.contentOrNull + listOfNotNull(error, description).joinToString(": ") + } + .getOrNull() + return parsed?.takeIf { it.isNotBlank() } ?: "Authentication request failed (HTTP $statusCode)" +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/Pkce.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/Pkce.kt new file mode 100644 index 00000000..e82784d1 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/Pkce.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import org.kotlincrypto.hash.sha2.SHA256 + +/** + * RFC 7636 (PKCE) helpers. + * + * The verifier is high-entropy random bytes, base64url-encoded. The challenge is + * `BASE64URL(SHA-256(ASCII(verifier)))` (method `S256`). SHA-256 comes from KotlinCrypto so it runs + * synchronously on every target — including Wasm/JS, where the browser's SubtleCrypto digest is + * async-only. + */ +internal data class PkcePair(val verifier: String, val challenge: String) { + val method: String = "S256" +} + +internal object Pkce { + + /** 32 random bytes → 43-char verifier, comfortably inside the 43–128 range. */ + @OptIn(ExperimentalEncodingApi::class) + fun generate(): PkcePair { + val verifier = base64Url(secureRandomBytes(32)) + val challenge = base64Url(SHA256().digest(verifier.encodeToByteArray())) + return PkcePair(verifier = verifier, challenge = challenge) + } + + /** A random, URL-safe `state` value to defend against CSRF on the callback. */ + fun randomState(): String = base64Url(secureRandomBytes(16)) + + @OptIn(ExperimentalEncodingApi::class) + private fun base64Url(bytes: ByteArray): String = + Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) +} + +/** + * Cryptographically secure random bytes. Implemented per platform with the OS CSPRNG (SecureRandom + * / SecRandomCopyBytes / Web Crypto getRandomValues). + */ +internal expect fun secureRandomBytes(size: Int): ByteArray diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/SessionRepository.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/SessionRepository.kt new file mode 100644 index 00000000..14db5a4f --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/auth/SessionRepository.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import eu.anifantakis.lib.ksafe.KSafe +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Storage seam for the session + pending-auth state. Lets tests substitute an in-memory fake. */ +internal interface SessionStore { + val session: StateFlow + + suspend fun load(): Session? + + suspend fun save(session: Session) + + suspend fun clear() + + suspend fun savePending(pending: PendingAuth) + + suspend fun takePending(): PendingAuth? +} + +/** + * Single source of truth for the signed-in [Session] and the transient [PendingAuth] used during + * the authorization round-trip. Everything is persisted through [KSafe], which encrypts at rest + * using each platform's hardware-backed keystore (Android Keystore, Apple Keychain, OS vaults, + * non-extractable WebCrypto keys on web). + * + * KSafe's `get` needs a default, so we use blank sentinels and treat a blank token / verifier as + * "absent". + * + * Deliberately a plain `object`, not Koin-constructed: [FhirBearerAuthenticator] must read it from + * each platform's `main()` / `Application.onCreate()`, which runs before `initKoin()`. Koin binds + * this same singleton via `single { SessionRepository }` for everything else. + */ +internal object SessionRepository : SessionStore { + + private val ksafe: KSafe by lazy { createKSafe() } + + private val _session = MutableStateFlow(null) + /** Emits the current session, or null when signed out. Hydrate via [load]. */ + override val session: StateFlow = _session.asStateFlow() + + /** Reads any persisted session into [session]. Call once at startup. */ + override suspend fun load(): Session? = readSession().also { _session.value = it } + + override suspend fun save(session: Session) { + ksafe.put(KEY_SESSION, session) + _session.value = session + } + + override suspend fun clear() { + ksafe.put(KEY_SESSION, EMPTY_SESSION) + _session.value = null + } + + override suspend fun savePending(pending: PendingAuth) = ksafe.put(KEY_PENDING, pending) + + /** Returns the pending auth (if any) and clears it — single use. */ + override suspend fun takePending(): PendingAuth? { + val pending = ksafe.get(KEY_PENDING, EMPTY_PENDING) + ksafe.put(KEY_PENDING, EMPTY_PENDING) + return pending.takeIf { it.codeVerifier.isNotBlank() } + } + + private suspend fun readSession(): Session? = + ksafe.get(KEY_SESSION, EMPTY_SESSION).takeIf { it.accessToken.isNotBlank() } + + private const val KEY_SESSION = "auth.session" + private const val KEY_PENDING = "auth.pending" + + private val EMPTY_SESSION = + Session( + accessToken = "", + refreshToken = null, + idToken = null, + expiresInSeconds = 0, + obtainedAtEpochSeconds = 0, + user = UserInfo(), + ) + private val EMPTY_PENDING = PendingAuth(codeVerifier = "", state = "") +} + +/** Creates the platform [KSafe] (Android needs the app Context; others don't). */ +internal expect fun createKSafe(): KSafe diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/DataChangeSignal.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/DataChangeSignal.kt new file mode 100644 index 00000000..c2088a21 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/DataChangeSignal.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-wide "the local FHIR data changed" tick that screens observe to re-query. Both app writes + * (questionnaire extraction) and completed syncs bump it: sync downloads write straight to the + * engine database, bypassing the repository's own write path, so without this signal a register + * would stay stale until the next app-side edit. WorkManager and `BGProcessingTask` run in the app + * process, so this singleton reaches the foreground collectors from a background sync too. + */ +object DataChangeSignal { + private val _revision = MutableStateFlow(0L) + val revision: StateFlow = _revision.asStateFlow() + + fun notifyChanged() { + _revision.value += 1 + } +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/datasource/SampleDataStore.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/datasource/SampleDataStore.kt index f7e94564..f3dd96fb 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/datasource/SampleDataStore.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/datasource/SampleDataStore.kt @@ -86,10 +86,15 @@ suspend fun patientProfileSearchResult( /** * Group list: root = Group only. Member count is derived from `Group.member.size` on the resource - * itself — no additional includes needed. + * itself — no additional includes needed. Ordered newest first: `meta.lastUpdated` is a UTC + * instant, so its ISO-8601 string sorts chronologically; groups without one fall to the end. */ suspend fun groupListSearchResults(repository: FhirRepository): List> = - repository.all("Group").filterIsInstance().map { group -> SearchResult(resource = group) } + repository + .all("Group") + .filterIsInstance() + .sortedByDescending { it.meta?.lastUpdated?.value?.toString() } + .map { group -> SearchResult(resource = group) } /** * Group profile: root = Group, member Patients in included. Each `Group.member.entity` is a FHIR diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/di/AppModule.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/di/AppModule.kt index bf47ce14..20f6cbee 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/di/AppModule.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/di/AppModule.kt @@ -15,17 +15,29 @@ */ package dev.ohs.player.reference.app.data.di +import dev.ohs.fhir.engine.FhirEngineProvider +import dev.ohs.player.reference.app.auth.AuthService +import dev.ohs.player.reference.app.auth.AuthViewModel +import dev.ohs.player.reference.app.auth.OAuthConfig +import dev.ohs.player.reference.app.auth.OidcAuthApi +import dev.ohs.player.reference.app.auth.SessionRepository +import dev.ohs.player.reference.app.auth.SessionStore import dev.ohs.player.reference.app.data.repository.FhirEngineRepository import dev.ohs.player.reference.app.data.repository.FhirRepository import dev.ohs.player.reference.app.data.repository.GroupRepository import dev.ohs.player.reference.app.data.repository.PatientRepository +import dev.ohs.player.reference.app.data.sync.DataStoreInitialSyncStore +import dev.ohs.player.reference.app.data.sync.InitialSyncStore +import dev.ohs.player.reference.app.data.sync.createSyncTimestampDataStore import dev.ohs.player.reference.app.feature.group.list.GroupListViewModel import dev.ohs.player.reference.app.feature.group.profile.GroupProfileViewModel +import dev.ohs.player.reference.app.feature.home.HomeViewModel import dev.ohs.player.reference.app.feature.patient.list.PatientListViewModel import dev.ohs.player.reference.app.feature.patient.profile.PatientProfileViewModel import dev.ohs.player.reference.app.feature.questionnaire.QuestionnaireHostViewModel import dev.ohs.player.reference.app.feature.questionnaire.QuestionnaireLaunchContext import dev.ohs.player.reference.app.feature.questionnaire.QuestionnaireService +import dev.ohs.player.reference.app.feature.sync.InitialSyncViewModel import org.koin.core.module.dsl.viewModel import org.koin.dsl.module @@ -49,6 +61,28 @@ internal val repositoryModule = module { internal val serviceModule = module { factory { QuestionnaireService(get()) } } +/** + * [SyncManager] isn't bound here — each platform's `initKoin` caller supplies its own + * implementation, constructing `AppFhirSyncTask` directly rather than through Koin (see + * `WorkManagerSyncManager` on Android, `ForegroundSyncManager` on JVM/web, and `IosSyncManager` on + * iOS). + */ +internal val syncModule = module { + single { FhirEngineProvider.getFhirDataStore() } + single { DataStoreInitialSyncStore(createSyncTimestampDataStore()) } +} + +/** + * `SessionStore`/`SessionRepository`/`AuthService` — everything downstream of the plain + * `SessionRepository` object (kept outside Koin; see its kdoc) is Koin-injected here. + */ +internal val authModule = module { + single { OAuthConfig.Default } + single { SessionRepository } + single { OidcAuthApi(get()) } + single { AuthService(get(), get(), get()) } +} + internal val viewModelModule = module { viewModel { PatientListViewModel(get()) } viewModel { (patientId: String) -> PatientProfileViewModel(patientId, get()) } @@ -57,4 +91,7 @@ internal val viewModelModule = module { viewModel { (questionnaireId: String, launchContext: QuestionnaireLaunchContext) -> QuestionnaireHostViewModel(questionnaireId, launchContext, get()) } + viewModel { HomeViewModel(get(), get()) } + viewModel { AuthViewModel(get(), get()) } + viewModel { InitialSyncViewModel(get(), get()) } } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/di/InitKoin.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/di/InitKoin.kt index 7f4aef16..ed4a89fc 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/di/InitKoin.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/di/InitKoin.kt @@ -29,6 +29,8 @@ fun initKoin(platformModule: Module) { fhirEngineRepositoryModule, repositoryModule, serviceModule, + syncModule, + authModule, viewModelModule, ) } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/repository/FhirEngineRepository.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/repository/FhirEngineRepository.kt index 60ea2820..f61f3766 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/repository/FhirEngineRepository.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/repository/FhirEngineRepository.kt @@ -15,16 +15,16 @@ */ package dev.ohs.player.reference.app.data.repository -import dev.ohs.fhir.FhirEngine -import dev.ohs.fhir.db.ResourceNotFoundException +import dev.ohs.fhir.engine.FhirEngine +import dev.ohs.fhir.engine.db.ResourceNotFoundException +import dev.ohs.fhir.engine.resourceType +import dev.ohs.fhir.engine.search.Search import dev.ohs.fhir.model.r4.Bundle import dev.ohs.fhir.model.r4.Resource import dev.ohs.fhir.model.r4.terminologies.ResourceType -import dev.ohs.fhir.resourceType -import dev.ohs.fhir.search.Search +import dev.ohs.player.reference.app.data.DataChangeSignal import dev.ohs.player.reference.app.generateId import dev.ohs.player.reference.app.util.FhirJson -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -41,20 +41,19 @@ import kotlinx.serialization.json.jsonObject class FhirEngineRepository(private val fhirEngine: FhirEngine) : FhirRepository { private val json = FhirJson.instance - private val _revision = MutableStateFlow(0L) - override val revision: StateFlow = _revision + override val revision: StateFlow = DataChangeSignal.revision override suspend fun upsert(resource: Resource) { upsertResource(resource) - _revision.value += 1 + DataChangeSignal.notifyChanged() } override suspend fun upsert(bundle: Bundle): Int { val normalized = normalizeBundleResources(bundle) if (normalized.isEmpty()) return 0 fhirEngine.withTransaction { normalized.forEach { upsertResource(it) } } - _revision.value += 1 + DataChangeSignal.notifyChanged() return normalized.size } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/sync/AppFhirSyncTask.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/sync/AppFhirSyncTask.kt new file mode 100644 index 00000000..13cffd84 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/sync/AppFhirSyncTask.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import dev.ohs.fhir.engine.FhirEngine +import dev.ohs.fhir.engine.sync.AcceptLocalConflictResolver +import dev.ohs.fhir.engine.sync.ConflictResolver +import dev.ohs.fhir.engine.sync.DownloadWorkManager +import dev.ohs.fhir.engine.sync.FhirSyncTask +import dev.ohs.fhir.engine.sync.download.ResourceParamsBasedDownloadWorkManager +import dev.ohs.fhir.engine.sync.download.ResourceSearchParams +import dev.ohs.fhir.engine.sync.upload.HttpCreateMethod +import dev.ohs.fhir.engine.sync.upload.HttpUpdateMethod +import dev.ohs.fhir.engine.sync.upload.UploadStrategy +import dev.ohs.fhir.model.r4.terminologies.ResourceType + +/** + * Resource types downloaded on every sync, with their search parameters. Empty parameters mean + * "everything of this type, since the last sync". Adding a resource type later is a new map entry. + */ +private val SYNC_RESOURCE_PARAMS: ResourceSearchParams = + mapOf(ResourceType.Patient to emptyMap(), ResourceType.Group to emptyMap()) + +const val SYNC_TIMEOUT_DURATION = 120L + +/** + * This app's [FhirSyncTask]: downloads [SYNC_RESOURCE_PARAMS], resolves conflicts in favor of the + * local change, and uploads pending local changes as a single bundle request. + */ +class AppFhirSyncTask(private val fhirEngine: FhirEngine) : FhirSyncTask { + private val timestampContext = DataStoreTimestampContext(createSyncTimestampDataStore()) + + override fun getFhirEngine(): FhirEngine = fhirEngine + + override fun getDownloadWorkManager(): DownloadWorkManager = + ResourceParamsBasedDownloadWorkManager(SYNC_RESOURCE_PARAMS, timestampContext) + + override fun getConflictResolver(): ConflictResolver = AcceptLocalConflictResolver + + override fun getUploadStrategy(): UploadStrategy = + UploadStrategy.forBundleRequest( + methodForCreate = HttpCreateMethod.PUT, + methodForUpdate = HttpUpdateMethod.PATCH, + squash = true, + bundleSize = 500, + ) +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/sync/DataStoreTimestampContext.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/sync/DataStoreTimestampContext.kt new file mode 100644 index 00000000..9a77a283 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/sync/DataStoreTimestampContext.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.ohs.fhir.engine.sync.download.ResourceParamsBasedDownloadWorkManager +import dev.ohs.fhir.model.r4.terminologies.ResourceType +import kotlinx.coroutines.flow.first + +/** + * File name for the [DataStore] backing [DataStoreTimestampContext] — see + * [createSyncTimestampDataStore]. + */ +internal const val SYNC_TIMESTAMP_DATASTORE_FILE_NAME = "sync_timestamps.preferences_pb" + +/** Supplies the platform [DataStore] backing [DataStoreTimestampContext]. */ +internal expect fun createSyncTimestampDataStore(): DataStore + +/** + * Persists each [ResourceType]'s download cursor (`_lastUpdated`) in a Preferences [DataStore], so + * a fresh app launch resumes incremental sync instead of re-downloading every configured resource + * type from scratch. Mirrors kotlin-fhir-engine's engine-app `DemoDataStore`. + */ +class DataStoreTimestampContext(private val dataStore: DataStore) : + ResourceParamsBasedDownloadWorkManager.TimestampContext { + + override suspend fun saveLastUpdatedTimestamp(resourceType: ResourceType, timestamp: String?) { + if (timestamp == null) return + dataStore.edit { prefs -> prefs[stringPreferencesKey(resourceType.name)] = timestamp } + } + + override suspend fun getLasUpdateTimestamp(resourceType: ResourceType): String? = + dataStore.data.first()[stringPreferencesKey(resourceType.name)] +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/sync/InitialSyncStore.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/sync/InitialSyncStore.kt new file mode 100644 index 00000000..9c20bcd0 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/sync/InitialSyncStore.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.first + +/** + * Persisted "the first sync has succeeded at least once" marker that drives the initial-sync gate. + * A plain data-count check ([dev.ohs.player.reference.app.data.repository.FhirRepository]) can't do + * this: an account that syncs successfully but holds no resources would look un-synced forever. The + * marker is set only on a successful sync, so a failed first sync still shows the blocking gate. + */ +interface InitialSyncStore { + suspend fun isComplete(): Boolean + + suspend fun markComplete() +} + +internal class DataStoreInitialSyncStore(private val dataStore: DataStore) : + InitialSyncStore { + override suspend fun isComplete(): Boolean = + dataStore.data.first()[INITIAL_SYNC_COMPLETE_KEY] == true + + override suspend fun markComplete() { + dataStore.edit { it[INITIAL_SYNC_COMPLETE_KEY] = true } + } + + private companion object { + val INITIAL_SYNC_COMPLETE_KEY = booleanPreferencesKey("initial_sync_complete") + } +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncManager.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncManager.kt new file mode 100644 index 00000000..68f9d7ad --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncManager.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import dev.ohs.fhir.engine.sync.SyncJobStatus + +/** + * This app's single sync seam. Scheduling is inherently platform-specific — WorkManager on Android, + * `BGTaskScheduler` on iOS, an in-process foreground loop on JVM/web — so each platform supplies + * its own implementation (`WorkManagerSyncManager`, `ForegroundSyncManager`, `IosSyncManager`). + * Routing every sync operation through one interface also keeps the ViewModels unit-testable + * against a fake: the engine's `runSync` is a top-level extension over a global singleton, so there + * is no seam to fake without it. + */ +interface SyncManager { + /** Runs a one-time sync and returns its terminal result. */ + suspend fun syncNow(): SyncJobStatus + + /** Cancels an in-flight [syncNow]. No-op if no one-time sync is running. */ + suspend fun cancelSyncNow() + + /** + * Schedules the recurring background sync (every 15 minutes while online). Safe to call + * repeatedly — never schedules a duplicate/competing cycle. What that means differs per platform: + * Android's `ExistingPeriodicWorkPolicy.KEEP` no-ops if already scheduled; iOS cancels any + * pending request before submitting a fresh one; JVM/web checks an already-running job and + * no-ops. + */ + suspend fun startPeriodicSync() + + /** Cancels the periodic sync. No-op if none is scheduled. */ + suspend fun cancelPeriodicSync() +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/component/common/CardView.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/component/common/CardView.kt deleted file mode 100644 index e8dc1df5..00000000 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/component/common/CardView.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2026 Open Health Stack Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package dev.ohs.player.reference.app.feature.component.common - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -class CardDslScope { - internal var headerContent: (@Composable () -> Unit)? = null - internal var bodyContent: (@Composable () -> Unit)? = null - internal var footerContent: (@Composable () -> Unit)? = null - - fun header(content: @Composable () -> Unit) { - headerContent = content - } - - fun body(content: @Composable () -> Unit) { - bodyContent = content - } - - fun footer(content: @Composable () -> Unit) { - footerContent = content - } -} - -@Composable -fun CardView( - elevationDp: Float = 2f, - contentPaddingDp: Float = 16f, - onClick: (() -> Unit)? = null, - builder: CardDslScope.() -> Unit, -) { - val scope = CardDslScope().apply(builder) - val cardModifier = - if (onClick != null) { - Modifier.fillMaxWidth().clickable(onClick = onClick) - } else { - Modifier.fillMaxWidth() - } - Card( - modifier = cardModifier, - elevation = CardDefaults.elevatedCardElevation(defaultElevation = elevationDp.dp), - ) { - Column( - modifier = Modifier.padding(contentPaddingDp.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - scope.headerContent?.invoke() - scope.bodyContent?.invoke() - scope.footerContent?.invoke() - } - } -} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/component/common/SectionCardLayoutRenderer.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/component/common/SectionCardLayoutRenderer.kt index c1567c98..22dd13e4 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/component/common/SectionCardLayoutRenderer.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/component/common/SectionCardLayoutRenderer.kt @@ -19,6 +19,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,15 +30,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -50,18 +45,26 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import dev.ohs.player.generated.config.SectionCardConfig import dev.ohs.player.library.renderer.ConfiguredRenderer import dev.ohs.player.library.renderer.LayoutRenderer import dev.ohs.player.library.renderer.RenderOptions +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.section_collapse +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.section_expand +import org.jetbrains.compose.resources.stringResource /** - * Layout renderer that wraps a list of items inside a titled section card. Registered under + * Layout renderer that introduces a list of items with a flat overline section label — no card, + * border, or per-row divider. Registered under * [dev.ohs.player.generated.viewtype.ViewTypeCS.SectionCard]. * - * Supports optional item-count badge and collapsible behavior driven by [SectionCardConfig]. + * Supports an optional item count and collapsible behavior driven by [SectionCardConfig]. */ class SectionCardLayoutRenderer( private val title: String, @@ -80,86 +83,74 @@ class SectionCardLayoutRenderer( ) { var expanded by rememberSaveable { mutableStateOf(true) } val tint = iconTint ?: MaterialTheme.colorScheme.primary + val collapsible = config.collapsible == true - Card( - modifier = modifier.fillMaxWidth(), - elevation = - CardDefaults.elevatedCardElevation( - defaultElevation = (config.elevation?.floatValue() ?: 2f).dp - ), - ) { - Column { - Box(modifier = Modifier.fillMaxWidth().height(3.dp).background(tint)) - Column(modifier = Modifier.padding((config.padding?.floatValue() ?: 16f).dp)) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = modifier.fillMaxWidth().padding(horizontal = 4.dp)) { + Row( + modifier = + Modifier.fillMaxWidth() + .then(if (collapsible) Modifier.clickable { expanded = !expanded } else Modifier) + .padding(top = 12.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelLarge, + letterSpacing = 0.8.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (config.showItemCount != false) { + Text( + text = items.size.toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (collapsible) { + Spacer(modifier = Modifier.width(12.dp)) + val toggleLabel = + stringResource(if (expanded) Res.string.section_collapse else Res.string.section_expand) + val barColor = MaterialTheme.colorScheme.onSurfaceVariant + Box( + modifier = Modifier.size(20.dp).semantics { contentDescription = toggleLabel }, + contentAlignment = Alignment.Center, + ) { Box( - modifier = - Modifier.size(32.dp).clip(CircleShape).background(tint.copy(alpha = 0.12f)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tint, - modifier = Modifier.size(18.dp), - ) - } - Spacer(modifier = Modifier.width(10.dp)) - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), + Modifier.width(12.dp).height(2.dp).clip(RoundedCornerShape(1.dp)).background(barColor) ) - if (config.showItemCount != false) { + if (!expanded) { Box( - modifier = - Modifier.clip(CircleShape) - .background(tint.copy(alpha = 0.12f)) - .padding(horizontal = 8.dp, vertical = 2.dp) - ) { - Text( - text = items.size.toString(), - style = MaterialTheme.typography.labelMedium, - color = tint, - fontWeight = FontWeight.Bold, - ) - } - } - if (config.collapsible == true) { - IconButton(onClick = { expanded = !expanded }, modifier = Modifier.size(32.dp)) { - Icon( - imageVector = - if (expanded) Icons.Default.KeyboardArrowUp - else Icons.Default.KeyboardArrowDown, - contentDescription = if (expanded) "Collapse" else "Expand", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } + Modifier.width(2.dp) + .height(12.dp) + .clip(RoundedCornerShape(1.dp)) + .background(barColor) + ) } } + } + } - AnimatedVisibility( - visible = expanded, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { - HorizontalDivider( - modifier = Modifier.padding(top = 10.dp, bottom = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant, - ) - items.forEachIndexed { i, item -> - if (i > 0) - HorizontalDivider( - modifier = Modifier.padding(vertical = 2.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - component.Render(item, RenderOptions(onClick = { onItemClick(item) })) - } - } + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + HorizontalDivider( + modifier = Modifier.padding(bottom = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + items.forEach { item -> + component.Render(item, RenderOptions(onClick = { onItemClick(item) })) } } } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/component/common/StatusRow.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/component/common/StatusRow.kt index 212c3888..7efa3c9d 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/component/common/StatusRow.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/component/common/StatusRow.kt @@ -21,10 +21,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,10 +38,10 @@ import androidx.compose.ui.unit.dp data class StatusChipData(val label: String, val containerColor: Color, val contentColor: Color) /** - * A list row with an optional left accent bar, a bold [title] with an optional [subtitle], and an - * optional trailing status [Chip]. Shared by the medical profile item renderers (allergy, - * condition, medication, immunization), which differ only in how they map their state to these - * fields. + * A flat list row with an optional leading severity dot, a bold [title] with an optional + * [subtitle], and an optional trailing status [Chip]. Shared by the medical profile item renderers + * (allergy, condition, medication, immunization), which differ only in how they map their state to + * these fields. */ @Composable fun StatusRow( @@ -51,24 +50,15 @@ fun StatusRow( subtitle: String? = null, subtitleColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, accentColor: Color? = null, - rowBackground: Color = Color.Transparent, status: StatusChipData? = null, ) { Row( - modifier = - modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .background(rowBackground) - .padding(vertical = 6.dp), + modifier = modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { if (accentColor != null) { - Box( - modifier = - Modifier.width(3.dp).height(36.dp).clip(RoundedCornerShape(2.dp)).background(accentColor) - ) + Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(accentColor)) } Column(modifier = Modifier.weight(1f)) { Text( diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupCard.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupCard.kt index d95b8d85..8cb62aae 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupCard.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupCard.kt @@ -16,27 +16,38 @@ package dev.ohs.player.reference.app.feature.group.list import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material3.Icon +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.ohs.player.generated.config.GroupCardConfig import dev.ohs.player.generated.state.GroupListState -import dev.ohs.player.reference.app.feature.component.common.CardView +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_member_count_one +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_member_count_other +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_unknown_name +import org.jetbrains.compose.resources.stringResource + +/** + * Selected household id for the two-pane list-detail highlight; null when there is no open pane. + */ +val LocalSelectedGroupId = compositionLocalOf { null } @Composable fun GroupCard( @@ -44,58 +55,55 @@ fun GroupCard( config: GroupCardConfig = GroupCardConfig(), onClick: (() -> Unit)? = null, ) { - val name = group.groupName ?: "Unknown Household" + val name = group.groupName ?: stringResource(Res.string.group_unknown_name) val initials = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "H" val count = group.memberCount ?: "0" - val memberLabel = if (count == "1") "1 member" else "$count members" + val memberLabel = + stringResource( + if (count == "1") Res.string.group_member_count_one else Res.string.group_member_count_other, + count, + ) + val selected = group.groupId != null && group.groupId == LocalSelectedGroupId.current - CardView( - elevationDp = config.elevation?.floatValue() ?: 2f, - contentPaddingDp = config.padding?.floatValue() ?: 16f, - onClick = onClick, + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, ) { - header { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier.size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Text( - text = initials, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer, - fontWeight = FontWeight.Bold, - ) - } - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - if (config.showMemberCount != false) { - Text( - text = memberLabel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - if (onClick != null) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), - modifier = Modifier.size(16.dp), - ) - } + Box( + modifier = + Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + text = initials, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = + if (selected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface, + ) + if (config.showMemberCount != false) { + Text( + text = memberLabel, + style = MaterialTheme.typography.bodyMedium, + color = + if (selected) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + else MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupListRegistrations.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupListRegistrations.kt index 3e8e8a4d..35be7dc0 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupListRegistrations.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupListRegistrations.kt @@ -33,6 +33,9 @@ fun ViewRegistry.registerGroupList() { ) registerLayout( VerticalListRenderer.VIEW_TYPE, - VerticalListRenderer(contentPadding = PaddingValues(16.dp), itemSpacing = 12.dp), + VerticalListRenderer( + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + itemSpacing = 2.dp, + ), ) } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupListScreen.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupListScreen.kt index f797dd17..07ef5b00 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupListScreen.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupListScreen.kt @@ -16,19 +16,17 @@ package dev.ohs.player.reference.app.feature.group.list import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -38,6 +36,10 @@ import dev.ohs.player.generated.state.GroupListState import dev.ohs.player.generated.viewtype.ViewTypeCS import dev.ohs.player.library.layout.VerticalListRenderer import dev.ohs.player.library.scaffold.ListScaffold +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_list_empty +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_list_register_household +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -54,20 +56,13 @@ fun GroupListScreen(onGroupClick: (String) -> Unit, onDataCaptureClick: () -> Un } Scaffold( - topBar = { - TopAppBar( - title = { Text("Households") }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primary, - titleContentColor = MaterialTheme.colorScheme.onPrimary, - ), - ) - }, + contentWindowInsets = WindowInsets(0, 0, 0, 0), floatingActionButton = { - FloatingActionButton(onClick = onDataCaptureClick) { - Icon(Icons.Filled.Add, contentDescription = "Register household") - } + ExtendedFloatingActionButton( + onClick = onDataCaptureClick, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + text = { Text(stringResource(Res.string.group_list_register_household)) }, + ) }, ) { padding -> Box(modifier = Modifier.fillMaxSize().padding(padding)) { @@ -78,7 +73,7 @@ fun GroupListScreen(onGroupClick: (String) -> Unit, onDataCaptureClick: () -> Un ) { component(ViewTypeCS.GroupCard) layout(VerticalListRenderer.VIEW_TYPE) - emptyState { Text("No households") } + emptyState { Text(stringResource(Res.string.group_list_empty)) } } } } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupListViewModel.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupListViewModel.kt index ad7d9568..99c5d7d3 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupListViewModel.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/list/GroupListViewModel.kt @@ -30,8 +30,6 @@ class GroupListViewModel(private val groupRepository: GroupRepository) : ViewMod val groups: StateFlow?> = _groups.asStateFlow() init { - viewModelScope.launch { - groupRepository.observeGroups().collect { _groups.value = it.reversed() } - } + viewModelScope.launch { groupRepository.observeGroups().collect { _groups.value = it } } } } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/profile/GroupHeaderRenderer.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/profile/GroupHeaderRenderer.kt index 1ad51dde..1b104daa 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/profile/GroupHeaderRenderer.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/profile/GroupHeaderRenderer.kt @@ -24,12 +24,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Person -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -42,7 +36,12 @@ import dev.ohs.player.generated.config.GroupHeaderConfig import dev.ohs.player.generated.state.GroupHeaderState import dev.ohs.player.library.renderer.ComponentRenderer import dev.ohs.player.library.renderer.RenderOptions -import dev.ohs.player.reference.app.feature.component.common.Chip +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_default_name +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_head +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_member_count_one +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_member_count_other +import org.jetbrains.compose.resources.stringResource class GroupHeaderRenderer : ComponentRenderer { @Composable @@ -57,72 +56,58 @@ fun GroupHeaderCard( config: GroupHeaderConfig = GroupHeaderConfig(), modifier: Modifier = Modifier, ) { - val name = item.groupName ?: "Household" + val name = item.groupName ?: stringResource(Res.string.group_default_name) val initials = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "H" val memberCount = item.memberCount ?: "0" val headName = listOfNotNull(item.headGivenName, item.headFamilyName).joinToString(" ").ifBlank { null } - - Card( - modifier = modifier.fillMaxWidth(), - elevation = - CardDefaults.elevatedCardElevation( - defaultElevation = (config.elevation?.floatValue() ?: 2f).dp - ), - ) { - Row( - modifier = Modifier.fillMaxWidth().padding((config.padding?.floatValue() ?: 20f).dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier.size(72.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Text( - text = initials, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - fontWeight = FontWeight.Bold, - ) - } - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(text = name, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - HorizontalDivider( - modifier = Modifier.padding(top = 4.dp, bottom = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant, - ) + val meta = + buildList { if (config.showMemberCount != false) { - Row { - Chip( - label = "$memberCount member${if (memberCount == "1") "" else "s"}", - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + add( + stringResource( + if (memberCount == "1") Res.string.group_member_count_one + else Res.string.group_member_count_other, + memberCount, ) - } + ) } if (config.showHeadName != false && headName != null) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.dp), - ) - Text( - text = "Head: $headName", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + add(stringResource(Res.string.group_head, headName)) } } + .joinToString(" · ") + + Row( + modifier = modifier.fillMaxWidth().padding((config.padding?.floatValue() ?: 20f).dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier.size(72.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + text = initials, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold, + ) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = name, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + if (meta.isNotEmpty()) { + Text( + text = meta, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/profile/GroupProfileScreen.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/profile/GroupProfileScreen.kt index f80bae1a..42c940e7 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/profile/GroupProfileScreen.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/profile/GroupProfileScreen.kt @@ -48,6 +48,12 @@ import dev.ohs.player.library.registry.LocalViewRegistry import dev.ohs.player.library.registry.componentRenderer import dev.ohs.player.library.registry.layoutRenderer import dev.ohs.player.library.renderer.RenderOptions +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_profile_add_members +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_profile_add_members_description +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_profile_back +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.group_profile_default_name +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -70,7 +76,8 @@ fun GroupProfileScreen( val memberSectionLayout = remember(registry) { registry.layoutRenderer(ViewTypeCS.SectionCard) } - val groupName = state?.groupHeader?.groupName ?: "Household" + val groupName = + state?.groupHeader?.groupName ?: stringResource(Res.string.group_profile_default_name) Scaffold( topBar = { @@ -80,7 +87,7 @@ fun GroupProfileScreen( IconButton(onClick = onBack) { Icon( Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(Res.string.group_profile_back), tint = MaterialTheme.colorScheme.onPrimary, ) } @@ -95,8 +102,13 @@ fun GroupProfileScreen( floatingActionButton = { ExtendedFloatingActionButton( onClick = onAddMembers, - text = { Text("Add members") }, - icon = { Icon(Icons.Filled.Add, contentDescription = "Add household members") }, + text = { Text(stringResource(Res.string.group_profile_add_members)) }, + icon = { + Icon( + Icons.Filled.Add, + contentDescription = stringResource(Res.string.group_profile_add_members_description), + ) + }, ) }, ) { padding -> diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/profile/MemberItemRenderer.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/profile/MemberItemRenderer.kt index 3b992f5b..9f31cdad 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/profile/MemberItemRenderer.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/group/profile/MemberItemRenderer.kt @@ -43,6 +43,8 @@ import dev.ohs.player.library.renderer.RenderOptions import dev.ohs.player.reference.app.feature.component.common.Chip import dev.ohs.player.reference.app.feature.patient.list.calculateAge import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.label_age +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.name_unknown import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.relationship_child import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.relationship_guardian import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.relationship_non_relative @@ -79,7 +81,7 @@ fun MemberItemRow( .ifBlank { "?" } val fullName = listOfNotNull(item.memberGivenName, item.memberFamilyName).joinToString(" ").ifBlank { - "Unknown" + stringResource(Res.string.name_unknown) } val relationshipLabel = item.relationshipCode?.toRelationshipLabel() @@ -94,15 +96,13 @@ fun MemberItemRow( ) { Box( modifier = - Modifier.size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), + Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center, ) { Text( text = initials, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold, ) } @@ -114,7 +114,9 @@ fun MemberItemRow( ) val subtitleParts = buildList { if (config.showAge != false) { - calculateAge(item.memberBirthDate?.toString())?.let { add("Age $it") } + calculateAge(item.memberBirthDate?.toString())?.let { + add(stringResource(Res.string.label_age, it)) + } } if (config.showGender != false) { item.memberGender?.let { add(it.replaceFirstChar { c -> c.uppercaseChar() }) } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/home/HomeDestination.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/home/HomeDestination.kt new file mode 100644 index 00000000..ad45a329 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/home/HomeDestination.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.feature.home + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.ui.graphics.vector.ImageVector +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.home_destination_households +import org.jetbrains.compose.resources.StringResource + +/** + * A top-level destination reachable from [HomeScreen]'s navigation drawer. `Households` is the only + * entry today; adding a second destination later is a new enum entry, not a rewrite. + */ +enum class HomeDestination(val label: StringResource, val icon: ImageVector) { + Households(label = Res.string.home_destination_households, icon = Icons.Filled.Home) +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/home/HomeScreen.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/home/HomeScreen.kt new file mode 100644 index 00000000..f894a240 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/home/HomeScreen.kt @@ -0,0 +1,417 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.feature.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowSizeClass +import dev.ohs.player.reference.app.feature.group.list.GroupListScreen +import dev.ohs.player.reference.app.feature.group.list.LocalSelectedGroupId +import dev.ohs.player.reference.app.feature.group.profile.GroupProfileScreen +import dev.ohs.player.reference.app.feature.patient.profile.PatientProfileScreen +import kotlinx.coroutines.launch +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.home_cancel_sync +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.home_last_synced +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.home_open_navigation_menu +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.home_registers +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.home_select_household +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.home_sign_out +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.home_signed_in +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.home_sync_cancelled +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.home_sync_failed +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.home_sync_in_progress +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.home_sync_now +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun HomeScreen( + userName: String, + onGroupClick: (String) -> Unit, + onDataCaptureClick: () -> Unit, + onAddMembers: (String) -> Unit, + onAddClinicalData: (String) -> Unit, + onSignOut: () -> Unit, +) { + val homeViewModel: HomeViewModel = koinViewModel() + val uiState by homeViewModel.uiState.collectAsStateWithLifecycle() + + var selectedDestination by remember { mutableStateOf(HomeDestination.Households) } + var selectedGroupId by rememberSaveable { mutableStateOf(null) } + var selectedPatientId by rememberSaveable(selectedGroupId) { mutableStateOf(null) } + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + val syncErrorMessage = + when (uiState.syncError) { + SyncError.Failed -> stringResource(Res.string.home_sync_failed) + SyncError.Cancelled -> stringResource(Res.string.home_sync_cancelled) + null -> null + } + LaunchedEffect(uiState.syncError) { + if (syncErrorMessage != null) { + snackbarHostState.showSnackbar(syncErrorMessage) + homeViewModel.clearSyncError() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val isExpandedWidth = + windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) + val isMediumWidth = + !isExpandedWidth && + windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) + + fun closeDrawerIfCompact() { + if (!isExpandedWidth) scope.launch { drawerState.close() } + } + + val onDrawer = MaterialTheme.colorScheme.onPrimary + val drawerItemColors = + NavigationDrawerItemDefaults.colors( + selectedContainerColor = onDrawer.copy(alpha = 0.20f), + unselectedContainerColor = Color.Transparent, + selectedTextColor = onDrawer, + unselectedTextColor = onDrawer, + selectedIconColor = onDrawer, + unselectedIconColor = onDrawer, + ) + val drawerItems: @Composable () -> Unit = { + val syncInProgressDescription = stringResource(Res.string.home_sync_in_progress) + Column(modifier = Modifier.fillMaxHeight().padding(horizontal = 12.dp)) { + Row( + modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 20.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier.size(40.dp).clip(CircleShape).background(onDrawer.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = userName.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?", + style = MaterialTheme.typography.titleMedium, + color = onDrawer, + ) + } + Text( + text = userName.ifBlank { stringResource(Res.string.home_signed_in) }, + style = MaterialTheme.typography.titleMedium, + color = onDrawer, + fontWeight = FontWeight.SemiBold, + ) + } + Text( + text = stringResource(Res.string.home_registers), + style = MaterialTheme.typography.titleSmall, + color = onDrawer.copy(alpha = 0.7f), + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp), + ) + HomeDestination.entries.forEach { destination -> + NavigationDrawerItem( + label = { Text(stringResource(destination.label)) }, + icon = { Icon(destination.icon, contentDescription = null) }, + selected = destination == selectedDestination, + colors = drawerItemColors, + onClick = { + selectedDestination = destination + closeDrawerIfCompact() + }, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + HorizontalDivider(color = onDrawer.copy(alpha = 0.2f)) + uiState.lastSyncedAt?.let { lastSyncedAt -> + Text( + text = stringResource(Res.string.home_last_synced, lastSyncedAt), + style = MaterialTheme.typography.bodySmall, + color = onDrawer.copy(alpha = 0.7f), + modifier = Modifier.padding(start = 16.dp, top = 8.dp), + ) + } + NavigationDrawerItem( + colors = drawerItemColors, + label = { + Text( + stringResource( + if (uiState.isSyncing) Res.string.home_cancel_sync else Res.string.home_sync_now + ) + ) + }, + icon = { + Icon( + if (uiState.isSyncing) Icons.Filled.Close else Icons.Filled.Refresh, + contentDescription = null, + ) + }, + badge = { + if (uiState.isSyncing) { + CircularProgressIndicator( + modifier = + Modifier.size(16.dp).semantics { contentDescription = syncInProgressDescription }, + strokeWidth = 2.dp, + color = onDrawer, + ) + } + }, + selected = false, + onClick = { + if (uiState.isSyncing) { + homeViewModel.cancelSync() + } else { + homeViewModel.syncNow() + } + closeDrawerIfCompact() + }, + ) + NavigationDrawerItem( + colors = drawerItemColors, + label = { Text(stringResource(Res.string.home_sign_out)) }, + icon = { Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = null) }, + selected = false, + onClick = { + onSignOut() + closeDrawerIfCompact() + }, + ) + } + } + + val content: @Composable () -> Unit = { + when (selectedDestination) { + HomeDestination.Households -> + if (isExpandedWidth) { + Row(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f).fillMaxSize()) { + CompositionLocalProvider(LocalSelectedGroupId provides selectedGroupId) { + GroupListScreen( + onGroupClick = { selectedGroupId = it }, + onDataCaptureClick = onDataCaptureClick, + ) + } + } + VerticalDivider() + Box(modifier = Modifier.weight(1.5f).fillMaxSize()) { + val patientId = selectedPatientId + val groupId = selectedGroupId + when { + patientId != null -> + PatientProfileScreen( + patientId = patientId, + onBack = { selectedPatientId = null }, + onAddClinicalData = { onAddClinicalData(patientId) }, + ) + groupId != null -> + GroupProfileScreen( + groupId = groupId, + onBack = { selectedGroupId = null }, + onMemberClick = { selectedPatientId = it }, + onAddMembers = { onAddMembers(groupId) }, + ) + else -> EmptyDetailPlaceholder() + } + } + } + } else { + GroupListScreen(onGroupClick = onGroupClick, onDataCaptureClick = onDataCaptureClick) + } + } + } + + if (isExpandedWidth) { + Row(modifier = Modifier.fillMaxSize()) { + Surface( + modifier = Modifier.width(260.dp).fillMaxHeight(), + color = MaterialTheme.colorScheme.primary, + ) { + drawerItems() + } + VerticalDivider() + Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding -> + Box(modifier = Modifier.padding(padding)) { content() } + } + } + } else if (isMediumWidth) { + Row(modifier = Modifier.fillMaxSize()) { + val railItemColors = + NavigationRailItemDefaults.colors( + indicatorColor = onDrawer.copy(alpha = 0.20f), + selectedIconColor = onDrawer, + unselectedIconColor = onDrawer, + selectedTextColor = onDrawer, + unselectedTextColor = onDrawer, + ) + NavigationRail(containerColor = MaterialTheme.colorScheme.primary) { + val syncInProgressDescription = stringResource(Res.string.home_sync_in_progress) + HomeDestination.entries.forEach { destination -> + NavigationRailItem( + selected = destination == selectedDestination, + onClick = { selectedDestination = destination }, + colors = railItemColors, + icon = { Icon(destination.icon, contentDescription = null) }, + label = { Text(stringResource(destination.label)) }, + ) + } + Spacer(modifier = Modifier.weight(1f)) + NavigationRailItem( + selected = false, + colors = railItemColors, + onClick = { + if (uiState.isSyncing) homeViewModel.cancelSync() else homeViewModel.syncNow() + }, + icon = { + if (uiState.isSyncing) { + Icon( + Icons.Filled.Close, + contentDescription = null, + modifier = Modifier.semantics { contentDescription = syncInProgressDescription }, + ) + } else { + Icon(Icons.Filled.Refresh, contentDescription = null) + } + }, + label = { + Text( + stringResource( + if (uiState.isSyncing) Res.string.home_cancel_sync else Res.string.home_sync_now + ) + ) + }, + ) + NavigationRailItem( + selected = false, + colors = railItemColors, + onClick = onSignOut, + icon = { Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = null) }, + label = { Text(stringResource(Res.string.home_sign_out)) }, + ) + } + VerticalDivider() + Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding -> + Box(modifier = Modifier.padding(padding)) { content() } + } + } + } else { + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet( + modifier = Modifier.width(280.dp), + drawerContainerColor = MaterialTheme.colorScheme.primary, + ) { + drawerItems() + } + }, + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(selectedDestination.label)) }, + navigationIcon = { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + Icon( + Icons.Filled.Menu, + contentDescription = stringResource(Res.string.home_open_navigation_menu), + ) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Box(modifier = Modifier.padding(padding)) { content() } + } + } + } + } +} + +@Composable +private fun EmptyDetailPlaceholder() { + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + text = stringResource(Res.string.home_select_household), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/home/HomeViewModel.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/home/HomeViewModel.kt new file mode 100644 index 00000000..47150fb6 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/home/HomeViewModel.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.feature.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.ohs.fhir.engine.sync.FhirDataStore +import dev.ohs.fhir.engine.sync.SyncJobStatus +import dev.ohs.player.reference.app.data.sync.SyncManager +import kotlin.time.Instant +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +/** Why the last sync ended without success — the screen maps each to a localized message. */ +enum class SyncError { + Failed, + Cancelled, +} + +data class HomeUiState( + val isSyncing: Boolean = false, + val lastSyncedAt: String? = null, + val syncError: SyncError? = null, +) + +class HomeViewModel( + private val syncManager: SyncManager, + private val fhirDataStore: FhirDataStore, +) : ViewModel() { + private val _uiState = MutableStateFlow(HomeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var cancelRequested = false + + init { + viewModelScope.launch { + val lastSyncedAt = fhirDataStore.readLastSyncTimestamp()?.toDisplayString() + _uiState.update { it.copy(lastSyncedAt = lastSyncedAt) } + } + } + + /** + * Triggers a one-time sync. Returns `null` without starting a new sync if one is already in + * progress; otherwise returns the launched [Job] (primarily so tests can `join()` it). + */ + fun syncNow(): Job? { + if (_uiState.value.isSyncing) return null + cancelRequested = false + _uiState.update { it.copy(isSyncing = true, syncError = null) } + return viewModelScope.launch { + val result = + try { + syncManager.syncNow() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + SyncJobStatus.Failed() + } + when (result) { + is SyncJobStatus.Succeeded -> { + val lastSyncedAt = fhirDataStore.readLastSyncTimestamp()?.toDisplayString() + _uiState.update { it.copy(isSyncing = false, lastSyncedAt = lastSyncedAt) } + } + else -> { + val error = if (cancelRequested) SyncError.Cancelled else SyncError.Failed + _uiState.update { it.copy(isSyncing = false, syncError = error) } + } + } + } + } + + /** Cancels an in-flight [syncNow]. No-op if no sync is currently running. */ + fun cancelSync() { + if (!_uiState.value.isSyncing) return + cancelRequested = true + viewModelScope.launch { syncManager.cancelSyncNow() } + } + + fun clearSyncError() { + _uiState.update { it.copy(syncError = null) } + } +} + +private fun Instant.toDisplayString(): String { + val local = toLocalDateTime(TimeZone.currentSystemDefault()) + return "${local.date} ${local.hour.toString().padStart(2, '0')}:" + + local.minute.toString().padStart(2, '0') +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/login/LoginScreen.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/login/LoginScreen.kt new file mode 100644 index 00000000..abe56d05 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/login/LoginScreen.kt @@ -0,0 +1,306 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.feature.login + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.app_logo +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.login_brand +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.login_card_title +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.login_error_dismiss +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.login_error_title +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.login_redirect_hint +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.login_sign_in +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/** Material 3's "expanded" window size class breakpoint (matches HomeWidthBreakpoint.kt). */ +private val LOGIN_EXPANDED_WIDTH_BREAKPOINT = 840.dp + +/** + * PKCE redirect login — no password form; the primary action hands off to the identity provider. + * Branches on width (never on platform): Expanded (>= 840dp) sets a solid brand panel beside a flat + * sign-in side; Compact/Medium stack a brand band over a rounded sign-in sheet. Both are flat — the + * brand color carries the identity, so there is no elevated card. + */ +@Composable +fun LoginScreen( + signingIn: Boolean, + error: String?, + onSignIn: () -> Unit, + onErrorDismiss: () -> Unit, +) { + BoxWithConstraints { + if (maxWidth >= LOGIN_EXPANDED_WIDTH_BREAKPOINT) { + ExpandedLogin(signingIn, onSignIn) + } else { + CompactLogin(signingIn, onSignIn) + } + } + + if (error != null) { + LoginErrorDialog(message = error, onDismiss = onErrorDismiss) + } +} + +/** Expanded: solid brand panel (left) beside a flat sign-in side (right). */ +@Composable +private fun ExpandedLogin(signingIn: Boolean, onSignIn: () -> Unit) { + Row(Modifier.fillMaxSize()) { + Column( + modifier = + Modifier.weight(5f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.primary) + .safeDrawingPadding() + .padding(44.dp) + ) { + MonoLogoMark(64.dp) + Spacer(Modifier.height(22.dp)) + Text( + text = stringResource(Res.string.login_brand), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold, + ) + } + + Box( + modifier = + Modifier.weight(6f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surface) + .safeDrawingPadding() + .padding(horizontal = 48.dp), + contentAlignment = Alignment.Center, + ) { + Column(modifier = Modifier.widthIn(max = 420.dp).fillMaxWidth()) { + SignInContent(signingIn, onSignIn, showBrandRow = true) + } + } + } +} + +/** Compact/Medium: brand band over a rounded sign-in sheet; button pinned low. */ +@Composable +private fun CompactLogin(signingIn: Boolean, onSignIn: () -> Unit) { + Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { + Column( + modifier = + Modifier.fillMaxWidth() + .background(MaterialTheme.colorScheme.primary) + .statusBarsPadding() + .padding(horizontal = 28.dp, vertical = 44.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + MonoLogoMark(56.dp) + Text( + text = stringResource(Res.string.login_brand), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold, + ) + } + + Column( + modifier = + Modifier.fillMaxWidth() + .weight(1f) + .background( + MaterialTheme.colorScheme.surface, + RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + ) + .navigationBarsPadding() + .padding(horizontal = 26.dp) + .padding(top = 32.dp, bottom = 22.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + SignInContent(signingIn, onSignIn, showBrandRow = false, buttonAtBottom = true) + } + } +} + +@Composable +private fun ColumnScope.SignInContent( + signingIn: Boolean, + onSignIn: () -> Unit, + showBrandRow: Boolean, + buttonAtBottom: Boolean = false, +) { + if (showBrandRow) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Image( + painter = painterResource(Res.drawable.app_logo), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(Res.string.login_brand).uppercase(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.height(26.dp)) + } + + Text( + text = stringResource(Res.string.login_card_title), + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(10.dp)) + Text( + text = stringResource(Res.string.login_redirect_hint), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (buttonAtBottom) { + Spacer(Modifier.weight(1f)) + } else { + Spacer(Modifier.height(28.dp)) + } + SignInButton(signingIn, onSignIn) +} + +@Composable +private fun SignInButton(signingIn: Boolean, onSignIn: () -> Unit) { + Button( + onClick = onSignIn, + enabled = !signingIn, + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp), + modifier = Modifier.fillMaxWidth().height(52.dp), + ) { + if (signingIn) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text( + text = stringResource(Res.string.login_sign_in), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.size(8.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } +} + +/** The 2x2 brand mark rendered monochrome for the solid brand panel. */ +@Composable +private fun MonoLogoMark(size: Dp) { + val gap = size * 0.09f + val cell = (size - gap) / 2 + val corner = cell * 0.28f + val alphas = listOf(0.9f, 0.28f, 0.28f, 0.55f) + val cellModifier = { index: Int -> + Modifier.size(cell) + .clip(RoundedCornerShape(corner)) + .background(Color.White.copy(alpha = alphas[index])) + } + Column(verticalArrangement = Arrangement.spacedBy(gap)) { + Row(horizontalArrangement = Arrangement.spacedBy(gap)) { + Box(cellModifier(0)) + Box(cellModifier(1)) + } + Row(horizontalArrangement = Arrangement.spacedBy(gap)) { + Box(cellModifier(2)) + Box(cellModifier(3)) + } + } +} + +@Composable +private fun LoginErrorDialog(message: String, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(48.dp), + ) + }, + title = { + Text( + text = stringResource(Res.string.login_error_title), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + }, + text = { + Text(text = message, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.login_error_dismiss)) } + }, + iconContentColor = MaterialTheme.colorScheme.error, + titleContentColor = MaterialTheme.colorScheme.error, + ) +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/list/PatientCard.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/list/PatientCard.kt index d3967b28..99c88080 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/list/PatientCard.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/list/PatientCard.kt @@ -16,16 +16,16 @@ package dev.ohs.player.reference.app.feature.patient.list import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material3.Icon +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -36,12 +36,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.ohs.player.generated.config.PatientCardConfig import dev.ohs.player.generated.state.PatientSummaryState -import dev.ohs.player.reference.app.feature.component.common.CardView import dev.ohs.player.reference.app.feature.component.common.StatusChip import kotlin.time.Clock import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.todayIn +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.label_age +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.name_unknown +import org.jetbrains.compose.resources.stringResource @Composable fun PatientCard( @@ -56,79 +59,64 @@ fun PatientCard( } .ifBlank { "?" } val fullName = - listOfNotNull(patient.givenName, patient.familyName).joinToString(" ").ifBlank { "Unknown" } + listOfNotNull(patient.givenName, patient.familyName).joinToString(" ").ifBlank { + stringResource(Res.string.name_unknown) + } - CardView( - elevationDp = config.elevation?.floatValue() ?: 2f, - contentPaddingDp = config.padding?.floatValue() ?: 16f, - onClick = onClick, + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, ) { - header { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier.size(52.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Text( - text = initials, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - fontWeight = FontWeight.Bold, - ) - } - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = fullName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - val subtitleParts = buildList { - if (config.showAge != false) { - calculateAge(patient.birthDate?.toString())?.let { add("Age $it") } - } - if (config.showGender != false) { - patient.gender?.let { add(it.replaceFirstChar { c -> c.uppercaseChar() }) } - } - } - if (subtitleParts.isNotEmpty()) { - Text( - text = subtitleParts.joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - patient.mrn?.let { - Text( - text = it, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline, - ) + Box( + modifier = + Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + text = initials, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = fullName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + val subtitleParts = buildList { + if (config.showAge != false) { + calculateAge(patient.birthDate?.toString())?.let { + add(stringResource(Res.string.label_age, it)) } } - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (config.showStatusChip != false) { - StatusChip(isActive = patient.active ?: false) - } - if (onClick != null) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), - modifier = Modifier.size(16.dp), - ) - } + if (config.showGender != false) { + patient.gender?.let { add(it.replaceFirstChar { c -> c.uppercaseChar() }) } } } + if (subtitleParts.isNotEmpty()) { + Text( + text = subtitleParts.joinToString(" · "), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + patient.mrn?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + if (config.showStatusChip != false) { + StatusChip(isActive = patient.active ?: false) } } } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/list/PatientListRegistrations.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/list/PatientListRegistrations.kt index 74e6fc0d..f2f41db0 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/list/PatientListRegistrations.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/list/PatientListRegistrations.kt @@ -35,7 +35,10 @@ fun ViewRegistry.registerPatientList() { ) registerLayout( VerticalListRenderer.VIEW_TYPE, - VerticalListRenderer(contentPadding = PaddingValues(16.dp), itemSpacing = 12.dp), + VerticalListRenderer( + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + itemSpacing = 2.dp, + ), ) registerLayout( HorizontalListRenderer.VIEW_TYPE, diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/list/PatientListScreen.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/list/PatientListScreen.kt index 108f636f..7b9ca930 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/list/PatientListScreen.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/list/PatientListScreen.kt @@ -32,6 +32,10 @@ import dev.ohs.player.generated.state.PatientSummaryState import dev.ohs.player.generated.viewtype.ViewTypeCS import dev.ohs.player.library.layout.VerticalListRenderer import dev.ohs.player.library.scaffold.ListScaffold +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.patient_list_empty +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.patient_list_title +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -56,7 +60,7 @@ fun PatientListScreen(onPatientClick: (String) -> Unit) { layout(VerticalListRenderer.VIEW_TYPE) topBar { TopAppBar( - title = { Text("Patients") }, + title = { Text(stringResource(Res.string.patient_list_title)) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primary, @@ -64,6 +68,6 @@ fun PatientListScreen(onPatientClick: (String) -> Unit) { ), ) } - emptyState { Text("No patients") } + emptyState { Text(stringResource(Res.string.patient_list_empty)) } } } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/AllergyItemRenderer.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/AllergyItemRenderer.kt index 9650dfe8..f71a6b02 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/AllergyItemRenderer.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/AllergyItemRenderer.kt @@ -24,6 +24,9 @@ import dev.ohs.player.library.renderer.ComponentRenderer import dev.ohs.player.library.renderer.RenderOptions import dev.ohs.player.reference.app.feature.component.common.StatusChipData import dev.ohs.player.reference.app.feature.component.common.StatusRow +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.allergy_unknown +import org.jetbrains.compose.resources.stringResource class AllergyItemRenderer : ComponentRenderer { @Composable @@ -34,16 +37,12 @@ class AllergyItemRenderer : ComponentRenderer { @@ -56,7 +59,7 @@ fun AllergyReactionItemRow( ) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - text = item.substance ?: "Unknown substance", + text = item.substance ?: stringResource(Res.string.allergy_unknown), style = MaterialTheme.typography.bodyMedium, ) if (config.showManifestation != false) { diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/ConditionItemRenderer.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/ConditionItemRenderer.kt index 54196158..f90631ff 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/ConditionItemRenderer.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/ConditionItemRenderer.kt @@ -24,6 +24,10 @@ import dev.ohs.player.library.renderer.ComponentRenderer import dev.ohs.player.library.renderer.RenderOptions import dev.ohs.player.reference.app.feature.component.common.StatusChipData import dev.ohs.player.reference.app.feature.component.common.StatusRow +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.condition_since +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.condition_unknown +import org.jetbrains.compose.resources.stringResource private val AmberAccent = Color(0xFFE37400) @@ -35,7 +39,6 @@ class ConditionItemRenderer : ComponentRenderer AmberAccent @@ -43,11 +46,13 @@ class ConditionItemRenderer : ComponentRenderer MaterialTheme.colorScheme.outline } StatusRow( - title = item.conditionCode ?: "Unknown condition", + title = item.conditionCode ?: stringResource(Res.string.condition_unknown), modifier = options.modifier, - subtitle = if (config.showOnsetDate != false) item.onsetDate?.let { "Since $it" } else null, + subtitle = + if (config.showOnsetDate != false) + item.onsetDate?.let { stringResource(Res.string.condition_since, it) } + else null, accentColor = accentColor, - rowBackground = if (isActive) AmberAccent.copy(alpha = 0.06f) else Color.Transparent, status = if (config.showStatus != false) item.conditionStatus?.let { diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/ContactItemRenderer.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/ContactItemRenderer.kt index 14209486..f5dfe16a 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/ContactItemRenderer.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/ContactItemRenderer.kt @@ -36,6 +36,9 @@ import dev.ohs.player.generated.state.PatientContactState import dev.ohs.player.library.renderer.ComponentRenderer import dev.ohs.player.library.renderer.RenderOptions import dev.ohs.player.reference.app.feature.component.common.Chip +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.name_unknown +import org.jetbrains.compose.resources.stringResource class ContactItemRenderer : ComponentRenderer { @Composable @@ -62,7 +65,7 @@ fun ContactItemRow( .ifBlank { "?" } val fullName = listOfNotNull(item.contactGivenName, item.contactFamilyName).joinToString(" ").ifBlank { - "Unknown" + stringResource(Res.string.name_unknown) } Row( diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/ImmunizationItemRenderer.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/ImmunizationItemRenderer.kt index 3e1af976..da979c89 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/ImmunizationItemRenderer.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/ImmunizationItemRenderer.kt @@ -17,13 +17,16 @@ package dev.ohs.player.reference.app.feature.patient.profile import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color import dev.ohs.player.generated.config.ImmunizationItemConfig import dev.ohs.player.generated.state.PatientImmunizationState import dev.ohs.player.library.renderer.ComponentRenderer import dev.ohs.player.library.renderer.RenderOptions import dev.ohs.player.reference.app.feature.component.common.StatusChipData import dev.ohs.player.reference.app.feature.component.common.StatusRow +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.immunization_given +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.immunization_unknown +import org.jetbrains.compose.resources.stringResource class ImmunizationItemRenderer : ComponentRenderer { @@ -35,14 +38,14 @@ class ImmunizationItemRenderer : ) { val isCompleted = item.immunizationStatus?.lowercase() == "completed" StatusRow( - title = item.vaccineName ?: "Unknown vaccine", + title = item.vaccineName ?: stringResource(Res.string.immunization_unknown), modifier = options.modifier, - subtitle = if (config.showDate != false) item.occurrenceDate?.let { "Given $it" } else null, + subtitle = + if (config.showDate != false) + item.occurrenceDate?.let { stringResource(Res.string.immunization_given, it) } + else null, accentColor = if (isCompleted) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.outline, - rowBackground = - if (isCompleted) MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.25f) - else Color.Transparent, status = if (config.showStatus != false) item.immunizationStatus?.let { diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/MedicationItemRenderer.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/MedicationItemRenderer.kt index 9a557fb1..859fb514 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/MedicationItemRenderer.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/MedicationItemRenderer.kt @@ -17,13 +17,15 @@ package dev.ohs.player.reference.app.feature.patient.profile import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color import dev.ohs.player.generated.config.MedicationItemConfig import dev.ohs.player.generated.state.PatientMedicationState import dev.ohs.player.library.renderer.ComponentRenderer import dev.ohs.player.library.renderer.RenderOptions import dev.ohs.player.reference.app.feature.component.common.StatusChipData import dev.ohs.player.reference.app.feature.component.common.StatusRow +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.medication_unknown +import org.jetbrains.compose.resources.stringResource class MedicationItemRenderer : ComponentRenderer { @Composable @@ -34,14 +36,11 @@ class MedicationItemRenderer : ComponentRenderer { @Composable @@ -67,7 +67,22 @@ fun PatientHeaderCard( } .ifBlank { "?" } val fullName = - listOfNotNull(patient.givenName, patient.familyName).joinToString(" ").ifBlank { "Unknown" } + listOfNotNull(patient.givenName, patient.familyName).joinToString(" ").ifBlank { + stringResource(Res.string.name_unknown) + } + val meta = + buildList { + calculateAge(patient.birthDate?.toString())?.let { + add(stringResource(Res.string.label_age, it)) + } + if (config.showGender != false) { + patient.gender?.let { add(it.replaceFirstChar { c -> c.uppercaseChar() }) } + } + if (config.showMrn != false) + patient.mrn?.let { add(stringResource(Res.string.label_mrn, it)) } + patient.phone?.let { add(it) } + } + .joinToString(" · ") Row( modifier = modifier.fillMaxWidth(), @@ -76,74 +91,29 @@ fun PatientHeaderCard( ) { Box( modifier = - Modifier.size(80.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), + Modifier.size(72.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center, ) { Text( text = initials, style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, + color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold, ) } - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( text = fullName, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) - val ageParts = buildList { - calculateAge(patient.birthDate?.toString())?.let { add("Age $it") } - if (config.showGender != false) { - patient.gender?.let { add(it.replaceFirstChar { c -> c.uppercaseChar() }) } - } - } - if (ageParts.isNotEmpty()) { + if (meta.isNotEmpty()) { Text( - text = ageParts.joinToString(" · "), + text = meta, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - HorizontalDivider( - modifier = Modifier.padding(top = 4.dp, bottom = 4.dp), - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), - ) - if (config.showMrn != false) { - patient.mrn?.let { mrn -> - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "MRN", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = mrn, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - patient.phone?.let { phone -> - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "Phone", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = phone, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) if (config.showStatus != false) { StatusChip(isActive = patient.active ?: false) } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/PatientProfileScreen.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/PatientProfileScreen.kt index 1407ea95..7b852c14 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/PatientProfileScreen.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/patient/profile/PatientProfileScreen.kt @@ -19,14 +19,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton @@ -57,6 +54,12 @@ import dev.ohs.player.library.registry.LocalViewRegistry import dev.ohs.player.library.registry.componentRenderer import dev.ohs.player.library.registry.layoutRenderer import dev.ohs.player.library.renderer.RenderOptions +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.patient_profile_add_clinical_data +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.patient_profile_back +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.patient_profile_default_name +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.patient_profile_not_found +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -108,9 +111,12 @@ fun PatientProfileScreen(patientId: String, onBack: () -> Unit, onAddClinicalDat remember(registry) { registry.componentRenderer(ViewTypeCS.TelecomItem) } val patient = state?.patient + val defaultPatientName = stringResource(Res.string.patient_profile_default_name) val patientName = - remember(patient) { - listOfNotNull(patient?.givenName, patient?.familyName).joinToString(" ").ifBlank { "Patient" } + remember(patient, defaultPatientName) { + listOfNotNull(patient?.givenName, patient?.familyName).joinToString(" ").ifBlank { + defaultPatientName + } } Scaffold( @@ -121,7 +127,7 @@ fun PatientProfileScreen(patientId: String, onBack: () -> Unit, onAddClinicalDat IconButton(onClick = onBack) { Icon( Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(Res.string.patient_profile_back), tint = MaterialTheme.colorScheme.onPrimary, ) } @@ -135,7 +141,10 @@ fun PatientProfileScreen(patientId: String, onBack: () -> Unit, onAddClinicalDat }, floatingActionButton = { FloatingActionButton(onClick = onAddClinicalData) { - Icon(Icons.Filled.Add, contentDescription = "Add clinical data") + Icon( + Icons.Filled.Add, + contentDescription = stringResource(Res.string.patient_profile_add_clinical_data), + ) } }, ) { padding -> @@ -148,7 +157,7 @@ fun PatientProfileScreen(patientId: String, onBack: () -> Unit, onAddClinicalDat } if (s.patient == null) { Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { - Text("Patient not found") + Text(stringResource(Res.string.patient_profile_not_found)) } return@Scaffold } @@ -158,16 +167,7 @@ fun PatientProfileScreen(patientId: String, onBack: () -> Unit, onAddClinicalDat contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - item(key = "patient_header") { - Card( - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), - ) { - Box(modifier = Modifier.padding(20.dp)) { - headerRenderer.Render(s.patient, RenderOptions()) - } - } - } + item(key = "patient_header") { headerRenderer.Render(s.patient, RenderOptions()) } if (s.allergies.isNotEmpty()) { item(key = "allergies") { diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/questionnaire/QuestionnaireHostScreen.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/questionnaire/QuestionnaireHostScreen.kt index d895a71c..e444c170 100644 --- a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/questionnaire/QuestionnaireHostScreen.kt +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/questionnaire/QuestionnaireHostScreen.kt @@ -18,14 +18,18 @@ package dev.ohs.player.reference.app.feature.questionnaire import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -33,8 +37,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -44,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import dev.ohs.fhir.datacapture.Questionnaire import dev.ohs.fhir.datacapture.QuestionnaireConfig @@ -52,10 +55,15 @@ import dev.ohs.fhir.datacapture.QuestionnaireItemViewFactoryMatchersProvider import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.questionnaire_back +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.questionnaire_close +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.questionnaire_retry +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.questionnaire_title +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf -@OptIn(ExperimentalMaterial3Api::class) @Composable fun QuestionnaireHostScreen( questionnaireId: String, @@ -94,83 +102,109 @@ fun QuestionnaireHostScreen( Scaffold( topBar = { - TopAppBar( - title = { Text(title ?: "Questionnaire") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = MaterialTheme.colorScheme.onPrimary, + Surface( + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Box(modifier = Modifier.fillMaxWidth().statusBarsPadding()) { + Row( + modifier = + Modifier.align(Alignment.Center) + .widthIn(max = 720.dp) + .fillMaxWidth() + .height(64.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.questionnaire_back), + ) + } + Text( + text = title ?: stringResource(Res.string.questionnaire_title), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(horizontal = 4.dp), ) + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(Res.string.questionnaire_close), + ) + } } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primary, - titleContentColor = MaterialTheme.colorScheme.onPrimary, - ), - ) + } + } } ) { padding -> - Column( - modifier = Modifier.fillMaxSize().padding(padding).padding(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.TopCenter, ) { - when (val state = uiState) { - is QuestionnaireHostUiState.Submitted -> - SubmissionBanner(message = state.result.successMessage, isSuccess = true) + Column( + modifier = Modifier.widthIn(max = 720.dp).fillMaxSize().padding(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + when (val state = uiState) { + is QuestionnaireHostUiState.Submitted -> + SubmissionBanner(message = state.result.successMessage, isSuccess = true) - is QuestionnaireHostUiState.Error -> Unit // rendered below, inline with a dismiss action - else -> Unit - } + is QuestionnaireHostUiState.Error -> Unit // rendered below, inline with a dismiss action + else -> Unit + } - Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { - when (val state = uiState) { - is QuestionnaireHostUiState.Loading -> CircularProgressIndicator() + Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { + when (val state = uiState) { + is QuestionnaireHostUiState.Loading -> CircularProgressIndicator() + + is QuestionnaireHostUiState.Error -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + ) + TextButton(onClick = viewModel::load) { + Text(stringResource(Res.string.questionnaire_retry)) + } + } + } - is QuestionnaireHostUiState.Error -> { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = state.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(16.dp), + is QuestionnaireHostUiState.Ready, + is QuestionnaireHostUiState.Submitting -> { + val questionnaireJson = + when (state) { + is QuestionnaireHostUiState.Ready -> state.questionnaireJson + is QuestionnaireHostUiState.Submitting -> state.questionnaireJson + else -> "" + } + Questionnaire( + questionnaireJson = questionnaireJson, + questionnaireLaunchContextMap = emptyMap(), + config = + QuestionnaireConfig( + showReviewPage = true, + showReviewPageFirst = false, + isReadOnly = false, + showCancelButton = false, + ), + onSubmit = { getResponse -> + coroutineScope.launch { viewModel.onSubmit(getResponse()) } + }, + matchersProvider = viewItemMatchersProvider, + onCancel = {}, ) - TextButton(onClick = viewModel::load) { Text("Retry") } } - } - is QuestionnaireHostUiState.Ready, - is QuestionnaireHostUiState.Submitting -> { - val questionnaireJson = - when (state) { - is QuestionnaireHostUiState.Ready -> state.questionnaireJson - is QuestionnaireHostUiState.Submitting -> state.questionnaireJson - else -> "" - } - Questionnaire( - questionnaireJson = questionnaireJson, - questionnaireLaunchContextMap = emptyMap(), - config = - QuestionnaireConfig( - showReviewPage = true, - showReviewPageFirst = false, - isReadOnly = false, - showCancelButton = false, - ), - onSubmit = { getResponse -> - coroutineScope.launch { viewModel.onSubmit(getResponse()) } - }, - matchersProvider = viewItemMatchersProvider, - onCancel = {}, - ) + is QuestionnaireHostUiState.Submitted -> Unit } - - is QuestionnaireHostUiState.Submitted -> Unit } } } diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/sync/InitialSyncScreen.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/sync/InitialSyncScreen.kt new file mode 100644 index 00000000..55dba6f3 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/sync/InitialSyncScreen.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.feature.sync + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.initial_sync_continue +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.initial_sync_failed_body +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.initial_sync_failed_title +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.initial_sync_retry +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.initial_sync_subtitle +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.initial_sync_title +import org.jetbrains.compose.resources.stringResource + +/** + * Full-screen blocking gate shown between login and Home: a progress state while checking/syncing, + * and a failure state offering Retry or Continue without syncing. Never shown for [Passed] — the + * caller ([dev.ohs.player.reference.app.App]) switches to the real content on that state instead. + */ +@Composable +fun InitialSyncScreen( + state: InitialSyncGateState, + onRetry: () -> Unit, + onContinueAnyway: () -> Unit, +) { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + when (state) { + InitialSyncGateState.Checking, + InitialSyncGateState.Syncing -> SyncingContent() + InitialSyncGateState.Failed -> FailedContent(onRetry, onContinueAnyway) + InitialSyncGateState.Passed -> Unit + } + } +} + +@Composable +private fun SyncingContent() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator() + Text( + text = stringResource(Res.string.initial_sync_title), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(Res.string.initial_sync_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun FailedContent(onRetry: () -> Unit, onContinueAnyway: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(48.dp), + ) + Text( + text = stringResource(Res.string.initial_sync_failed_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(Res.string.initial_sync_failed_body), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Button(onClick = onRetry, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(Res.string.initial_sync_retry)) + } + TextButton(onClick = onContinueAnyway, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(Res.string.initial_sync_continue)) + } + } +} diff --git a/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/sync/InitialSyncViewModel.kt b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/sync/InitialSyncViewModel.kt new file mode 100644 index 00000000..5be80b83 --- /dev/null +++ b/ohs-player-reference-app/src/commonMain/kotlin/dev/ohs/player/reference/app/feature/sync/InitialSyncViewModel.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.feature.sync + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.ohs.fhir.engine.sync.SyncJobStatus +import dev.ohs.player.reference.app.data.sync.InitialSyncStore +import dev.ohs.player.reference.app.data.sync.SyncManager +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** State the blocking initial-sync gate renders against. */ +sealed interface InitialSyncGateState { + data object Checking : InitialSyncGateState + + data object Syncing : InitialSyncGateState + + data object Failed : InitialSyncGateState + + data object Passed : InitialSyncGateState +} + +/** + * Gates a freshly-authenticated session behind a one-time blocking sync until a first sync has + * succeeded (tracked by [InitialSyncStore], not by whether the local database happens to hold any + * resources — a legitimately-empty account is still "synced"). [start] deliberately has no "already + * ran" guard — it must fully re-run its check every time it's called, because Koin can hand back + * the same ViewModel instance across a logout → login round-trip (the top-level `App()` composables + * aren't scoped per auth session), so a fresh login must always be re-evaluated. + */ +class InitialSyncViewModel( + private val syncManager: SyncManager, + private val initialSyncStore: InitialSyncStore, +) : ViewModel() { + + private val _state = MutableStateFlow(InitialSyncGateState.Checking) + val state: StateFlow = _state.asStateFlow() + + fun start(): Job = viewModelScope.launch { runCheck() } + + fun retry(): Job = viewModelScope.launch { runSync() } + + fun continueAnyway(): Job = viewModelScope.launch { passGate() } + + private suspend fun runCheck() { + _state.value = InitialSyncGateState.Checking + if (initialSyncStore.isComplete()) { + passGate() + } else { + runSync() + } + } + + private suspend fun runSync() { + _state.value = InitialSyncGateState.Syncing + val result = + try { + syncManager.syncNow() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + SyncJobStatus.Failed() + } + when (result) { + is SyncJobStatus.Succeeded -> { + initialSyncStore.markComplete() + passGate() + } + else -> _state.value = InitialSyncGateState.Failed + } + } + + private suspend fun passGate() { + syncManager.startPeriodicSync() + _state.value = InitialSyncGateState.Passed + } +} diff --git a/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/AuthModelsTest.kt b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/AuthModelsTest.kt new file mode 100644 index 00000000..809416af --- /dev/null +++ b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/AuthModelsTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.serialization.json.Json + +class AuthModelsTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun discoveryDocument_parsesProviderEndpoints() { + val body = + """ + { + "issuer": "https://idp.example.org", + "authorization_endpoint": "https://idp.example.org/oauth/v2/authorize", + "token_endpoint": "https://idp.example.org/oauth/v2/token", + "userinfo_endpoint": "https://idp.example.org/oidc/v1/userinfo", + "end_session_endpoint": "https://idp.example.org/oidc/v1/end_session" + } + """ + .trimIndent() + + val doc = json.decodeFromString(body) + + assertEquals("https://idp.example.org/oauth/v2/authorize", doc.authorizationEndpoint) + assertEquals("https://idp.example.org/oauth/v2/token", doc.tokenEndpoint) + assertEquals("https://idp.example.org/oidc/v1/userinfo", doc.userInfoEndpoint) + assertEquals("https://idp.example.org/oidc/v1/end_session", doc.endSessionEndpoint) + } + + @Test + fun discoveryDocument_toleratesMissingOptionalEndpoints() { + val body = + """ + { + "authorization_endpoint": "https://idp.example.org/authorize", + "token_endpoint": "https://idp.example.org/token" + } + """ + .trimIndent() + + val doc = json.decodeFromString(body) + + assertEquals("", doc.userInfoEndpoint) + assertEquals("", doc.endSessionEndpoint) + } + + @Test + fun session_isAccessTokenExpired_trueOncePastExpiryMinusSkew() { + val session = + Session( + accessToken = "a", + refreshToken = "r", + idToken = null, + expiresInSeconds = 300, + obtainedAtEpochSeconds = 1_000, + user = UserInfo(), + ) + + assertEquals(false, session.isAccessTokenExpired(nowEpochSeconds = 1_269)) // 1000+300-30=1270 + assertEquals(true, session.isAccessTokenExpired(nowEpochSeconds = 1_270)) + } +} diff --git a/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/AuthServiceTest.kt b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/AuthServiceTest.kt new file mode 100644 index 00000000..9a0296b8 --- /dev/null +++ b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/AuthServiceTest.kt @@ -0,0 +1,251 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.HttpResponseData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json + +/** + * Verifies the offline-safe session logic: a logout happens ONLY on a definitive provider rejection + * (4xx), never on a network/offline failure, which must keep the local session. + */ +class AuthServiceTest { + + private class FakeSessionStore(initial: Session?) : SessionStore { + private val _session = MutableStateFlow(initial) + override val session: StateFlow = _session.asStateFlow() + var cleared = false + private set + + private var pending: PendingAuth? = null + + override suspend fun load(): Session? = _session.value + + override suspend fun save(session: Session) { + _session.value = session + } + + override suspend fun clear() { + _session.value = null + cleared = true + } + + override suspend fun savePending(pending: PendingAuth) { + this.pending = pending + } + + override suspend fun takePending(): PendingAuth? = pending.also { pending = null } + } + + private class FakeLauncher( + override val redirectUri: String, + private val onAuthorize: suspend (String) -> AuthResult, + ) : AuthorizationLauncherApi { + override suspend fun authorize(authUrl: String): AuthResult = onAuthorize(authUrl) + + override fun consumeRedirectCallback(): String? = null + } + + private val jsonHeaders = headersOf(HttpHeaders.ContentType, "application/json") + private val discoveryBody = + """ + { + "authorization_endpoint": "https://idp.example.org/authorize", + "token_endpoint": "https://idp.example.org/token", + "userinfo_endpoint": "https://idp.example.org/userinfo", + "end_session_endpoint": "https://idp.example.org/logout" + } + """ + .trimIndent() + private val tokenBody = + """{"access_token":"new-access","refresh_token":"new-refresh","expires_in":300,"token_type":"Bearer"}""" + private val invalidGrantBody = + """{"error":"invalid_grant","error_description":"Token is not active"}""" + + private fun session(expired: Boolean, refreshToken: String? = "refresh-token") = + Session( + accessToken = "old-access", + refreshToken = refreshToken, + idToken = null, + expiresInSeconds = if (expired) 0L else 10_000_000_000L, + obtainedAtEpochSeconds = 0L, + user = UserInfo(), + ) + + private fun apiWith( + onToken: MockRequestHandleScope.() -> HttpResponseData = { + respond(tokenBody, HttpStatusCode.OK, jsonHeaders) + }, + onUserInfo: MockRequestHandleScope.() -> HttpResponseData = { + respond("{}", HttpStatusCode.OK, jsonHeaders) + }, + ): OidcAuthApi { + val engine = MockEngine { request -> + val path = request.url.encodedPath + when { + path.endsWith("openid-configuration") -> + respond(discoveryBody, HttpStatusCode.OK, jsonHeaders) + path.endsWith("/token") -> onToken() + path.endsWith("/userinfo") -> onUserInfo() + else -> respond("not found", HttpStatusCode.NotFound) + } + } + val client = + HttpClient(engine) { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + isLenient = true + } + ) + } + } + return OidcAuthApi(OAuthConfig("https://idp.example.org", "client", "openid"), client) + } + + private fun service(store: SessionStore, api: OidcAuthApi) = + AuthService(OAuthConfig("https://idp.example.org", "client", "openid"), store, api) + + @Test + fun refreshSuccess_updatesSession() = runTest { + val store = FakeSessionStore(session(expired = true)) + val result = service(store, apiWith()).ensureFreshSession() + + assertNotNull(result) + assertEquals("new-access", result.accessToken) + assertEquals("new-access", store.session.value?.accessToken) + assertFalse(store.cleared) + } + + @Test + fun refreshRejectedByProvider_logsOut() = runTest { + val store = FakeSessionStore(session(expired = true)) + val api = + apiWith(onToken = { respond(invalidGrantBody, HttpStatusCode.BadRequest, jsonHeaders) }) + + val result = service(store, api).ensureFreshSession() + + assertNull(result) + assertTrue(store.cleared, "a definitive provider rejection must log the user out") + assertNull(store.session.value) + } + + @Test + fun refreshOffline_keepsSession() = runTest { + val original = session(expired = true) + val store = FakeSessionStore(original) + val api = apiWith(onToken = { throw RuntimeException("offline") }) + + val result = service(store, api).ensureFreshSession() + + assertEquals(original, result, "a network failure must NOT log the user out") + assertFalse(store.cleared) + } + + @Test + fun validToken_returnsWithoutContactingProvider() = runTest { + val original = session(expired = false) + val store = FakeSessionStore(original) + val api = + apiWith( + onToken = { throw RuntimeException("must not be called") }, + onUserInfo = { throw RuntimeException("must not be called") }, + ) + + assertEquals(original, service(store, api).ensureFreshSession()) + assertFalse(store.cleared) + } + + @Test + fun revalidateRevoked_logsOut() = runTest { + val store = FakeSessionStore(session(expired = false)) + val api = apiWith(onUserInfo = { respond("", HttpStatusCode.Unauthorized) }) + + assertFalse(service(store, api).revalidateSession()) + assertTrue(store.cleared) + } + + @Test + fun revalidateOffline_keepsSession() = runTest { + val store = FakeSessionStore(session(expired = false)) + val api = apiWith(onUserInfo = { throw RuntimeException("offline") }) + + assertTrue(service(store, api).revalidateSession(), "offline probe must not log out") + assertFalse(store.cleared) + } + + @Test + fun login_buildsAuthorizationUrlWithRequiredPkceParamsAndCompletesOnMatchingState() = runTest { + val store = FakeSessionStore(null) + var capturedAuthUrl: String? = null + val fakeLauncher = + FakeLauncher( + redirectUri = "app://callback", + onAuthorize = { authUrl -> + capturedAuthUrl = authUrl + val state = Url(authUrl).parameters["state"].orEmpty() + AuthResult.Success("app://callback?code=abc123&state=$state") + }, + ) + + val outcome = service(store, apiWith()).login(fakeLauncher) + + assertTrue(outcome is LoginOutcome.Authenticated) + val builtUrl = capturedAuthUrl + assertNotNull(builtUrl) + val params = Url(builtUrl).parameters + assertEquals("code", params["response_type"]) + assertEquals("S256", params["code_challenge_method"]) + assertEquals("app://callback", params["redirect_uri"]) + assertNotNull(params["state"]) + assertNotNull(params["code_challenge"]) + } + + @Test + fun login_withMismatchedState_returnsError() = runTest { + val store = FakeSessionStore(null) + val fakeLauncher = + FakeLauncher( + redirectUri = "app://callback", + onAuthorize = { AuthResult.Success("app://callback?code=abc123&state=wrong-state") }, + ) + + val outcome = service(store, apiWith()).login(fakeLauncher) + + assertTrue(outcome is LoginOutcome.Error) + } +} diff --git a/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/AuthViewModelTest.kt b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/AuthViewModelTest.kt new file mode 100644 index 00000000..388ce63a --- /dev/null +++ b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/AuthViewModelTest.kt @@ -0,0 +1,235 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import dev.ohs.player.reference.app.data.sync.FakeSyncManager +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json + +/** + * Drives the real [AuthService] against a [FakeSessionStore] + mocked [OidcAuthApi] (same pattern + * as [AuthServiceTest], one layer up), so [AuthViewModel]'s state transitions are exercised through + * real logic, not a hand-rolled fake of [AuthService] itself. + */ +class AuthViewModelTest { + + private class FakeSessionStore(initial: Session?) : SessionStore { + private val _session = MutableStateFlow(initial) + override val session: StateFlow = _session.asStateFlow() + + override suspend fun load(): Session? = _session.value + + override suspend fun save(session: Session) { + _session.value = session + } + + override suspend fun clear() { + _session.value = null + } + + override suspend fun savePending(pending: PendingAuth) = Unit + + override suspend fun takePending(): PendingAuth? = null + } + + private class FakeLauncher( + override val redirectUri: String, + private val redirectCallback: String? = null, + private val onAuthorize: suspend (String) -> AuthResult, + ) : AuthorizationLauncherApi { + override suspend fun authorize(authUrl: String): AuthResult = onAuthorize(authUrl) + + override fun consumeRedirectCallback(): String? = redirectCallback + } + + private val jsonHeaders = headersOf(HttpHeaders.ContentType, "application/json") + + private fun apiThatNeverGetsCalled(): OidcAuthApi { + val engine = MockEngine { respond("must not be called", HttpStatusCode.InternalServerError) } + val client = + HttpClient(engine) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } } + return OidcAuthApi(OAuthConfig("https://idp.example.org", "client", "openid"), client) + } + + /** + * [AuthService.login] always resolves discovery (to build the authorization URL) before invoking + * the launcher, so a login-path test needs discovery to succeed — only the launcher's own result + * determines the outcome under test. + */ + private fun apiWithWorkingDiscovery(): OidcAuthApi { + val discoveryBody = + """ + { + "authorization_endpoint": "https://idp.example.org/authorize", + "token_endpoint": "https://idp.example.org/token", + "userinfo_endpoint": "https://idp.example.org/userinfo", + "end_session_endpoint": "https://idp.example.org/logout" + } + """ + .trimIndent() + val engine = MockEngine { request -> + if (request.url.encodedPath.endsWith("openid-configuration")) { + respond(discoveryBody, HttpStatusCode.OK, jsonHeaders) + } else { + respond("must not be called", HttpStatusCode.InternalServerError) + } + } + val client = + HttpClient(engine) { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + isLenient = true + } + ) + } + } + return OidcAuthApi(OAuthConfig("https://idp.example.org", "client", "openid"), client) + } + + private fun session() = + Session( + accessToken = "a", + refreshToken = "r", + idToken = null, + expiresInSeconds = 10_000_000_000L, + obtainedAtEpochSeconds = 0, + user = UserInfo(), + ) + + @Test + fun bootstrap_withNoStoredSession_goesUnauthenticated() = runTest { + val service = + AuthService( + OAuthConfig("https://idp.example.org", "c", "openid"), + FakeSessionStore(null), + apiThatNeverGetsCalled(), + ) + val viewModel = AuthViewModel(service, FakeSyncManager()) + val launcher = FakeLauncher("app://callback") { AuthResult.Canceled } + + viewModel.bootstrapForTest(launcher) + + assertEquals(AuthState.Unauthenticated, viewModel.state.value) + } + + @Test + fun bootstrap_withValidStoredSession_goesAuthenticatedWithoutContactingProvider() = runTest { + val service = + AuthService( + OAuthConfig("https://idp.example.org", "c", "openid"), + FakeSessionStore(session()), + apiThatNeverGetsCalled(), + ) + val viewModel = AuthViewModel(service, FakeSyncManager()) + val launcher = FakeLauncher("app://callback") { AuthResult.Canceled } + + viewModel.bootstrapForTest(launcher) + + assertIs(viewModel.state.value) + } + + @Test + fun bootstrap_withRedirectCallback_completesRedirectLoginBranch() = runTest { + val service = + AuthService( + OAuthConfig("https://idp.example.org", "c", "openid"), + FakeSessionStore(null), + apiThatNeverGetsCalled(), + ) + val viewModel = AuthViewModel(service, FakeSyncManager()) + // A callback with no matching pending auth is rejected as a CSRF mismatch — proving bootstrap + // ran the redirect-completion branch rather than only ensureFreshSession(). + val launcher = + FakeLauncher("app://callback", redirectCallback = "app://callback?code=abc&state=xyz") { + AuthResult.Canceled + } + + viewModel.bootstrapForTest(launcher) + + assertEquals("Invalid state — possible CSRF, please try again", viewModel.error.value) + } + + @Test + fun login_onError_setsErrorAndClearsSigningIn() = runTest { + val service = + AuthService( + OAuthConfig("https://idp.example.org", "c", "openid"), + FakeSessionStore(null), + apiWithWorkingDiscovery(), + ) + val viewModel = AuthViewModel(service, FakeSyncManager()) + val launcher = FakeLauncher("app://callback") { AuthResult.Failure("network down") } + + viewModel.loginForTest(launcher) + + assertEquals("network down", viewModel.error.value) + assertEquals(false, viewModel.signingIn.value) + } + + @Test + fun clearError_removesTheErrorMessage() = runTest { + val service = + AuthService( + OAuthConfig("https://idp.example.org", "c", "openid"), + FakeSessionStore(null), + apiWithWorkingDiscovery(), + ) + val viewModel = AuthViewModel(service, FakeSyncManager()) + val launcher = FakeLauncher("app://callback") { AuthResult.Failure("network down") } + viewModel.loginForTest(launcher) + + viewModel.clearError() + + assertNull(viewModel.error.value) + } + + @Test + fun logout_clearsSessionAndStopsSync() = runTest { + val store = FakeSessionStore(session()) + val service = + AuthService( + OAuthConfig("https://idp.example.org", "c", "openid"), + store, + apiThatNeverGetsCalled(), + ) + val syncManager = FakeSyncManager() + val viewModel = AuthViewModel(service, syncManager) + + viewModel.logoutForTest() + + assertEquals(AuthState.Unauthenticated, viewModel.state.value) + assertNull(store.session.value) + assertEquals(1, syncManager.cancelSyncNowCount) + assertEquals(1, syncManager.cancelPeriodicCount) + } +} diff --git a/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/OAuthConfigTest.kt b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/OAuthConfigTest.kt new file mode 100644 index 00000000..97c805b3 --- /dev/null +++ b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/OAuthConfigTest.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import kotlin.test.Test +import kotlin.test.assertEquals + +class OAuthConfigTest { + + @Test + fun discoveryUrl_appendsWellKnown_andTrimsTrailingSlash() { + val keycloak = + OAuthConfig( + issuer = "https://keycloak.example.org/realms/ohs-player/", + clientId = "ohs-player-reference-app", + scopes = "openid profile", + ) + assertEquals( + "https://keycloak.example.org/realms/ohs-player/.well-known/openid-configuration", + keycloak.discoveryUrl, + ) + + val zitadel = keycloak.copy(issuer = "https://my-instance.zitadel.cloud") + assertEquals( + "https://my-instance.zitadel.cloud/.well-known/openid-configuration", + zitadel.discoveryUrl, + ) + } +} diff --git a/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/OidcAuthApiTest.kt b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/OidcAuthApiTest.kt new file mode 100644 index 00000000..d937c6a0 --- /dev/null +++ b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/OidcAuthApiTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.HttpResponseData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json + +class OidcAuthApiTest { + + private val jsonHeaders = headersOf(HttpHeaders.ContentType, "application/json") + private val discoveryBody = + """ + { + "authorization_endpoint": "https://idp.example.org/authorize", + "token_endpoint": "https://idp.example.org/token", + "userinfo_endpoint": "https://idp.example.org/userinfo", + "end_session_endpoint": "https://idp.example.org/logout" + } + """ + .trimIndent() + + private fun apiWith( + onToken: MockRequestHandleScope.() -> HttpResponseData = { + respond( + """{"access_token":"a","refresh_token":"r","expires_in":300,"token_type":"Bearer"}""", + HttpStatusCode.OK, + jsonHeaders, + ) + }, + onUserInfo: MockRequestHandleScope.() -> HttpResponseData = { + respond("{}", HttpStatusCode.OK, jsonHeaders) + }, + ): OidcAuthApi { + val engine = MockEngine { request -> + val path = request.url.encodedPath + when { + path.endsWith("openid-configuration") -> + respond(discoveryBody, HttpStatusCode.OK, jsonHeaders) + path.endsWith("/token") -> onToken() + path.endsWith("/userinfo") -> onUserInfo() + else -> respond("not found", HttpStatusCode.NotFound) + } + } + val client = + HttpClient(engine) { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + isLenient = true + } + ) + } + } + return OidcAuthApi(OAuthConfig("https://idp.example.org", "client", "openid"), client) + } + + @Test + fun exchangeCode_returnsTokensFromMockedEndpoint() = runTest { + val tokens = apiWith().exchangeCode("code123", "verifier", "app://callback") + assertEquals("a", tokens.accessToken) + assertEquals("r", tokens.refreshToken) + } + + @Test + fun exchangeCode_onProviderError_throwsAuthExceptionWithMessage() = runTest { + val api = + apiWith( + onToken = { + respond( + """{"error":"invalid_grant","error_description":"Token is not active"}""", + HttpStatusCode.BadRequest, + jsonHeaders, + ) + } + ) + val error = assertFailsWith { api.exchangeCode("bad", "v", "app://callback") } + assertEquals("invalid_grant: Token is not active", error.message) + } + + @Test + fun sessionStatus_mapsHttpResultsToSessionStatus() = runTest { + assertEquals( + SessionStatus.Active, + apiWith(onUserInfo = { respond("{}", HttpStatusCode.OK, jsonHeaders) }).sessionStatus("t"), + ) + assertEquals( + SessionStatus.Revoked, + apiWith(onUserInfo = { respond("", HttpStatusCode.Unauthorized) }).sessionStatus("t"), + ) + assertEquals( + SessionStatus.Unknown, + apiWith(onUserInfo = { respond("", HttpStatusCode.InternalServerError) }).sessionStatus("t"), + ) + assertEquals( + SessionStatus.Unknown, + apiWith(onUserInfo = { throw RuntimeException("offline") }).sessionStatus("t"), + ) + } + + @Test + fun fetchUserInfo_parsesUserFields() = runTest { + val api = + apiWith( + onUserInfo = { + respond( + """{"sub":"u1","preferred_username":"jdoe","name":"Jane Doe","email":"j@example.org"}""", + HttpStatusCode.OK, + jsonHeaders, + ) + } + ) + val user = api.fetchUserInfo("token") + assertEquals("jdoe", user.username) + assertEquals("j@example.org", user.email) + } +} diff --git a/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/PkceTest.kt b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/PkceTest.kt new file mode 100644 index 00000000..7b01f06d --- /dev/null +++ b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/auth/PkceTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import org.kotlincrypto.hash.sha2.SHA256 + +class PkceTest { + + @OptIn(ExperimentalEncodingApi::class) + @Test + fun pkceChallenge_isBase64UrlSha256OfVerifier() { + val pair = Pkce.generate() + + assertEquals("S256", pair.method) + assertTrue(pair.verifier.length in 43..128, "verifier length ${pair.verifier.length}") + + val expected = + Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT) + .encode(SHA256().digest(pair.verifier.encodeToByteArray())) + assertEquals(expected, pair.challenge, "challenge must be S256 of verifier") + + listOf(pair.verifier, pair.challenge).forEach { + assertFalse(it.contains('+') || it.contains('/') || it.contains('='), "not url-safe: $it") + } + } + + @Test + fun verifiersAndStates_areUnique() { + assertNotEquals(Pkce.generate().verifier, Pkce.generate().verifier) + assertNotEquals(Pkce.randomState(), Pkce.randomState()) + } +} diff --git a/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/data/repository/InMemorySampleFhirRepository.kt b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/data/repository/InMemorySampleFhirRepository.kt index 2021fa46..54ff3c41 100644 --- a/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/data/repository/InMemorySampleFhirRepository.kt +++ b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/data/repository/InMemorySampleFhirRepository.kt @@ -15,9 +15,9 @@ */ package dev.ohs.player.reference.app.data.repository +import dev.ohs.fhir.engine.resourceType import dev.ohs.fhir.model.r4.Bundle import dev.ohs.fhir.model.r4.Resource -import dev.ohs.fhir.resourceType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/data/sync/FakeSyncManager.kt b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/data/sync/FakeSyncManager.kt new file mode 100644 index 00000000..5c777ad2 --- /dev/null +++ b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/data/sync/FakeSyncManager.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import dev.ohs.fhir.engine.sync.SyncJobStatus + +/** In-memory [SyncManager] that counts calls, so ViewModel tests can assert sync behavior. */ +internal class FakeSyncManager( + private val syncResult: suspend () -> SyncJobStatus = { SyncJobStatus.Succeeded() } +) : SyncManager { + var syncNowCount = 0 + private set + + var cancelSyncNowCount = 0 + private set + + var startPeriodicCount = 0 + private set + + var cancelPeriodicCount = 0 + private set + + override suspend fun syncNow(): SyncJobStatus { + syncNowCount++ + return syncResult() + } + + override suspend fun cancelSyncNow() { + cancelSyncNowCount++ + } + + override suspend fun startPeriodicSync() { + startPeriodicCount++ + } + + override suspend fun cancelPeriodicSync() { + cancelPeriodicCount++ + } +} diff --git a/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/feature/patient/profile/PatientProfileScreenTest.kt b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/feature/patient/profile/PatientProfileScreenTest.kt index 5ef3a036..b484ee14 100644 --- a/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/feature/patient/profile/PatientProfileScreenTest.kt +++ b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/feature/patient/profile/PatientProfileScreenTest.kt @@ -70,9 +70,9 @@ class PatientProfileScreenTest { val scrollable = onNode(hasScrollAction()) listOf("Amina Diallo", "Allergies", "Medications", "Conditions", "Immunizations").forEach { text -> - scrollable.performScrollToNode(hasText(text)) + scrollable.performScrollToNode(hasText(text, ignoreCase = true)) assertTrue( - onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty(), + onAllNodesWithText(text, ignoreCase = true).fetchSemanticsNodes().isNotEmpty(), "Expected to find '$text' after scrolling the patient profile", ) } diff --git a/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/feature/sync/InitialSyncViewModelTest.kt b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/feature/sync/InitialSyncViewModelTest.kt new file mode 100644 index 00000000..6ba24702 --- /dev/null +++ b/ohs-player-reference-app/src/commonTest/kotlin/dev/ohs/player/reference/app/feature/sync/InitialSyncViewModelTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.feature.sync + +import dev.ohs.fhir.engine.sync.SyncJobStatus +import dev.ohs.player.reference.app.data.sync.FakeSyncManager +import dev.ohs.player.reference.app.data.sync.InitialSyncStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +private class FakeInitialSyncStore(private var complete: Boolean = false) : InitialSyncStore { + override suspend fun isComplete(): Boolean = complete + + override suspend fun markComplete() { + complete = true + } +} + +class InitialSyncViewModelTest { + + @Test + fun start_whenAlreadyComplete_passesWithoutSyncing() = runTest { + val syncManager = FakeSyncManager() + val viewModel = InitialSyncViewModel(syncManager, FakeInitialSyncStore(complete = true)) + + viewModel.start().join() + + assertEquals(InitialSyncGateState.Passed, viewModel.state.value) + assertEquals(0, syncManager.syncNowCount) + assertEquals(1, syncManager.startPeriodicCount) + } + + @Test + fun start_whenNotComplete_syncsThenPassesAndMarksComplete() = runTest { + val syncManager = FakeSyncManager() + val store = FakeInitialSyncStore() + val viewModel = InitialSyncViewModel(syncManager, store) + + viewModel.start().join() + + assertEquals(InitialSyncGateState.Passed, viewModel.state.value) + assertEquals(1, syncManager.syncNowCount) + assertEquals(1, syncManager.startPeriodicCount) + assertTrue(store.isComplete()) + } + + @Test + fun start_whenSyncFails_goesFailedAndStaysIncomplete() = runTest { + val syncManager = FakeSyncManager { SyncJobStatus.Failed() } + val store = FakeInitialSyncStore() + val viewModel = InitialSyncViewModel(syncManager, store) + + viewModel.start().join() + + assertIs(viewModel.state.value) + assertEquals(0, syncManager.startPeriodicCount) + assertFalse(store.isComplete()) + } + + @Test + fun retry_afterFailure_syncsAgainAndMarksComplete() = runTest { + var shouldFail = true + val syncManager = FakeSyncManager { + if (shouldFail) SyncJobStatus.Failed() else SyncJobStatus.Succeeded() + } + val store = FakeInitialSyncStore() + val viewModel = InitialSyncViewModel(syncManager, store) + viewModel.start().join() + assertIs(viewModel.state.value) + + shouldFail = false + viewModel.retry().join() + + assertEquals(InitialSyncGateState.Passed, viewModel.state.value) + assertEquals(2, syncManager.syncNowCount) + assertEquals(1, syncManager.startPeriodicCount) + assertTrue(store.isComplete()) + } + + @Test + fun continueAnyway_passesButDoesNotMarkComplete() = runTest { + val syncManager = FakeSyncManager { SyncJobStatus.Failed() } + val store = FakeInitialSyncStore() + val viewModel = InitialSyncViewModel(syncManager, store) + viewModel.start().join() + assertIs(viewModel.state.value) + + viewModel.continueAnyway().join() + + assertEquals(InitialSyncGateState.Passed, viewModel.state.value) + assertEquals(1, syncManager.startPeriodicCount) + assertFalse(store.isComplete()) + } +} diff --git a/ohs-player-reference-app/src/foregroundSyncMain/kotlin/dev/ohs/player/reference/app/data/sync/ForegroundSyncManager.kt b/ohs-player-reference-app/src/foregroundSyncMain/kotlin/dev/ohs/player/reference/app/data/sync/ForegroundSyncManager.kt new file mode 100644 index 00000000..aaf69b27 --- /dev/null +++ b/ohs-player-reference-app/src/foregroundSyncMain/kotlin/dev/ohs/player/reference/app/data/sync/ForegroundSyncManager.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import dev.ohs.fhir.engine.FhirEngineProvider +import dev.ohs.fhir.engine.sync.CurrentSyncJobStatus +import dev.ohs.fhir.engine.sync.SyncJobStatus +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.flow.first + +/** + * JVM/web [SyncManager]: schedules sync through the shared foreground [Sync] scheduler. Desktop and + * web only sync while their host process is alive (see [Sync]'s docs for why), with each periodic + * cycle gated on [isNetworkConnected]. + */ +class ForegroundSyncManager : SyncManager { + override suspend fun syncNow(): SyncJobStatus { + val terminalStatus = + Sync.oneTimeSync(taskFactory = { AppFhirSyncTask(FhirEngineProvider.getInstance()) }).first { + it is CurrentSyncJobStatus.Succeeded || + it is CurrentSyncJobStatus.Failed || + it is CurrentSyncJobStatus.Cancelled + } + return when (terminalStatus) { + is CurrentSyncJobStatus.Succeeded -> SyncJobStatus.Succeeded() + else -> SyncJobStatus.Failed() + } + } + + override suspend fun cancelSyncNow() { + Sync.cancelOneTimeSync() + } + + override suspend fun startPeriodicSync() { + Sync.periodicSync( + taskFactory = { AppFhirSyncTask(FhirEngineProvider.getInstance()) }, + repeatInterval = 15.minutes, + ) + } + + override suspend fun cancelPeriodicSync() { + Sync.cancelPeriodicSync() + } +} diff --git a/ohs-player-reference-app/src/foregroundSyncMain/kotlin/dev/ohs/player/reference/app/data/sync/Sync.kt b/ohs-player-reference-app/src/foregroundSyncMain/kotlin/dev/ohs/player/reference/app/data/sync/Sync.kt new file mode 100644 index 00000000..caff42a5 --- /dev/null +++ b/ohs-player-reference-app/src/foregroundSyncMain/kotlin/dev/ohs/player/reference/app/data/sync/Sync.kt @@ -0,0 +1,284 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import co.touchlab.kermit.Logger +import dev.ohs.fhir.engine.FhirEngineProvider +import dev.ohs.fhir.engine.sync.BackoffPolicy +import dev.ohs.fhir.engine.sync.CurrentSyncJobStatus +import dev.ohs.fhir.engine.sync.FhirDataStore +import dev.ohs.fhir.engine.sync.FhirSyncTask +import dev.ohs.fhir.engine.sync.RetryConfiguration +import dev.ohs.fhir.engine.sync.SyncJobStatus +import dev.ohs.fhir.engine.sync.defaultRetryConfiguration +import dev.ohs.fhir.engine.sync.runSync +import dev.ohs.fhir.engine.sync.syncDispatcher +import dev.ohs.player.reference.app.data.DataChangeSignal +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull + +/** Supplies each platform's best-effort "is the device online" check for [Sync.periodicSync]. */ +internal expect fun isNetworkConnected(): Boolean + +/** + * Foreground-only sync scheduler shared by Desktop (JVM) and web (js, wasmJs) — neither platform + * has a native OS background scheduler (no WorkManager, no BGTaskScheduler), so sync only runs + * while the host process (JVM process / browser tab) stays alive. Mirrors kotlin-fhir-engine's + * engine-app `Sync` object. + */ +internal object Sync { + private val scope = CoroutineScope(SupervisorJob() + syncDispatcher) + private val mutex = Mutex() + private val activeSyncs = mutableMapOf() + private val activePeriodicJobs = mutableMapOf() + private val fhirDataStore: FhirDataStore by lazy { FhirEngineProvider.getFhirDataStore() } + + /** + * Executes a one-time sync using [FhirSyncTask] instances created by [taskFactory]. + * + * If a one-time sync for [T] is already active, the existing [Flow] is returned immediately + * without starting a new job. + * + * @param taskFactory Creates a fresh [FhirSyncTask] for each attempt (including retries). + * @param retryConfiguration Retry policy on failure, or null to disable retries. + * @param syncTimeout Maximum duration for a single sync attempt. If exceeded, the attempt is + * treated as failed and subject to retry. `null` means no timeout. + * @return A [Flow] of [CurrentSyncJobStatus] tracking the full sync lifecycle. + */ + suspend inline fun oneTimeSync( + noinline taskFactory: () -> T, + retryConfiguration: RetryConfiguration? = defaultRetryConfiguration, + syncTimeout: Duration? = null, + ): Flow { + val uniqueWorkName = "${T::class.simpleName}-oneTimeSync" + return runOneTimeSync(uniqueWorkName, taskFactory, retryConfiguration, syncTimeout) + } + + /** Cancels an active one-time sync for [T]. No-op if none is active. */ + suspend inline fun cancelOneTimeSync() { + cancelSync("${T::class.simpleName}-oneTimeSync") + } + + /** + * Schedules a recurring foreground sync using [FhirSyncTask] instances created by [taskFactory]. + * Safe to call repeatedly — a cycle already running for [T] is left alone, never duplicated. + * Skips (not fails/retries) any cycle where [isNetworkConnected] returns false. + * + * @param repeatInterval Delay between the end of one cycle and the start of the next. + * @param retryConfiguration Retry policy applied within each cycle, or null to disable retries. + */ + suspend inline fun periodicSync( + noinline taskFactory: () -> T, + repeatInterval: Duration = 15.minutes, + retryConfiguration: RetryConfiguration? = defaultRetryConfiguration, + ) { + runPeriodicSync( + "${T::class.simpleName}-periodicSync", + taskFactory, + repeatInterval, + retryConfiguration, + ) + } + + /** Cancels the recurring periodic sync for [T]. No-op if none is active. */ + suspend inline fun cancelPeriodicSync() { + val job = mutex.withLock { activePeriodicJobs.remove("${T::class.simpleName}-periodicSync") } + job?.cancel() + } + + suspend fun runOneTimeSync( + uniqueWorkName: String, + taskFactory: () -> FhirSyncTask, + retryConfiguration: RetryConfiguration?, + syncTimeout: Duration? = null, + ): Flow { + mutex + .withLock { activeSyncs[uniqueWorkName] } + ?.takeIf { it.job.isActive } + ?.let { + return it.progressChannel + } + + val statusFlow = MutableSharedFlow(replay = 1) + storeUniqueWorkNameInDataStore(fhirDataStore, uniqueWorkName) + + statusFlow.emit(CurrentSyncJobStatus.Enqueued) + + val job = + scope.launch { + val lastResult = + runAttemptsWithRetry(taskFactory, uniqueWorkName, retryConfiguration, syncTimeout) { + statusFlow.emit(it) + } + when (lastResult) { + is SyncJobStatus.Succeeded -> + statusFlow.emit(CurrentSyncJobStatus.Succeeded(lastResult.timestamp)) + else -> + statusFlow.emit( + CurrentSyncJobStatus.Failed( + (lastResult as? SyncJobStatus.Failed)?.timestamp ?: Clock.System.now() + ) + ) + } + removeUniqueWorkNameInDataStore(fhirDataStore, uniqueWorkName) + mutex.withLock { activeSyncs.remove(uniqueWorkName) } + } + + mutex.withLock { activeSyncs[uniqueWorkName] = SyncHandle(job, statusFlow) } + return statusFlow + } + + suspend fun runPeriodicSync( + uniqueWorkName: String, + taskFactory: () -> FhirSyncTask, + repeatInterval: Duration, + retryConfiguration: RetryConfiguration?, + ) { + mutex + .withLock { activePeriodicJobs[uniqueWorkName] } + ?.takeIf { it.isActive } + ?.let { + return + } + + val job = + scope.launch { + while (true) { + if (isNetworkConnected()) { + runAttemptsWithRetry( + taskFactory, + uniqueWorkName, + retryConfiguration, + syncTimeout = null, + ) {} + } else { + Logger.d { "Periodic sync cycle skipped for $uniqueWorkName — offline" } + } + delay(repeatInterval) + } + } + mutex.withLock { activePeriodicJobs[uniqueWorkName] = job } + } + + suspend fun cancelSync(uniqueWorkName: String) { + val handle = mutex.withLock { activeSyncs[uniqueWorkName] } + if (handle == null || !handle.job.isActive) { + Logger.w { "No active sync found for: $uniqueWorkName" } + return + } + handle.progressChannel.emit(CurrentSyncJobStatus.Cancelled) + handle.job.cancel() + mutex.withLock { activeSyncs.remove(uniqueWorkName) } + removeUniqueWorkNameInDataStore(fhirDataStore, uniqueWorkName) + } + + /** Runs [taskFactory]'s sync with retry, reporting each intermediate status via [onStatus]. */ + private suspend fun runAttemptsWithRetry( + taskFactory: () -> FhirSyncTask, + uniqueWorkName: String, + retryConfiguration: RetryConfiguration?, + syncTimeout: Duration?, + onStatus: suspend (CurrentSyncJobStatus) -> Unit, + ): SyncJobStatus { + val maxRetries = retryConfiguration?.maxRetries ?: 0 + var attempt = 0 + var lastResult: SyncJobStatus = SyncJobStatus.Failed() + + while (attempt <= maxRetries) { + if (attempt > 0) { + delay(computeBackoffDelayMillis(retryConfiguration!!, attempt - 1).milliseconds) + } + onStatus(CurrentSyncJobStatus.Running(SyncJobStatus.Started())) + lastResult = + try { + runSyncWithTimeout(taskFactory(), uniqueWorkName, syncTimeout) { syncJobStatus -> + onStatus(CurrentSyncJobStatus.Running(syncJobStatus)) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.e(e) { "Sync failed: ${e.message}" } + SyncJobStatus.Failed() + } + if (lastResult is SyncJobStatus.Succeeded) break + attempt++ + } + if (lastResult is SyncJobStatus.Succeeded) DataChangeSignal.notifyChanged() + return lastResult + } + + private suspend fun runSyncWithTimeout( + task: FhirSyncTask, + taskName: String?, + syncTimeout: Duration?, + onProgress: suspend (SyncJobStatus) -> Unit, + ): SyncJobStatus { + val call = suspend { task.runSync(taskName = taskName, onProgress = onProgress) } + return if (syncTimeout != null) { + withTimeoutOrNull(syncTimeout) { call() } + ?: run { + Logger.w { "Sync timed out after $syncTimeout" } + SyncJobStatus.Failed() + } + } else { + call() + } + } + + private suspend fun storeUniqueWorkNameInDataStore( + fhirDataStore: FhirDataStore, + uniqueWorkName: String, + ) { + if (fhirDataStore.fetchUniqueWorkName(uniqueWorkName) == null) { + fhirDataStore.storeUniqueWorkName(key = uniqueWorkName, value = uniqueWorkName) + } + } + + private suspend fun removeUniqueWorkNameInDataStore( + fhirDataStore: FhirDataStore, + uniqueWorkName: String, + ) { + if (fhirDataStore.fetchUniqueWorkName(uniqueWorkName) != null) { + fhirDataStore.removeUniqueWorkName(key = uniqueWorkName) + } + } + + private fun computeBackoffDelayMillis(config: RetryConfiguration, attempt: Int): Long { + val baseDelayMs = config.backoffCriteria.backoffDelay.inWholeMilliseconds + return when (config.backoffCriteria.backoffPolicy) { + BackoffPolicy.EXPONENTIAL -> baseDelayMs * (1L shl attempt) + BackoffPolicy.LINEAR -> baseDelayMs + } + } +} + +private data class SyncHandle( + val job: Job, + val progressChannel: MutableSharedFlow, +) diff --git a/ohs-player-reference-app/src/foregroundSyncWebMain/kotlin/dev/ohs/player/reference/app/data/sync/NetworkConnectivity.web.kt b/ohs-player-reference-app/src/foregroundSyncWebMain/kotlin/dev/ohs/player/reference/app/data/sync/NetworkConnectivity.web.kt new file mode 100644 index 00000000..bdb14115 --- /dev/null +++ b/ohs-player-reference-app/src/foregroundSyncWebMain/kotlin/dev/ohs/player/reference/app/data/sync/NetworkConnectivity.web.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import kotlinx.browser.window + +/** + * `navigator.onLine` is a best-effort browser signal (reliably `false` when definitely offline, but + * can still report `true` without real connectivity) — good enough to skip an obviously doomed + * periodic sync cycle. + */ +internal actual fun isNetworkConnected(): Boolean = window.navigator.onLine diff --git a/ohs-player-reference-app/src/foregroundSyncWebMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncTimestampDataStore.web.kt b/ohs-player-reference-app/src/foregroundSyncWebMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncTimestampDataStore.web.kt new file mode 100644 index 00000000..a5d0c77e --- /dev/null +++ b/ohs-player-reference-app/src/foregroundSyncWebMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncTimestampDataStore.web.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.core.okio.WebLocalStorage +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.PreferencesSerializer + +// The web has no native filesystem for a DataStore file, so this persists in browser localStorage +// (survives page reloads, shared across tabs). +private val dataStore: DataStore by lazy { + PreferenceDataStoreFactory.create( + storage = + WebLocalStorage(serializer = PreferencesSerializer, name = SYNC_TIMESTAMP_DATASTORE_FILE_NAME) + ) +} + +internal actual fun createSyncTimestampDataStore(): DataStore = dataStore diff --git a/ohs-player-reference-app/src/foregroundSyncWebMain/kotlin/dev/ohs/player/reference/app/main.kt b/ohs-player-reference-app/src/foregroundSyncWebMain/kotlin/dev/ohs/player/reference/app/main.kt new file mode 100644 index 00000000..b773edaf --- /dev/null +++ b/ohs-player-reference-app/src/foregroundSyncWebMain/kotlin/dev/ohs/player/reference/app/main.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.ComposeViewport +import dev.ohs.fhir.engine.FhirEngine +import dev.ohs.fhir.engine.FhirEngineConfiguration +import dev.ohs.fhir.engine.FhirEngineProvider +import dev.ohs.fhir.engine.NetworkConfiguration +import dev.ohs.fhir.engine.ServerConfiguration +import dev.ohs.fhir.engine.sync.remote.HttpLogger +import dev.ohs.player.reference.app.auth.FhirBearerAuthenticator +import dev.ohs.player.reference.app.auth.GeneratedAuthConfig +import dev.ohs.player.reference.app.data.di.initKoin +import dev.ohs.player.reference.app.data.sync.ForegroundSyncManager +import dev.ohs.player.reference.app.data.sync.SYNC_TIMEOUT_DURATION +import dev.ohs.player.reference.app.data.sync.SyncManager +import org.koin.dsl.module + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + FhirEngineProvider.init( + FhirEngineConfiguration( + serverConfiguration = + ServerConfiguration( + baseUrl = GeneratedAuthConfig.FHIR_BASE_URL, + networkConfiguration = + NetworkConfiguration( + connectionTimeOut = SYNC_TIMEOUT_DURATION, + readTimeOut = SYNC_TIMEOUT_DURATION, + writeTimeOut = SYNC_TIMEOUT_DURATION, + ), + httpLogger = HttpLogger(level = HttpLogger.Level.HEADERS), + authenticator = FhirBearerAuthenticator, + ) + ) + ) + initKoin( + module { + single { FhirEngineProvider.getInstance() } + single { ForegroundSyncManager() } + } + ) + ComposeViewport { App() } +} diff --git a/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/MainViewController.kt b/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/MainViewController.kt index b0a9609a..52d1cf9f 100644 --- a/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/MainViewController.kt +++ b/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/MainViewController.kt @@ -16,14 +16,45 @@ package dev.ohs.player.reference.app import androidx.compose.ui.window.ComposeUIViewController -import dev.ohs.fhir.FhirEngine -import dev.ohs.fhir.FhirEngineConfiguration -import dev.ohs.fhir.FhirEngineProvider +import dev.ohs.fhir.engine.FhirEngine +import dev.ohs.fhir.engine.FhirEngineConfiguration +import dev.ohs.fhir.engine.FhirEngineProvider +import dev.ohs.fhir.engine.NetworkConfiguration +import dev.ohs.fhir.engine.ServerConfiguration +import dev.ohs.fhir.engine.sync.remote.HttpLogger +import dev.ohs.player.reference.app.auth.FhirBearerAuthenticator +import dev.ohs.player.reference.app.auth.GeneratedAuthConfig import dev.ohs.player.reference.app.data.di.initKoin +import dev.ohs.player.reference.app.data.sync.IosSyncManager +import dev.ohs.player.reference.app.data.sync.SYNC_TIMEOUT_DURATION +import dev.ohs.player.reference.app.data.sync.SyncManager import org.koin.dsl.module fun MainViewController() = run { - FhirEngineProvider.init(FhirEngineConfiguration()) - initKoin(module { single { FhirEngineProvider.getInstance() } }) + FhirEngineProvider.init( + FhirEngineConfiguration( + serverConfiguration = + ServerConfiguration( + baseUrl = GeneratedAuthConfig.FHIR_BASE_URL, + networkConfiguration = + NetworkConfiguration( + connectionTimeOut = SYNC_TIMEOUT_DURATION, + readTimeOut = SYNC_TIMEOUT_DURATION, + writeTimeOut = SYNC_TIMEOUT_DURATION, + ), + httpLogger = HttpLogger(level = HttpLogger.Level.HEADERS), + authenticator = FhirBearerAuthenticator, + ) + ) + ) + // Constructed eagerly (not inside the Koin lambda, which is lazy) so BGTaskScheduler + // registration happens now, during app launch — see IosBgSyncScheduler's docs. + val syncManager = IosSyncManager() + initKoin( + module { + single { FhirEngineProvider.getInstance() } + single { syncManager } + } + ) ComposeUIViewController { App() } } diff --git a/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/auth/Platform.ios.kt b/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/auth/Platform.ios.kt new file mode 100644 index 00000000..a2326055 --- /dev/null +++ b/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/auth/Platform.ios.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalForeignApi::class) + +package dev.ohs.player.reference.app.auth + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import eu.anifantakis.lib.ksafe.KSafe +import eu.anifantakis.lib.ksafe.KSafeConfig +import kotlin.coroutines.resume +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import kotlinx.coroutines.suspendCancellableCoroutine +import platform.AuthenticationServices.ASPresentationAnchor +import platform.AuthenticationServices.ASWebAuthenticationPresentationContextProvidingProtocol +import platform.AuthenticationServices.ASWebAuthenticationSession +import platform.Foundation.NSError +import platform.Foundation.NSURL +import platform.Security.SecRandomCopyBytes +import platform.Security.errSecSuccess +import platform.Security.kSecRandomDefault +import platform.UIKit.UIApplication +import platform.UIKit.UISceneActivationStateForegroundActive +import platform.UIKit.UIWindow +import platform.UIKit.UIWindowScene +import platform.darwin.NSObject + +/** + * `requireUnlockedDevice = false` keeps the Keychain key at + * `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, so the background `BGProcessingTask` sync can + * read the session while the device is locked (once it has been unlocked at least once since boot). + * Setting it `true` would break headless sync on a locked device. + */ +internal actual fun createKSafe(): KSafe = + KSafe(config = KSafeConfig(requireUnlockedDevice = false)) + +internal actual fun secureRandomBytes(size: Int): ByteArray { + val bytes = ByteArray(size) + val status = + bytes.usePinned { pinned -> + SecRandomCopyBytes(kSecRandomDefault, size.toULong(), pinned.addressOf(0)) + } + check(status == errSecSuccess) { "SecRandomCopyBytes failed (OSStatus $status)" } + return bytes +} + +/** + * iOS login via ASWebAuthenticationSession — the platform-blessed flow that shares cookies with + * Safari and returns the callback URL directly to the app (no AppDelegate plumbing needed). + */ +actual class AuthorizationLauncher( + actual override val redirectUri: String, + private val callbackScheme: String, +) : AuthorizationLauncherApi { + private val contextProvider = PresentationContextProvider() + + actual override suspend fun authorize(authUrl: String): AuthResult = + suspendCancellableCoroutine { continuation -> + val session = + ASWebAuthenticationSession( + uRL = NSURL(string = authUrl), + callbackURLScheme = callbackScheme, + ) { callbackURL: NSURL?, error: NSError? -> + val result = + when { + callbackURL != null -> AuthResult.Success(callbackURL.absoluteString ?: "") + // ASWebAuthenticationSessionErrorCodeCanceledLogin == 1 + error != null && error.code.toInt() == 1 -> AuthResult.Canceled + error != null -> AuthResult.Failure(error.localizedDescription) + else -> AuthResult.Failure("Unknown authentication error") + } + if (continuation.isActive) continuation.resume(result) + } + session.presentationContextProvider = contextProvider + session.prefersEphemeralWebBrowserSession = false + continuation.invokeOnCancellation { session.cancel() } + session.start() + } + + actual override fun consumeRedirectCallback(): String? = null +} + +private class PresentationContextProvider : + NSObject(), ASWebAuthenticationPresentationContextProvidingProtocol { + @Suppress("DEPRECATION") + override fun presentationAnchorForWebAuthenticationSession( + session: ASWebAuthenticationSession + ): ASPresentationAnchor { + val app = UIApplication.sharedApplication + val scenes = app.connectedScenes.filterIsInstance() + val scene = + scenes.firstOrNull { it.activationState == UISceneActivationStateForegroundActive } + ?: scenes.firstOrNull() + val window = scene?.windows?.filterIsInstance()?.firstOrNull() + return window ?: app.keyWindow ?: UIWindow() + } +} + +@Composable +actual fun rememberAuthorizationLauncher(): AuthorizationLauncher = remember { + AuthorizationLauncher( + redirectUri = "${GeneratedAuthConfig.REDIRECT_SCHEME}://${GeneratedAuthConfig.REDIRECT_HOST}", + callbackScheme = GeneratedAuthConfig.REDIRECT_SCHEME, + ) +} diff --git a/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/data/sync/IosBgSyncScheduler.kt b/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/data/sync/IosBgSyncScheduler.kt new file mode 100644 index 00000000..8bf3ec59 --- /dev/null +++ b/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/data/sync/IosBgSyncScheduler.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import co.touchlab.kermit.Logger +import dev.ohs.fhir.engine.sync.FhirSyncTask +import dev.ohs.fhir.engine.sync.SyncJobStatus +import dev.ohs.fhir.engine.sync.runSync +import dev.ohs.player.reference.app.auth.ensureFreshSessionForSync +import dev.ohs.player.reference.app.data.DataChangeSignal +import kotlin.concurrent.AtomicInt +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import platform.BackgroundTasks.BGProcessingTask +import platform.BackgroundTasks.BGProcessingTaskRequest +import platform.BackgroundTasks.BGTaskScheduler +import platform.Foundation.NSError + +/** + * Registers and schedules this app's sync as a `BGProcessingTask`. Mirrors kotlin-fhir-engine's + * engine-app `IosBgSyncScheduler`. [register] must be called during app launch, before + * `applicationDidFinishLaunching` returns per Apple's `BGTaskScheduler` docs — see + * [dev.ohs.player.reference.app.MainViewController], which constructs [IosPeriodicSyncUseCase] + * eagerly (not lazily through Koin) for exactly this reason. Requires + * `BGTaskSchedulerPermittedIdentifiers`/`UIBackgroundModes` in `iosApp/iosApp/Info.plist`. + */ +internal class IosBgSyncScheduler( + private val taskIdentifier: String, + private val taskFactory: () -> FhirSyncTask, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val registered = AtomicInt(0) + + fun register() { + if (!registered.compareAndSet(0, 1)) return + BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifier( + taskIdentifier, + usingQueue = null, + launchHandler = { task -> handleTask(task as BGProcessingTask) }, + ) + Logger.d { "IosBgSyncScheduler: registered handler for $taskIdentifier" } + } + + fun schedule() { + BGTaskScheduler.sharedScheduler.cancelTaskRequestWithIdentifier(taskIdentifier) + val request = + BGProcessingTaskRequest(taskIdentifier).apply { + requiresNetworkConnectivity = true + requiresExternalPower = false + } + submitRequest(request) + } + + fun cancel() { + BGTaskScheduler.sharedScheduler.cancelTaskRequestWithIdentifier(taskIdentifier) + } + + private fun handleTask(task: BGProcessingTask) { + val mutex = Mutex() + var completed = false + + val completeOnce: (Boolean) -> Unit = { success -> + scope.launch { + mutex.withLock { + if (!completed) { + completed = true + task.setTaskCompletedWithSuccess(success) + if (success) schedule() + } + } + } + } + + task.expirationHandler = { + Logger.w { "IosBgSyncScheduler: task expired for $taskIdentifier" } + completeOnce(false) + } + + scope.launch { + try { + // A background launch never runs the UI bootstrap, so hydrate + refresh the session first + // to hand the sync's requests a valid Bearer token. + ensureFreshSessionForSync() + val status = taskFactory().runSync(taskName = taskIdentifier, onProgress = {}) + Logger.d { "IosBgSyncScheduler: sync completed with $status" } + if (status is SyncJobStatus.Succeeded) DataChangeSignal.notifyChanged() + completeOnce(status is SyncJobStatus.Succeeded) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.e(e) { "IosBgSyncScheduler: sync failed" } + completeOnce(false) + } + } + } + + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + private fun submitRequest(request: BGProcessingTaskRequest) { + try { + memScoped { + val error = alloc>() + val success = BGTaskScheduler.sharedScheduler.submitTaskRequest(request, error.ptr) + if (!success) { + val msg = error.value?.localizedDescription ?: "no error details" + Logger.e { "IosBgSyncScheduler: submit failed ($msg)" } + } + } + } catch (e: Exception) { + Logger.e(e) { "IosBgSyncScheduler: exception submitting request" } + } + } +} diff --git a/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/data/sync/IosSyncManager.kt b/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/data/sync/IosSyncManager.kt new file mode 100644 index 00000000..8caee56b --- /dev/null +++ b/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/data/sync/IosSyncManager.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import co.touchlab.kermit.Logger +import dev.ohs.fhir.engine.FhirEngineProvider +import dev.ohs.fhir.engine.sync.SyncJobStatus +import dev.ohs.fhir.engine.sync.runSync +import dev.ohs.player.reference.app.data.DataChangeSignal +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import platform.Foundation.NSNotificationCenter +import platform.UIKit.UIApplicationDidEnterBackgroundNotification +import platform.UIKit.UIApplicationWillEnterForegroundNotification + +/** Must match `BGTaskSchedulerPermittedIdentifiers` in `iosApp/iosApp/Info.plist`. */ +internal const val PERIODIC_SYNC_TASK_IDENTIFIER = "dev.ohs.player.reference.app.sync.periodic" + +/** + * iOS [SyncManager]. Periodic sync runs as a `BGProcessingTask` via [IosBgSyncScheduler]; + * registration happens in the constructor (not lazily) because `BGTaskScheduler` requires it before + * `applicationDidFinishLaunching` returns — see [IosBgSyncScheduler]'s docs and + * [dev.ohs.player.reference.app.MainViewController], which constructs this eagerly. + * + * One-time sync runs on a dedicated [Dispatchers.IO]-backed scope decoupled from the caller, so + * backgrounding the triggering screen doesn't cancel an in-flight network sync. Mirrors + * kotlin-fhir-engine's engine-app `FhirSyncController.ios.kt`: iOS suspends networking for + * backgrounded apps with no active background task, so an in-progress one-time sync is cancelled on + * [UIApplicationDidEnterBackgroundNotification] and relaunched from scratch on + * [UIApplicationWillEnterForegroundNotification], with [syncNow] suspending across the relaunch + * until a terminal result arrives. + */ +class IosSyncManager : SyncManager { + private val scheduler = + IosBgSyncScheduler( + taskIdentifier = PERIODIC_SYNC_TASK_IDENTIFIER, + taskFactory = { AppFhirSyncTask(FhirEngineProvider.getInstance()) }, + ) + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private var currentJob: Job? = null + private var currentStatusFlow: MutableSharedFlow? = null + private var syncWasRunning = false + + init { + scheduler.register() + + NSNotificationCenter.defaultCenter.addObserverForName( + UIApplicationDidEnterBackgroundNotification, + null, + null, + ) { _ -> + if (currentJob?.isActive == true) { + syncWasRunning = true + currentJob?.cancel() + Logger.d { "IosSyncManager: sync suspended on background" } + } + } + + NSNotificationCenter.defaultCenter.addObserverForName( + UIApplicationWillEnterForegroundNotification, + null, + null, + ) { _ -> + if (syncWasRunning) { + syncWasRunning = false + launchSyncJob() + Logger.d { "IosSyncManager: sync restarted on foreground" } + } + } + } + + override suspend fun syncNow(): SyncJobStatus { + val statusFlow = MutableSharedFlow(replay = 1) + currentStatusFlow = statusFlow + launchSyncJob() + return statusFlow.first { it is SyncJobStatus.Succeeded || it is SyncJobStatus.Failed } + } + + override suspend fun cancelSyncNow() { + syncWasRunning = false + currentJob?.cancel() + currentStatusFlow?.emit(SyncJobStatus.Failed()) + } + + override suspend fun startPeriodicSync() { + scheduler.schedule() + } + + override suspend fun cancelPeriodicSync() { + scheduler.cancel() + } + + private fun launchSyncJob() { + val statusFlow = currentStatusFlow ?: return + currentJob?.cancel() + currentJob = + scope.launch { + val result = + try { + AppFhirSyncTask(FhirEngineProvider.getInstance()).runSync(taskName = null) {} + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.e(e) { "IosSyncManager: one-time sync failed" } + SyncJobStatus.Failed() + } + if (result is SyncJobStatus.Succeeded) DataChangeSignal.notifyChanged() + statusFlow.emit(result) + } + } +} diff --git a/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncTimestampDataStore.ios.kt b/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncTimestampDataStore.ios.kt new file mode 100644 index 00000000..7272f7bb --- /dev/null +++ b/ohs-player-reference-app/src/iosMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncTimestampDataStore.ios.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import dev.ohs.fhir.engine.sync.createDataStore +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSApplicationSupportDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSUserDomainMask + +@OptIn(ExperimentalForeignApi::class) +private val dataStore: DataStore by lazy { + val appSupportDir = + NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true) + .first() as String + NSFileManager.defaultManager.createDirectoryAtPath( + appSupportDir, + withIntermediateDirectories = true, + attributes = null, + error = null, + ) + createDataStore { "$appSupportDir/$SYNC_TIMESTAMP_DATASTORE_FILE_NAME" } +} + +internal actual fun createSyncTimestampDataStore(): DataStore = dataStore diff --git a/ohs-player-reference-app/src/jsMain/kotlin/dev/ohs/player/reference/app/auth/SecureRandom.js.kt b/ohs-player-reference-app/src/jsMain/kotlin/dev/ohs/player/reference/app/auth/SecureRandom.js.kt new file mode 100644 index 00000000..dbd2bba8 --- /dev/null +++ b/ohs-player-reference-app/src/jsMain/kotlin/dev/ohs/player/reference/app/auth/SecureRandom.js.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import org.khronos.webgl.Int8Array +import org.khronos.webgl.get + +/** Browser CSPRNG via Web Crypto `crypto.getRandomValues`. */ +internal actual fun secureRandomBytes(size: Int): ByteArray { + val array = Int8Array(size) + js("crypto.getRandomValues(array)") + return ByteArray(size) { array[it] } +} diff --git a/ohs-player-reference-app/src/webMain/kotlin/dev/ohs/player/reference/app/main.kt b/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/DesktopStorage.kt similarity index 56% rename from ohs-player-reference-app/src/webMain/kotlin/dev/ohs/player/reference/app/main.kt rename to ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/DesktopStorage.kt index 23398291..7e874ef8 100644 --- a/ohs-player-reference-app/src/webMain/kotlin/dev/ohs/player/reference/app/main.kt +++ b/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/DesktopStorage.kt @@ -15,17 +15,11 @@ */ package dev.ohs.player.reference.app -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.window.ComposeViewport -import dev.ohs.fhir.FhirEngine -import dev.ohs.fhir.FhirEngineConfiguration -import dev.ohs.fhir.FhirEngineProvider -import dev.ohs.player.reference.app.data.di.initKoin -import org.koin.dsl.module +import java.io.File -@OptIn(ExperimentalComposeUiApi::class) -fun main() { - FhirEngineProvider.init(FhirEngineConfiguration()) - initKoin(module { single { FhirEngineProvider.getInstance() } }) - ComposeViewport { App() } -} +/** + * One desktop storage root: the FHIR database, KSafe, and the sync-timestamp DataStore all live + * here. + */ +internal val desktopStorageDirectory: File = + File(System.getProperty("user.home").orEmpty().ifBlank { "." }, ".player-reference") diff --git a/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/auth/Platform.jvm.kt b/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/auth/Platform.jvm.kt new file mode 100644 index 00000000..4f2cdbdb --- /dev/null +++ b/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/auth/Platform.jvm.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.sun.net.httpserver.HttpServer +import dev.ohs.player.reference.app.desktopStorageDirectory +import eu.anifantakis.lib.ksafe.KSafe +import java.awt.Desktop +import java.net.InetSocketAddress +import java.net.URI +import java.security.SecureRandom +import kotlinx.coroutines.suspendCancellableCoroutine + +internal actual fun createKSafe(): KSafe = KSafe(baseDir = desktopStorageDirectory) + +internal actual fun secureRandomBytes(size: Int): ByteArray = + ByteArray(size).also { SecureRandom().nextBytes(it) } + +/** + * Desktop login: open the system browser to the identity provider and capture the redirect on a + * short-lived localhost loopback server (the OAuth 2.0 native-app recommendation, RFC 8252). + */ +actual class AuthorizationLauncher(private val port: Int) : AuthorizationLauncherApi { + + actual override val redirectUri: String = "http://127.0.0.1:$port/callback" + + actual override suspend fun authorize(authUrl: String): AuthResult = + suspendCancellableCoroutine { continuation -> + val server = HttpServer.create(InetSocketAddress("127.0.0.1", port), 0) + server.createContext("/callback") { exchange -> + val callbackUrl = "$redirectUri?${exchange.requestURI.rawQuery.orEmpty()}" + val html = + "" + + "

You can return to the app

You may close this window.

" + + "" + val bytes = html.encodeToByteArray() + exchange.responseHeaders.add("Content-Type", "text/html; charset=utf-8") + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + Thread { server.stop(0) }.start() + if (continuation.isActive) + continuation.resumeWith(Result.success(AuthResult.Success(callbackUrl))) + } + server.start() + continuation.invokeOnCancellation { server.stop(0) } + + runCatching { + val desktop = Desktop.getDesktop() + if (Desktop.isDesktopSupported() && desktop.isSupported(Desktop.Action.BROWSE)) { + desktop.browse(URI(authUrl)) + } else { + error("Opening a browser is not supported on this desktop environment") + } + } + .onFailure { + server.stop(0) + if (continuation.isActive) { + continuation.resumeWith( + Result.success(AuthResult.Failure(it.message ?: "Failed to open browser")) + ) + } + } + } + + actual override fun consumeRedirectCallback(): String? = null +} + +@Composable +actual fun rememberAuthorizationLauncher(): AuthorizationLauncher = remember { + AuthorizationLauncher(GeneratedAuthConfig.DESKTOP_REDIRECT_PORT) +} diff --git a/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/data/sync/NetworkConnectivity.jvm.kt b/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/data/sync/NetworkConnectivity.jvm.kt new file mode 100644 index 00000000..6e01283f --- /dev/null +++ b/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/data/sync/NetworkConnectivity.jvm.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import java.net.NetworkInterface +import java.util.Collections + +/** + * Desktop has no OS-level sync scheduler to enforce a network constraint (unlike Android/iOS), so + * [dev.ohs.player.reference.app.data.sync.Sync.periodicSync] checks this before every cycle. Fails + * open (returns true) on enumeration errors — an unnecessary sync attempt that fails is preferable + * to silently never syncing again because this heuristic broke. + */ +internal actual fun isNetworkConnected(): Boolean = + try { + Collections.list(NetworkInterface.getNetworkInterfaces()).any { it.isUp && !it.isLoopback } + } catch (e: Exception) { + true + } diff --git a/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncTimestampDataStore.jvm.kt b/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncTimestampDataStore.jvm.kt new file mode 100644 index 00000000..2d8e1767 --- /dev/null +++ b/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/data/sync/SyncTimestampDataStore.jvm.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import dev.ohs.fhir.engine.sync.createDataStore +import dev.ohs.player.reference.app.desktopStorageDirectory +import java.io.File + +private val dataStore: DataStore by lazy { + createDataStore { File(desktopStorageDirectory, SYNC_TIMESTAMP_DATASTORE_FILE_NAME).absolutePath } +} + +internal actual fun createSyncTimestampDataStore(): DataStore = dataStore diff --git a/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/main.kt b/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/main.kt index 288b297e..6962a19e 100644 --- a/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/main.kt +++ b/ohs-player-reference-app/src/jvmMain/kotlin/dev/ohs/player/reference/app/main.kt @@ -17,17 +17,52 @@ package dev.ohs.player.reference.app import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import dev.ohs.fhir.FhirEngine -import dev.ohs.fhir.FhirEngineConfiguration -import dev.ohs.fhir.FhirEngineProvider +import dev.ohs.fhir.engine.FhirEngine +import dev.ohs.fhir.engine.FhirEngineConfiguration +import dev.ohs.fhir.engine.FhirEngineProvider +import dev.ohs.fhir.engine.NetworkConfiguration +import dev.ohs.fhir.engine.ServerConfiguration +import dev.ohs.fhir.engine.sync.remote.HttpLogger +import dev.ohs.player.reference.app.auth.FhirBearerAuthenticator +import dev.ohs.player.reference.app.auth.GeneratedAuthConfig import dev.ohs.player.reference.app.data.di.initKoin -import java.io.File +import dev.ohs.player.reference.app.data.sync.ForegroundSyncManager +import dev.ohs.player.reference.app.data.sync.SYNC_TIMEOUT_DURATION +import dev.ohs.player.reference.app.data.sync.SyncManager +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.Res +import ohsplayerreferenceclientapp.ohs_player_reference_app.generated.resources.app_logo +import org.jetbrains.compose.resources.painterResource import org.koin.dsl.module fun main() = application { - val userHome = System.getProperty("user.home").orEmpty().ifBlank { "." } - val storageDirectory = File(userHome, ".ohs-player-reference-app").absolutePath - FhirEngineProvider.init(FhirEngineConfiguration(storageDirectory = storageDirectory)) - initKoin(module { single { FhirEngineProvider.getInstance() } }) - Window(onCloseRequest = ::exitApplication, title = "OHS Player Reference App") { App() } + FhirEngineProvider.init( + FhirEngineConfiguration( + storageDirectory = desktopStorageDirectory.absolutePath, + serverConfiguration = + ServerConfiguration( + baseUrl = GeneratedAuthConfig.FHIR_BASE_URL, + networkConfiguration = + NetworkConfiguration( + connectionTimeOut = SYNC_TIMEOUT_DURATION, + readTimeOut = SYNC_TIMEOUT_DURATION, + writeTimeOut = SYNC_TIMEOUT_DURATION, + ), + httpLogger = HttpLogger(level = HttpLogger.Level.HEADERS), + authenticator = FhirBearerAuthenticator, + ), + ) + ) + initKoin( + module { + single { FhirEngineProvider.getInstance() } + single { ForegroundSyncManager() } + } + ) + Window( + onCloseRequest = ::exitApplication, + title = "Player Reference", + icon = painterResource(Res.drawable.app_logo), + ) { + App() + } } diff --git a/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/auth/SessionRepositoryTest.kt b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/auth/SessionRepositoryTest.kt new file mode 100644 index 00000000..46a89507 --- /dev/null +++ b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/auth/SessionRepositoryTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.coroutines.test.runTest + +class SessionRepositoryTest { + + @AfterTest fun tearDown() = runTest { SessionRepository.clear() } + + private fun testSession() = + Session( + accessToken = "access-1", + refreshToken = "refresh-1", + idToken = "id-1", + expiresInSeconds = 300, + obtainedAtEpochSeconds = 1_000, + user = UserInfo(subject = "u1", username = "jdoe"), + ) + + @Test + fun saveThenLoad_roundTripsTheSession() = runTest { + SessionRepository.save(testSession()) + + val loaded = SessionRepository.load() + + assertEquals(testSession(), loaded) + assertEquals(testSession(), SessionRepository.session.value) + } + + @Test + fun clear_removesTheSession() = runTest { + SessionRepository.save(testSession()) + + SessionRepository.clear() + + assertNull(SessionRepository.load()) + assertNull(SessionRepository.session.value) + } + + @Test + fun takePending_returnsAndClearsPendingAuth_onlyOnce() = runTest { + SessionRepository.savePending(PendingAuth(codeVerifier = "verifier-1", state = "state-1")) + + val first = SessionRepository.takePending() + val second = SessionRepository.takePending() + + assertEquals(PendingAuth("verifier-1", "state-1"), first) + assertNull(second, "pending auth must be single-use") + } +} diff --git a/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/data/repository/FhirEngineRepositoryTest.kt b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/data/repository/FhirEngineRepositoryTest.kt index ed9ce518..52f5e0bf 100644 --- a/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/data/repository/FhirEngineRepositoryTest.kt +++ b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/data/repository/FhirEngineRepositoryTest.kt @@ -15,9 +15,9 @@ */ package dev.ohs.player.reference.app.data.repository -import dev.ohs.fhir.FhirEngine -import dev.ohs.fhir.FhirEngineConfiguration -import dev.ohs.fhir.FhirEngineProvider +import dev.ohs.fhir.engine.FhirEngine +import dev.ohs.fhir.engine.FhirEngineConfiguration +import dev.ohs.fhir.engine.FhirEngineProvider import dev.ohs.fhir.model.r4.Bundle import dev.ohs.fhir.model.r4.Group import dev.ohs.fhir.model.r4.Patient @@ -63,11 +63,12 @@ class FhirEngineRepositoryTest { .trimIndent(), ) + val revisionBefore = repository.revision.value repository.upsert(patient) val stored = repository.get("Patient", "patient-1") as? Patient assertEquals("patient-1", stored?.id) - assertEquals(1L, repository.revision.value) + assertEquals(revisionBefore + 1, repository.revision.value) } @Test @@ -84,13 +85,14 @@ class FhirEngineRepositoryTest { """{"resourceType": "Patient", "id": "patient-2", "active": false}""", ) + val revisionBefore = repository.revision.value repository.upsert(original) repository.upsert(updated) val stored = repository.get("Patient", "patient-2") as? Patient assertEquals(false, stored?.active?.value) assertEquals(listOf("patient-2"), repository.all("Patient").mapNotNull { it.id }) - assertEquals(2L, repository.revision.value) + assertEquals(revisionBefore + 2, repository.revision.value) } @Test @@ -136,10 +138,11 @@ class FhirEngineRepositoryTest { .trimIndent(), ) + val revisionBefore = repository.revision.value val storedCount = repository.upsert(bundle) assertEquals(2, storedCount) - assertEquals(1L, repository.revision.value) + assertEquals(revisionBefore + 1, repository.revision.value) val patients = repository.all("Patient").filterIsInstance() assertEquals(1, patients.size) val patientId = patients.first().id.orEmpty() @@ -198,12 +201,13 @@ class FhirEngineRepositoryTest { .trimIndent(), ) + val revisionBefore = repository.revision.value val storedCount = repository.upsert(bundle) // 4 stored: absolute-fullUrl patient, request-url patient, generated-id patient, and the // group. The 5th entry (resource == null) is silently dropped and does not count. assertEquals(4, storedCount) - assertEquals(1L, repository.revision.value) + assertEquals(revisionBefore + 1, repository.revision.value) val patients = repository.all("Patient").filterIsInstance() assertEquals(3, patients.size) diff --git a/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/data/sync/DataStoreTimestampContextTest.kt b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/data/sync/DataStoreTimestampContextTest.kt new file mode 100644 index 00000000..fe5bedb2 --- /dev/null +++ b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/data/sync/DataStoreTimestampContextTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import dev.ohs.fhir.engine.sync.createDataStore +import dev.ohs.fhir.model.r4.terminologies.ResourceType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.coroutines.test.runTest + +class DataStoreTimestampContextTest { + + private fun testDataStore(): DataStore { + val file = kotlin.io.path.createTempFile(suffix = ".preferences_pb").toFile() + file.deleteOnExit() + return createDataStore { file.absolutePath } + } + + @Test + fun getLasUpdateTimestamp_beforeAnySave_isNull() = runTest { + val context = DataStoreTimestampContext(testDataStore()) + + assertNull(context.getLasUpdateTimestamp(ResourceType.Patient)) + } + + @Test + fun saveThenGet_roundTripsPerResourceType() = runTest { + val context = DataStoreTimestampContext(testDataStore()) + + context.saveLastUpdatedTimestamp(ResourceType.Patient, "2026-07-15T10:00:00Z") + context.saveLastUpdatedTimestamp(ResourceType.Group, "2026-07-14T09:00:00Z") + + assertEquals("2026-07-15T10:00:00Z", context.getLasUpdateTimestamp(ResourceType.Patient)) + assertEquals("2026-07-14T09:00:00Z", context.getLasUpdateTimestamp(ResourceType.Group)) + } + + @Test + fun saveWithNullTimestamp_doesNotOverwriteExistingValue() = runTest { + val context = DataStoreTimestampContext(testDataStore()) + context.saveLastUpdatedTimestamp(ResourceType.Patient, "2026-07-15T10:00:00Z") + + context.saveLastUpdatedTimestamp(ResourceType.Patient, null) + + assertEquals("2026-07-15T10:00:00Z", context.getLasUpdateTimestamp(ResourceType.Patient)) + } +} diff --git a/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/data/sync/SyncTest.kt b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/data/sync/SyncTest.kt new file mode 100644 index 00000000..b114ebf0 --- /dev/null +++ b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/data/sync/SyncTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.data.sync + +import dev.ohs.fhir.engine.FhirEngine +import dev.ohs.fhir.engine.FhirEngineConfiguration +import dev.ohs.fhir.engine.FhirEngineProvider +import dev.ohs.fhir.engine.sync.AcceptLocalConflictResolver +import dev.ohs.fhir.engine.sync.ConflictResolver +import dev.ohs.fhir.engine.sync.CurrentSyncJobStatus +import dev.ohs.fhir.engine.sync.DownloadWorkManager +import dev.ohs.fhir.engine.sync.FhirSyncTask +import dev.ohs.fhir.engine.sync.download.DownloadRequest +import dev.ohs.fhir.engine.sync.upload.HttpCreateMethod +import dev.ohs.fhir.engine.sync.upload.HttpUpdateMethod +import dev.ohs.fhir.engine.sync.upload.UploadStrategy +import dev.ohs.fhir.model.r4.Resource +import dev.ohs.fhir.model.r4.terminologies.ResourceType +import java.nio.file.Files +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest + +/** + * A no-op [FhirSyncTask] whose download step optionally blocks on [gate] before completing empty + * (no requests, no local changes) — real enough for [dev.ohs.fhir.engine.sync.runSync] to reach + * [dev.ohs.fhir.engine.sync.SyncJobStatus.Succeeded] without ever touching the network. Matches the + * shape kotlin-fhir-engine's own `DownloaderImplTest.TestDownloadWorkManager` uses. + */ +private class TestFhirSyncTask(private val gate: CompletableDeferred? = null) : FhirSyncTask { + override fun getFhirEngine(): FhirEngine = FhirEngineProvider.getInstance() + + override fun getDownloadWorkManager(): DownloadWorkManager = + object : DownloadWorkManager { + private var requested = false + + override suspend fun getNextRequest(): DownloadRequest? { + if (requested) return null + requested = true + gate?.await() + return null + } + + override suspend fun getSummaryRequestUrls(): Map = emptyMap() + + override suspend fun processResponse(response: Resource): Collection = emptyList() + } + + override fun getConflictResolver(): ConflictResolver = AcceptLocalConflictResolver + + override fun getUploadStrategy(): UploadStrategy = + UploadStrategy.forBundleRequest( + methodForCreate = HttpCreateMethod.PUT, + methodForUpdate = HttpUpdateMethod.PATCH, + squash = true, + bundleSize = 500, + ) +} + +class SyncTest { + + @BeforeTest + fun setUp() = runTest { + // Matches the established pattern in FhirEngineRepositoryTest: FhirEngineProvider is a + // process-wide singleton that throws if initialized twice, so guard it and reset state + // between tests instead of re-initializing. + if (FhirEngineProvider.isNotInitialized()) { + FhirEngineProvider.init( + FhirEngineConfiguration( + storageDirectory = Files.createTempDirectory("sync-test").toString() + ) + ) + } + FhirEngineProvider.getInstance().clearDatabase() + } + + @Test + fun cancelOneTimeSync_whileRunning_emitsCancelled() = runTest { + val gate = CompletableDeferred() + val statusFlow = Sync.oneTimeSync(taskFactory = { TestFhirSyncTask(gate) }) + + // Let the sync actually start (past Enqueued) before cancelling. + statusFlow.first { it is CurrentSyncJobStatus.Running } + + Sync.cancelOneTimeSync() + + val terminal = statusFlow.first { it !is CurrentSyncJobStatus.Running } + assertIs(terminal) + } + + @Test + fun cancelOneTimeSync_withNoActiveSync_doesNotThrow() = runTest { + Sync.cancelOneTimeSync() + assertTrue(true) // reaching here means no-op didn't throw + } + + @Test + fun periodicSync_start_runsFirstCycleImmediately() = runTest { + val firstAttempt = CompletableDeferred() + + Sync.periodicSync( + taskFactory = { + firstAttempt.complete(Unit) + TestFhirSyncTask() + }, + // Long enough that only one cycle should fire during this test's lifetime. + repeatInterval = 1.minutes, + ) + + // No withTimeout wrapper here on purpose: withTimeout schedules its cancellation on the + // *test* dispatcher's virtual-time scheduler, which auto-advances past it near-instantly + // once this coroutine looks "idle" (runTest doesn't know about the real work happening on + // Sync's separate syncDispatcher) — a virtual/real time mismatch that made this test flake. + // A plain await() is a real suspension resolved only when the background coroutine actually + // completes it; runTest's own real-time dispatch timeout is the safety net if it never does. + firstAttempt.await() + + Sync.cancelPeriodicSync() + } + + @Test + fun periodicSync_calledTwice_doesNotStartASecondCompetingLoop() = runTest { + var startCount = 0 + val firstAttemptStarted = CompletableDeferred() + val factory: () -> TestFhirSyncTask = { + startCount++ + firstAttemptStarted.complete(Unit) + TestFhirSyncTask() + } + + Sync.periodicSync(taskFactory = factory, repeatInterval = 1.minutes) + firstAttemptStarted.await() + // The first cycle's loop is now sleeping the 1-minute interval, so it's still "active" — + // this second call must see that and no-op rather than starting a competing loop. + Sync.periodicSync(taskFactory = factory, repeatInterval = 1.minutes) + + assertEquals(1, startCount) + + Sync.cancelPeriodicSync() + } +} diff --git a/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/feature/home/HomeScreenTest.kt b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/feature/home/HomeScreenTest.kt new file mode 100644 index 00000000..f3cf9ef3 --- /dev/null +++ b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/feature/home/HomeScreenTest.kt @@ -0,0 +1,211 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.feature.home + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import dev.ohs.fhir.engine.sync.FhirDataStore +import dev.ohs.fhir.engine.sync.SyncJobStatus +import dev.ohs.fhir.engine.sync.createDataStore +import dev.ohs.player.library.registry.LocalViewRegistry +import dev.ohs.player.reference.app.buildAppViewRegistry +import dev.ohs.player.reference.app.data.di.repositoryModule +import dev.ohs.player.reference.app.data.di.viewModelModule +import dev.ohs.player.reference.app.data.repository.FhirRepository +import dev.ohs.player.reference.app.data.repository.InMemorySampleFhirRepository +import dev.ohs.player.reference.app.data.sync.FakeSyncManager +import dev.ohs.player.reference.app.data.sync.SyncManager +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module + +@OptIn(ExperimentalTestApi::class) +class HomeScreenTest { + + private fun newFhirDataStore(): FhirDataStore { + val path = Files.createTempFile("home-screen-test", ".preferences_pb").toString() + return FhirDataStore(createDataStore { path }) + } + + private fun startTestKoin(syncManager: SyncManager) { + startKoin { + modules( + module { + single { InMemorySampleFhirRepository() } + single { newFhirDataStore() } + single { syncManager } + }, + repositoryModule, + viewModelModule, + ) + } + } + + @AfterTest fun tearDown() = stopKoin() + + @Test + fun homeScreen_defaultsToHouseholdsContentWithDrawerSectionsVisible() = runComposeUiTest { + startTestKoin(FakeSyncManager { SyncJobStatus.Succeeded() }) + val registry = buildAppViewRegistry() + setContent { + CompositionLocalProvider(LocalViewRegistry provides registry) { + MaterialTheme { + HomeScreen( + userName = "Test User", + onGroupClick = {}, + onDataCaptureClick = {}, + onAddMembers = {}, + onAddClinicalData = {}, + onSignOut = {}, + ) + } + } + } + + waitUntil(timeoutMillis = 5_000L) { + onAllNodesWithText("No households").fetchSemanticsNodes().isNotEmpty() + } + assertTrue(onAllNodesWithText("Registers").fetchSemanticsNodes().isNotEmpty()) + assertTrue(onAllNodesWithText("Households").fetchSemanticsNodes().isNotEmpty()) + assertTrue(onAllNodesWithText("Sync now").fetchSemanticsNodes().isNotEmpty()) + } + + @Test + fun tappingSyncNow_showsProgressIndicatorWhileSyncPending() = runComposeUiTest { + val syncStarted = CompletableDeferred() + val releaseSyncResult = CompletableDeferred() + startTestKoin( + FakeSyncManager { + syncStarted.complete(Unit) + releaseSyncResult.await() + } + ) + val registry = buildAppViewRegistry() + setContent { + CompositionLocalProvider(LocalViewRegistry provides registry) { + MaterialTheme { + HomeScreen( + userName = "Test User", + onGroupClick = {}, + onDataCaptureClick = {}, + onAddMembers = {}, + onAddClinicalData = {}, + onSignOut = {}, + ) + } + } + } + + waitUntil(timeoutMillis = 5_000L) { + onAllNodesWithText("Sync now").fetchSemanticsNodes().isNotEmpty() + } + onNodeWithText("Sync now").performClick() + + waitUntil(timeoutMillis = 5_000L) { syncStarted.isCompleted } + waitUntil(timeoutMillis = 5_000L) { + onAllNodesWithContentDescription("Sync in progress").fetchSemanticsNodes().isNotEmpty() + } + + releaseSyncResult.complete(SyncJobStatus.Succeeded()) + + waitUntil(timeoutMillis = 5_000L) { + onAllNodesWithContentDescription("Sync in progress").fetchSemanticsNodes().isEmpty() + } + } + + @Test + fun tappingCancelSync_whileSyncing_callsCancelAndShowsCancelledMessage() = runComposeUiTest { + val syncStarted = CompletableDeferred() + val releaseSyncResult = CompletableDeferred() + val fake = FakeSyncManager { + syncStarted.complete(Unit) + releaseSyncResult.await() + } + startTestKoin(fake) + val registry = buildAppViewRegistry() + setContent { + CompositionLocalProvider(LocalViewRegistry provides registry) { + MaterialTheme { + HomeScreen( + userName = "Test User", + onGroupClick = {}, + onDataCaptureClick = {}, + onAddMembers = {}, + onAddClinicalData = {}, + onSignOut = {}, + ) + } + } + } + + waitUntil(timeoutMillis = 5_000L) { + onAllNodesWithText("Sync now").fetchSemanticsNodes().isNotEmpty() + } + onNodeWithText("Sync now").performClick() + waitUntil(timeoutMillis = 5_000L) { syncStarted.isCompleted } + waitUntil(timeoutMillis = 5_000L) { + onAllNodesWithText("Cancel sync").fetchSemanticsNodes().isNotEmpty() + } + + onNodeWithText("Cancel sync").performClick() + waitUntil(timeoutMillis = 5_000L) { fake.cancelSyncNowCount > 0 } + releaseSyncResult.complete(SyncJobStatus.Failed()) + + waitUntil(timeoutMillis = 5_000L) { + onAllNodesWithText("Sync cancelled.").fetchSemanticsNodes().isNotEmpty() + } + } + + @Test + fun tappingSyncNow_onFailure_showsSnackbarMessage() = runComposeUiTest { + startTestKoin(FakeSyncManager { SyncJobStatus.Failed() }) + val registry = buildAppViewRegistry() + setContent { + CompositionLocalProvider(LocalViewRegistry provides registry) { + MaterialTheme { + HomeScreen( + userName = "Test User", + onGroupClick = {}, + onDataCaptureClick = {}, + onAddMembers = {}, + onAddClinicalData = {}, + onSignOut = {}, + ) + } + } + } + + waitUntil(timeoutMillis = 5_000L) { + onAllNodesWithText("Sync now").fetchSemanticsNodes().isNotEmpty() + } + onNodeWithText("Sync now").performClick() + + waitUntil(timeoutMillis = 5_000L) { + onAllNodesWithText("Sync failed. Please try again.").fetchSemanticsNodes().isNotEmpty() + } + } +} diff --git a/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/feature/home/HomeViewModelTest.kt b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/feature/home/HomeViewModelTest.kt new file mode 100644 index 00000000..92c964ee --- /dev/null +++ b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/feature/home/HomeViewModelTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.feature.home + +import dev.ohs.fhir.engine.sync.FhirDataStore +import dev.ohs.fhir.engine.sync.SyncJobStatus +import dev.ohs.fhir.engine.sync.createDataStore +import dev.ohs.player.reference.app.data.sync.SyncManager +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest + +private class RecordingSyncManager( + private val fhirDataStore: FhirDataStore, + private val result: suspend () -> SyncJobStatus, +) : SyncManager { + var invocationCount = 0 + private set + + var cancelCount = 0 + private set + + override suspend fun syncNow(): SyncJobStatus { + invocationCount++ + val status = result() + // Mirrors FhirSynchronizer's real behavior of persisting the timestamp on every terminal + // outcome, so the ViewModel's re-read-after-sync behavior is exercised realistically. + fhirDataStore.writeLastSyncTimestamp(status.timestamp) + return status + } + + override suspend fun cancelSyncNow() { + cancelCount++ + } + + override suspend fun startPeriodicSync() {} + + override suspend fun cancelPeriodicSync() {} +} + +class HomeViewModelTest { + + private fun newFhirDataStore(): FhirDataStore { + val path = Files.createTempFile("home-viewmodel-test", ".preferences_pb").toString() + return FhirDataStore(createDataStore { path }) + } + + @Test + fun initialState_withNoPriorSync_hasNoLastSyncedAt() = runTest { + val viewModel = + HomeViewModel( + RecordingSyncManager(newFhirDataStore()) { SyncJobStatus.Succeeded() }, + newFhirDataStore(), + ) + + assertNull(viewModel.uiState.value.lastSyncedAt) + } + + @Test + fun syncNow_onSuccess_clearsIsSyncingAndPopulatesLastSyncedAt() = runTest { + val fhirDataStore = newFhirDataStore() + val viewModel = + HomeViewModel( + RecordingSyncManager(fhirDataStore) { SyncJobStatus.Succeeded() }, + fhirDataStore, + ) + + viewModel.syncNow()?.join() + + val state = viewModel.uiState.value + assertEquals(false, state.isSyncing) + assertNull(state.syncError) + assertNotNull(state.lastSyncedAt) + } + + @Test + fun syncNow_onFailure_clearsIsSyncingAndSetsSyncError() = runTest { + val fhirDataStore = newFhirDataStore() + val viewModel = + HomeViewModel(RecordingSyncManager(fhirDataStore) { SyncJobStatus.Failed() }, fhirDataStore) + + viewModel.syncNow()?.join() + + val state = viewModel.uiState.value + assertEquals(false, state.isSyncing) + assertEquals(SyncError.Failed, state.syncError) + } + + @Test + fun syncNow_whenUseCaseThrows_setsSyncError() = runTest { + val fhirDataStore = newFhirDataStore() + val viewModel = + HomeViewModel( + RecordingSyncManager(fhirDataStore) { throw RuntimeException("network down") }, + fhirDataStore, + ) + + viewModel.syncNow()?.join() + + val state = viewModel.uiState.value + assertEquals(false, state.isSyncing) + assertEquals(SyncError.Failed, state.syncError) + } + + @Test + fun syncNow_whileAlreadySyncing_doesNotStartASecondSync() = runTest { + val fhirDataStore = newFhirDataStore() + val fake = RecordingSyncManager(fhirDataStore) { SyncJobStatus.Succeeded() } + val viewModel = HomeViewModel(fake, fhirDataStore) + + val first = viewModel.syncNow() + val second = viewModel.syncNow() + first?.join() + + assertNull(second) + assertEquals(1, fake.invocationCount) + } + + @Test + fun cancelSync_whileSyncing_callsUseCaseCancelAndSetsCancelledMessage() = runTest { + val fhirDataStore = newFhirDataStore() + val releaseSyncResult = CompletableDeferred() + val fake = RecordingSyncManager(fhirDataStore) { releaseSyncResult.await() } + val viewModel = HomeViewModel(fake, fhirDataStore) + + val job = viewModel.syncNow() + viewModel.cancelSync() + releaseSyncResult.complete(SyncJobStatus.Failed()) + job?.join() + + assertEquals(1, fake.cancelCount) + val state = viewModel.uiState.value + assertEquals(false, state.isSyncing) + assertEquals(SyncError.Cancelled, state.syncError) + } + + @Test + fun cancelSync_whileNotSyncing_isNoOp() = runTest { + val fhirDataStore = newFhirDataStore() + val fake = RecordingSyncManager(fhirDataStore) { SyncJobStatus.Succeeded() } + val viewModel = HomeViewModel(fake, fhirDataStore) + + viewModel.cancelSync() + + assertEquals(0, fake.cancelCount) + } + + @Test + fun clearSyncError_removesTheErrorMessage() = runTest { + val fhirDataStore = newFhirDataStore() + val viewModel = + HomeViewModel(RecordingSyncManager(fhirDataStore) { SyncJobStatus.Failed() }, fhirDataStore) + viewModel.syncNow()?.join() + + viewModel.clearSyncError() + + assertNull(viewModel.uiState.value.syncError) + } +} diff --git a/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/feature/login/LoginScreenTest.kt b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/feature/login/LoginScreenTest.kt new file mode 100644 index 00000000..6715ed47 --- /dev/null +++ b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/feature/login/LoginScreenTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.feature.login + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class LoginScreenTest { + + @Test + fun tappingSignIn_invokesCallback() = runComposeUiTest { + var clicked = false + setContent { + MaterialTheme { + LoginScreen( + signingIn = false, + error = null, + onSignIn = { clicked = true }, + onErrorDismiss = {}, + ) + } + } + + onNodeWithText("Continue to sign in", ignoreCase = true).performClick() + + assertTrue(clicked) + } + + @Test + fun error_showsDialogWithMessage_andDismissClearsIt() = runComposeUiTest { + var dismissed = false + setContent { + MaterialTheme { + LoginScreen( + signingIn = false, + error = "Sign-in failed: invalid_grant", + onSignIn = {}, + onErrorDismiss = { dismissed = true }, + ) + } + } + + assertTrue( + onAllNodesWithText("Sign-in failed: invalid_grant").fetchSemanticsNodes().isNotEmpty() + ) + onNodeWithText("Dismiss", ignoreCase = true).performClick() + + assertTrue(dismissed) + } +} diff --git a/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/feature/sync/InitialSyncScreenTest.kt b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/feature/sync/InitialSyncScreenTest.kt new file mode 100644 index 00000000..b9bc1bce --- /dev/null +++ b/ohs-player-reference-app/src/jvmTest/kotlin/dev/ohs/player/reference/app/feature/sync/InitialSyncScreenTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.feature.sync + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class InitialSyncScreenTest { + + @Test + fun syncingState_showsBlockingProgressText() = runComposeUiTest { + setContent { + MaterialTheme { + InitialSyncScreen(state = InitialSyncGateState.Syncing, onRetry = {}, onContinueAnyway = {}) + } + } + + assertTrue( + onAllNodesWithText("Setting up your data", substring = true) + .fetchSemanticsNodes() + .isNotEmpty() + ) + } + + @Test + fun failedState_tappingRetry_invokesCallback() = runComposeUiTest { + var retried = false + setContent { + MaterialTheme { + InitialSyncScreen( + state = InitialSyncGateState.Failed, + onRetry = { retried = true }, + onContinueAnyway = {}, + ) + } + } + + onNodeWithText("Retry", ignoreCase = true).performClick() + + assertTrue(retried) + } + + @Test + fun failedState_tappingContinueAnyway_invokesCallback() = runComposeUiTest { + var continued = false + setContent { + MaterialTheme { + InitialSyncScreen( + state = InitialSyncGateState.Failed, + onRetry = {}, + onContinueAnyway = { continued = true }, + ) + } + } + + onNodeWithText("Continue without syncing", ignoreCase = true).performClick() + + assertTrue(continued) + } +} diff --git a/ohs-player-reference-app/src/wasmJsMain/kotlin/dev/ohs/player/reference/app/auth/SecureRandom.wasmJs.kt b/ohs-player-reference-app/src/wasmJsMain/kotlin/dev/ohs/player/reference/app/auth/SecureRandom.wasmJs.kt new file mode 100644 index 00000000..97e23f7c --- /dev/null +++ b/ohs-player-reference-app/src/wasmJsMain/kotlin/dev/ohs/player/reference/app/auth/SecureRandom.wasmJs.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class) + +package dev.ohs.player.reference.app.auth + +import org.khronos.webgl.Int8Array +import org.khronos.webgl.get + +private fun fillRandom(array: Int8Array): Unit = js("crypto.getRandomValues(array)") + +/** Browser CSPRNG via Web Crypto `crypto.getRandomValues`. */ +internal actual fun secureRandomBytes(size: Int): ByteArray { + val array = Int8Array(size) + fillRandom(array) + return ByteArray(size) { array[it] } +} diff --git a/ohs-player-reference-app/src/webMain/kotlin/dev/ohs/player/reference/app/auth/Platform.web.kt b/ohs-player-reference-app/src/webMain/kotlin/dev/ohs/player/reference/app/auth/Platform.web.kt new file mode 100644 index 00000000..0e0f2335 --- /dev/null +++ b/ohs-player-reference-app/src/webMain/kotlin/dev/ohs/player/reference/app/auth/Platform.web.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Open Health Stack Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.ohs.player.reference.app.auth + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import eu.anifantakis.lib.ksafe.KSafe +import kotlinx.browser.window + +internal actual fun createKSafe(): KSafe = KSafe() + +// secureRandomBytes lives in jsMain / wasmJsMain — Web Crypto interop differs +// between the two web targets, so it can't be shared from webMain. + +/** + * Web login uses a full-page redirect to the identity provider (the recommended SPA flow). + * [authorize] navigates away and the page unloads, so it returns [AuthResult.Redirecting]; the + * [PendingAuth] saved in KSafe survives the round-trip. On the next load [consumeRedirectCallback] + * picks up the result. + */ +actual class AuthorizationLauncher(actual override val redirectUri: String) : + AuthorizationLauncherApi { + + actual override suspend fun authorize(authUrl: String): AuthResult { + window.location.href = authUrl + return AuthResult.Redirecting + } + + actual override fun consumeRedirectCallback(): String? { + val search = window.location.search + if (!search.contains("code=") && !search.contains("error=")) return null + val href = window.location.href + // Strip the query so a refresh doesn't replay the (single-use) code. + window.history.replaceState(null, "", window.location.pathname) + return href + } +} + +@Composable +actual fun rememberAuthorizationLauncher(): AuthorizationLauncher = remember { + AuthorizationLauncher(GeneratedAuthConfig.WEB_REDIRECT_URL) +} diff --git a/ohs-player-reference-app/src/webMain/npm/sqlite-wasm-worker/package.json b/ohs-player-reference-app/src/webMain/npm/sqlite-wasm-worker/package.json new file mode 100644 index 00000000..a7639eb9 --- /dev/null +++ b/ohs-player-reference-app/src/webMain/npm/sqlite-wasm-worker/package.json @@ -0,0 +1,8 @@ +{ + "name": "sqlite-wasm-worker", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@sqlite.org/sqlite-wasm": "^3.50.1-build1" + } +} diff --git a/ohs-player-reference-app/src/webMain/npm/sqlite-wasm-worker/worker.js b/ohs-player-reference-app/src/webMain/npm/sqlite-wasm-worker/worker.js new file mode 100644 index 00000000..55edfd68 --- /dev/null +++ b/ohs-player-reference-app/src/webMain/npm/sqlite-wasm-worker/worker.js @@ -0,0 +1,165 @@ +// Web Worker backing the wasmJs SQLite driver for the browser build. It wraps the official +// `@sqlite.org/sqlite-wasm` package (see package.json) and exposes an open/prepare/step/close +// message protocol that the Kotlin/Wasm side drives over `postMessage`. +// +// This must run in a dedicated Worker, not the main thread: persistence uses SQLite's OPFS VFS +// (`sqlite3.oo1.OpfsDb`), which relies on synchronous OPFS access handles that the browser only +// exposes off the main thread. The protocol below is adapted from the worker examples shipped +// with `@sqlite.org/sqlite-wasm`. +import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; + +let sqlite3 = null; + +// Maps to track of active database connections and prepared statements by their unique IDs. +const databases = new Map(); // stores databaseId -> SQLiteDbObject +const statements = new Map(); // stores statementId -> SQLiteStatementObject + +// Counters to generate unique IDs for new database connections and statements. +let nextDatabaseId = 0; +let nextStatementId = 0; + +function openRequest(id, requestData) { + try { + const newDatabaseId = nextDatabaseId++; + const newDatabase = new sqlite3.oo1.OpfsDb(requestData.fileName); + databases.set(newDatabaseId, newDatabase); + postMessage({'id': id, data: {'databaseId': newDatabaseId}}); + } catch (error) { + postMessage({'id': id, error: error.message}); + } +} + +function prepareRequest(id, requestData) { + try { + const newStatementId = nextStatementId++; + const resultData = { + 'statementId': newStatementId, + 'parameterCount': 0, + 'columnNames': [] + }; + const database = databases.get(requestData.databaseId); + if (!database) { + postMessage({'id': id, error: "Invalid database ID: " + requestData.databaseId}); + return; + } + const statement = database.prepare(requestData.sql); + statements.set(newStatementId, statement); + resultData.parameterCount = sqlite3.capi.sqlite3_bind_parameter_count(statement); + for (let i = 0; i < statement.columnCount; i++) { + resultData.columnNames.push(sqlite3.capi.sqlite3_column_name(statement, i)); + } + postMessage({'id': id, data: resultData}); + } catch (error) { + postMessage({'id': id, error: error.message}); + } +} + +function stepRequest(id, requestData) { + const statement = statements.get(requestData.statementId); + if (!statement) { + postMessage({'id': id, error: "Invalid statement ID: " + requestData.statementId}); + return; + } + try { + const resultData = { + 'rows': [], + 'columnTypes': [] + }; + statement.reset() + statement.clearBindings() + for (let i = 0; i < requestData.bindings.length; i++) { + statement.bind(i + 1, requestData.bindings[i]); + } + while (statement.step()) { + if (!resultData.columnTypes.length) { + for (let i = 0; i < statement.columnCount; i++) { + resultData.columnTypes.push(sqlite3.capi.sqlite3_column_type(statement, i)); + } + } + resultData.rows.push(statement.get([])); + } + postMessage({'id': id, data: resultData}); + } catch (error) { + postMessage({'id': id, error: error.message}); + } +} + +function closeRequest(id, requestData) { + if (requestData.statementId) { + const statement = statements.get(requestData.statementId); + if (!statement) { + postMessage({'id': id, error: "Invalid statement ID: " + requestData.statementId}); + return; + } + try { + statement.finalize(); + statements.delete(requestData.statementId); + } catch (error) { + postMessage({'id': id, error: error.message}); + } + } + + if (requestData.databaseId) { + const database = databases.get(requestData.databaseId); + if (!database) { + postMessage({'id': id, error: "Invalid database ID: " + requestData.databaseId}); + return; + } + try { + database.close(); + databases.delete(requestData.databaseId); + } catch (error) { + postMessage({'id': id, error: error.message}); + } + } +} + +// A map that links command names (strings) to their respective handler functions. +const commandMap = { + 'open': openRequest, + 'prepare': prepareRequest, + 'step': stepRequest, + 'close': closeRequest, +}; + +function handleMessage(e) { + const requestMsg = e.data; + console.log("handleMessage: " + JSON.stringify(requestMsg)); + if (!Object.hasOwn(requestMsg, 'data') || requestMsg.data == null) { + postMessage( + {'id': requestMsg.id, 'error': "Invalid request, missing 'data'."} + ); + return; + } + if (!Object.hasOwn(requestMsg.data, 'cmd') || requestMsg.data.cmd == null) { + postMessage( + {'id': requestMsg.id, 'error': "Invalid request, missing 'cmd'."} + ); + return; + } + const command = requestMsg.data.cmd; + const requestHandler = commandMap[command]; + if (requestHandler) { + requestHandler(requestMsg.id, requestMsg.data); + } else { + postMessage( + {'id': requestMsg.id, 'error': "Invalid request, unknown command: '" + command + "'."} + ); + } +} + +const messageQueue = []; +onmessage = (e) => { + if (!sqlite3) { + messageQueue.push(e); + } else { + handleMessage(e); + } +}; + +sqlite3InitModule().then(instance => { + sqlite3 = instance; + while (messageQueue.length > 0) { + handleMessage(messageQueue.shift()); + } +}); diff --git a/ohs-player-reference-app/src/webMain/resources/favicon.ico b/ohs-player-reference-app/src/webMain/resources/favicon.ico new file mode 100644 index 00000000..3f857a7f Binary files /dev/null and b/ohs-player-reference-app/src/webMain/resources/favicon.ico differ diff --git a/ohs-player-reference-app/src/webMain/resources/favicon.svg b/ohs-player-reference-app/src/webMain/resources/favicon.svg new file mode 100644 index 00000000..f648e718 --- /dev/null +++ b/ohs-player-reference-app/src/webMain/resources/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ohs-player-reference-app/src/webMain/resources/index.html b/ohs-player-reference-app/src/webMain/resources/index.html index 20421019..807b52da 100644 --- a/ohs-player-reference-app/src/webMain/resources/index.html +++ b/ohs-player-reference-app/src/webMain/resources/index.html @@ -3,7 +3,9 @@ - OhsPlayerReferenceClientApp + Player Reference + +