From 8ca5c93bab3587c763b76e934536807d38408b60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:18:06 +0000 Subject: [PATCH 01/13] Initial plan From 71433bc383ab9497c5d7bd4144fd606e7f1b354e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:25:25 +0000 Subject: [PATCH 02/13] Implement core VHL support with URI decoding and manifest processing Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- .../java/org/who/gdhcnvalidator/QRDecoder.kt | 80 +++++++- .../hcert/healthlink/HealthLinkMapper.kt | 95 +++++++++- .../hcert/healthlink/SmartHealthLinkModel.kt | 20 +- .../verify/hcert/healthlink/VhlVerifier.kt | 173 ++++++++++++++++++ .../gdhcnvalidator/verify/VhlQRDecoderTest.kt | 55 ++++++ .../hcert/healthlink/VhlVerifierTest.kt | 67 +++++++ 6 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt create mode 100644 verify/src/test/java/org/who/gdhcnvalidator/verify/VhlQRDecoderTest.kt create mode 100644 verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifierTest.kt diff --git a/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt b/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt index 2e365d68..9060ad25 100644 --- a/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt +++ b/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt @@ -5,6 +5,7 @@ import org.hl7.fhir.r4.model.Composition import org.who.gdhcnvalidator.trust.TrustRegistry import org.who.gdhcnvalidator.verify.divoc.DivocVerifier import org.who.gdhcnvalidator.verify.hcert.HCertVerifier +import org.who.gdhcnvalidator.verify.hcert.healthlink.VhlVerifier import org.who.gdhcnvalidator.verify.icao.IcaoVerifier import org.who.gdhcnvalidator.verify.shc.ShcVerifier @@ -25,6 +26,9 @@ class QRDecoder(private val registry: TrustRegistry) { REVOKED_KEYS, // keys were revoked by the issuer INVALID_SIGNATURE, // signature doesn't match VERIFIED, // Verified content. + VHL_REQUIRES_PIN, // VHL requires PIN entry + VHL_INVALID_URI, // VHL URI could not be decoded + VHL_FETCH_ERROR, // VHL manifest could not be fetched } data class VerificationResult ( @@ -32,12 +36,27 @@ class QRDecoder(private val registry: TrustRegistry) { var contents: Bundle?, // the Composition var issuer: TrustRegistry.TrustedEntity?, var qr: String, - var unpacked: String? + var unpacked: String?, + var vhlInfo: VhlInfo? = null // Additional VHL-specific information ) { fun composition() = contents?.entry?.filter { it.resource is Composition }?.firstOrNull()?.resource as Composition? } + + /** + * Additional information for VHL processing + */ + data class VhlInfo( + val decodedLink: VhlVerifier.VhlDecodedLink?, + val requiresPin: Boolean = false, + val fileList: List? = null + ) fun decode(qrPayload : String): VerificationResult { + // Check for VHL/SHL URIs first + if (qrPayload.startsWith("vhlink:/") || qrPayload.startsWith("shlink:/")) { + return processVhlUri(qrPayload) + } + if (qrPayload.uppercase().startsWith("HC1:")) { return HCertVerifier(registry).unpackAndVerify(qrPayload) } @@ -53,4 +72,63 @@ class QRDecoder(private val registry: TrustRegistry) { return VerificationResult(Status.NOT_SUPPORTED, null, null, qrPayload, null) } + + /** + * Process VHL URI and return initial verification result + * Full VHL processing with PIN entry and manifest fetching happens in the UI layer + */ + private fun processVhlUri(qrPayload: String): VerificationResult { + val vhlVerifier = VhlVerifier() + val decodedLink = vhlVerifier.decodeVhlUri(qrPayload) + + return if (decodedLink != null) { + val requiresPin = vhlVerifier.isPinRequired(decodedLink) + val vhlInfo = VhlInfo( + decodedLink = decodedLink, + requiresPin = requiresPin + ) + + if (requiresPin) { + VerificationResult( + status = Status.VHL_REQUIRES_PIN, + contents = null, + issuer = null, + qr = qrPayload, + unpacked = null, + vhlInfo = vhlInfo + ) + } else { + // Try to fetch manifest without PIN + val manifest = vhlVerifier.fetchManifest(VhlVerifier.VhlManifestRequest(decodedLink.url)) + if (manifest != null) { + val fileList = vhlVerifier.extractFileList(manifest) + VerificationResult( + status = Status.VERIFIED, + contents = manifest, + issuer = null, + qr = qrPayload, + unpacked = decodedLink.url, + vhlInfo = vhlInfo.copy(fileList = fileList) + ) + } else { + VerificationResult( + status = Status.VHL_FETCH_ERROR, + contents = null, + issuer = null, + qr = qrPayload, + unpacked = decodedLink.url, + vhlInfo = vhlInfo + ) + } + } + } else { + VerificationResult( + status = Status.VHL_INVALID_URI, + contents = null, + issuer = null, + qr = qrPayload, + unpacked = null + ) + } + } } \ No newline at end of file diff --git a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt index 9b591be0..1824b805 100644 --- a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt +++ b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt @@ -1,14 +1,103 @@ package org.who.gdhcnvalidator.verify.hcert.healthlink import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Composition +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Reference import org.who.gdhcnvalidator.verify.BaseMapper +import java.util.* /** - * Translates a QR CBOR object into FHIR Objects + * Translates Smart Health Links and Verifiable Health Links (VHL) into FHIR Objects */ class HealthLinkMapper: BaseMapper() { + + private val vhlVerifier = VhlVerifier() + fun run(link: SmartHealthLinkModel): Bundle { - // TODO: how do we parse this? - return Bundle() + return if (link.isVHL()) { + processVhl(link) + } else { + processRegularShl(link) + } + } + + /** + * Process Verifiable Health Link (VHL) + * Creates a placeholder bundle that indicates VHL processing is needed + */ + private fun processVhl(link: SmartHealthLinkModel): Bundle { + val bundle = Bundle() + bundle.id = UUID.randomUUID().toString() + bundle.type = Bundle.BundleType.DOCUMENT + + // Create a composition to indicate this is a VHL + val composition = Composition() + composition.id = UUID.randomUUID().toString() + composition.status = Composition.CompositionStatus.FINAL + composition.type = createCodeableConcept("VHL", "Verifiable Health Link") + composition.title = "Verifiable Health Link" + composition.date = Date() + + // Add VHL URI as a section + val section = Composition.SectionComponent() + section.title = "VHL URI" + section.text = createNarrative("VHL URI: ${link.getUri()}") + composition.section = listOf(section) + + // Create placeholder patient + val patient = Patient() + patient.id = UUID.randomUUID().toString() + composition.subject = Reference("Patient/${patient.id}") + + // Add to bundle + bundle.addEntry().setResource(composition) + bundle.addEntry().setResource(patient) + + return bundle + } + + /** + * Process regular Smart Health Link (SHL) + * Currently returns placeholder bundle + */ + private fun processRegularShl(link: SmartHealthLinkModel): Bundle { + val bundle = Bundle() + bundle.id = UUID.randomUUID().toString() + bundle.type = Bundle.BundleType.DOCUMENT + + val composition = Composition() + composition.id = UUID.randomUUID().toString() + composition.status = Composition.CompositionStatus.FINAL + composition.type = createCodeableConcept("SHL", "Smart Health Link") + composition.title = "Smart Health Link" + composition.date = Date() + + // Create placeholder patient + val patient = Patient() + patient.id = UUID.randomUUID().toString() + composition.subject = Reference("Patient/${patient.id}") + + bundle.addEntry().setResource(composition) + bundle.addEntry().setResource(patient) + + return bundle + } + + private fun createCodeableConcept(code: String, display: String): org.hl7.fhir.r4.model.CodeableConcept { + val concept = org.hl7.fhir.r4.model.CodeableConcept() + val coding = org.hl7.fhir.r4.model.Coding() + coding.code = code + coding.display = display + concept.coding = listOf(coding) + return concept + } + + private fun createNarrative(text: String): org.hl7.fhir.r4.model.Narrative { + val narrative = org.hl7.fhir.r4.model.Narrative() + narrative.status = org.hl7.fhir.r4.model.Narrative.NarrativeStatus.GENERATED + narrative.div = org.hl7.fhir.utilities.xhtml.XhtmlNode() + narrative.div.addTag("div").addText(text) + return narrative } } \ No newline at end of file diff --git a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt index 890971d7..0e33524e 100644 --- a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt +++ b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt @@ -3,6 +3,24 @@ package org.who.gdhcnvalidator.verify.hcert.healthlink import org.hl7.fhir.r4.model.StringType import org.who.gdhcnvalidator.verify.BaseModel +/** + * Model for Smart Health Links and Verifiable Health Links (VHL) + * Supports both SHL (shlink:/) and VHL (vhlink:/) URI formats + */ open class SmartHealthLinkModel ( val u: StringType? -): BaseModel() \ No newline at end of file +): BaseModel() { + + /** + * Checks if this is a VHL (Verifiable Health Link) based on URI prefix + */ + fun isVHL(): Boolean { + val uri = u?.value ?: return false + return uri.startsWith("vhlink:/") || uri.startsWith("shlink:/") + } + + /** + * Gets the raw URI value + */ + fun getUri(): String? = u?.value +} \ No newline at end of file diff --git a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt new file mode 100644 index 00000000..b8503f4d --- /dev/null +++ b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt @@ -0,0 +1,173 @@ +package org.who.gdhcnvalidator.verify.hcert.healthlink + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.ListResource +import java.net.URI +import java.net.URLDecoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.util.* +import kotlin.text.Charsets.UTF_8 + +/** + * Verifies and processes Verifiable Health Links (VHL) according to the VHL specification + * https://build.fhir.org/ig/IHE/ITI.VHL/branches/master/volume-1.html + */ +class VhlVerifier { + + data class VhlManifestRequest( + val url: String, + val pin: String? = null + ) + + data class VhlDecodedLink( + val url: String, + val flag: String? = null, + val key: String? = null, + val label: String? = null, + val exp: Long? = null + ) + + private val mapper = jacksonObjectMapper() + private val httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .build() + + /** + * Decodes a VHL URI (vhlink:/ or shlink:/) to extract the manifest URL + */ + fun decodeVhlUri(uri: String): VhlDecodedLink? { + return try { + val payload = when { + uri.startsWith("vhlink:/") -> uri.substring(8) + uri.startsWith("shlink:/") -> uri.substring(8) + else -> return null + } + + val decodedBytes = Base64.getUrlDecoder().decode(payload) + val decodedJson = String(decodedBytes, UTF_8) + mapper.readValue(decodedJson, VhlDecodedLink::class.java) + } catch (e: Exception) { + null + } + } + + /** + * Fetches the VHL manifest from the decoded URL + * Returns a FHIR SearchSet Bundle containing List resources and included items + */ + fun fetchManifest(request: VhlManifestRequest): Bundle? { + return try { + val url = if (request.pin != null) { + // Add PIN parameter if provided + val separator = if (request.url.contains("?")) "&" else "?" + "${request.url}${separator}recipient=${URLDecoder.decode(request.pin, "UTF-8")}" + } else { + request.url + } + + val httpRequest = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "application/fhir+json") + .GET() + .build() + + val response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()) + + if (response.statusCode() == 200) { + mapper.readValue(response.body(), Bundle::class.java) + } else { + null + } + } catch (e: Exception) { + null + } + } + + /** + * Checks if a PIN is required for accessing the manifest + * This is typically indicated by specific flags in the decoded link or 401 response + */ + fun isPinRequired(decodedLink: VhlDecodedLink): Boolean { + // Check if flag indicates PIN is required (P flag for password/PIN required) + return decodedLink.flag?.contains("P") == true + } + + /** + * Extracts file information from the VHL manifest + * Returns list of file metadata for display to user + */ + fun extractFileList(manifest: Bundle): List { + val files = mutableListOf() + + // Process List resources and their included items + manifest.entry?.forEach { entry -> + when (val resource = entry.resource) { + is ListResource -> { + resource.entry?.forEach { listEntry -> + val reference = listEntry.item?.reference + if (reference != null) { + // Find the referenced resource in the Bundle + val referencedResource = manifest.entry?.find { + it.resource?.id == reference.substringAfter("/") + }?.resource + + if (referencedResource != null) { + files.add(extractFileInfo(referencedResource)) + } + } + } + } + } + } + + return files + } + + private fun extractFileInfo(resource: org.hl7.fhir.r4.model.Resource): VhlFileInfo { + // Extract file information based on resource type + return when (resource.resourceType.name) { + "DocumentReference" -> { + val docRef = resource as org.hl7.fhir.r4.model.DocumentReference + VhlFileInfo( + id = docRef.id ?: "unknown", + type = "PDF", // Assume PDF for DocumentReference + title = docRef.description ?: "Document", + url = docRef.content?.firstOrNull()?.attachment?.url, + size = docRef.content?.firstOrNull()?.attachment?.size + ) + } + "Bundle" -> { + val bundle = resource as Bundle + VhlFileInfo( + id = bundle.id ?: "unknown", + type = "FHIR_IPS", // Assume IPS for Bundle + title = "FHIR IPS Document", + content = bundle // Store the bundle for direct processing + ) + } + else -> { + VhlFileInfo( + id = resource.id ?: "unknown", + type = "UNKNOWN", + title = "Unknown Resource Type: ${resource.resourceType.name}" + ) + } + } + } +} + +/** + * Represents a file available in a VHL manifest + */ +data class VhlFileInfo( + val id: String, + val type: String, // "PDF", "FHIR_IPS", "UNKNOWN" + val title: String, + val url: String? = null, + val size: Long? = null, + val content: Any? = null // For direct FHIR content +) \ No newline at end of file diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlQRDecoderTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlQRDecoderTest.kt new file mode 100644 index 00000000..a96bf5ae --- /dev/null +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlQRDecoderTest.kt @@ -0,0 +1,55 @@ +package org.who.gdhcnvalidator.verify + +import org.junit.Assert.* +import org.junit.Test +import org.who.gdhcnvalidator.QRDecoder +import org.who.gdhcnvalidator.test.BaseTrustRegistryTest +import java.util.* + +class VhlQRDecoderTest : BaseTrustRegistryTest() { + + @Test + fun testVhlUriDetection() { + val decoder = QRDecoder(registry) + + // Create a sample VHL URI + val testData = """{"url":"https://example.com/manifest","flag":"P"}""" + val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) + val vhlUri = "vhlink:/$encodedData" + + val result = decoder.decode(vhlUri) + + assertEquals("Should detect VHL and require PIN", QRDecoder.Status.VHL_REQUIRES_PIN, result.status) + assertNotNull("Should have VHL info", result.vhlInfo) + assertTrue("Should require PIN", result.vhlInfo?.requiresPin == true) + assertEquals("Should preserve original QR", vhlUri, result.qr) + } + + @Test + fun testShlUriDetection() { + val decoder = QRDecoder(registry) + + // Create a sample SHL URI (no PIN required) + val testData = """{"url":"https://example.com/manifest"}""" + val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) + val shlUri = "shlink:/$encodedData" + + val result = decoder.decode(shlUri) + + // Since we can't actually fetch the manifest, expect fetch error + assertEquals("Should detect SHL but fail to fetch", QRDecoder.Status.VHL_FETCH_ERROR, result.status) + assertNotNull("Should have VHL info", result.vhlInfo) + assertFalse("Should not require PIN", result.vhlInfo?.requiresPin == true) + assertEquals("Should preserve original QR", shlUri, result.qr) + } + + @Test + fun testInvalidVhlUri() { + val decoder = QRDecoder(registry) + + val result = decoder.decode("vhlink:/invalid-base64-data") + + assertEquals("Should detect invalid VHL URI", QRDecoder.Status.VHL_INVALID_URI, result.status) + assertNull("Should not have VHL info", result.vhlInfo) + } +} \ No newline at end of file diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifierTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifierTest.kt new file mode 100644 index 00000000..050d586a --- /dev/null +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifierTest.kt @@ -0,0 +1,67 @@ +package org.who.gdhcnvalidator.verify.hcert.healthlink + +import org.junit.Assert.* +import org.junit.Test +import org.who.gdhcnvalidator.test.BaseTrustRegistryTest +import java.util.* + +class VhlVerifierTest : BaseTrustRegistryTest() { + + @Test + fun testDecodeVhlUri() { + val vhlVerifier = VhlVerifier() + + // Create a sample VHL URI with base64 encoded JSON + val testData = """{"url":"https://example.com/manifest","flag":"P"}""" + val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) + val vhlUri = "vhlink:/$encodedData" + + val result = vhlVerifier.decodeVhlUri(vhlUri) + + assertNotNull("Should decode VHL URI successfully", result) + assertEquals("Should extract correct URL", "https://example.com/manifest", result?.url) + assertEquals("Should extract correct flag", "P", result?.flag) + } + + @Test + fun testDecodeShlUri() { + val vhlVerifier = VhlVerifier() + + // Create a sample SHL URI + val testData = """{"url":"https://example.com/shl-manifest"}""" + val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) + val shlUri = "shlink:/$encodedData" + + val result = vhlVerifier.decodeVhlUri(shlUri) + + assertNotNull("Should decode SHL URI successfully", result) + assertEquals("Should extract correct URL", "https://example.com/shl-manifest", result?.url) + } + + @Test + fun testInvalidUri() { + val vhlVerifier = VhlVerifier() + + val result = vhlVerifier.decodeVhlUri("invalid-uri") + + assertNull("Should return null for invalid URI", result) + } + + @Test + fun testIsPinRequired() { + val vhlVerifier = VhlVerifier() + + val linkWithPin = VhlVerifier.VhlDecodedLink( + url = "https://example.com/manifest", + flag = "P" + ) + + val linkWithoutPin = VhlVerifier.VhlDecodedLink( + url = "https://example.com/manifest", + flag = null + ) + + assertTrue("Should require PIN when flag contains P", vhlVerifier.isPinRequired(linkWithPin)) + assertFalse("Should not require PIN when flag is null", vhlVerifier.isPinRequired(linkWithoutPin)) + } +} \ No newline at end of file From 576374047817ad6b1ad6a9f960aa37615d07c531 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:31:06 +0000 Subject: [PATCH 03/13] Add VHL UI components and update documentation Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- .../gdhcnvalidator/views/ResultFragment.kt | 297 +++++++++++++++--- app/src/main/res/values/strings.xml | 5 + docs/data-models.md | 25 +- docs/user-workflows.md | 10 + 4 files changed, 296 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt b/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt index 280ae4d3..d73fb1ef 100644 --- a/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt +++ b/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt @@ -1,14 +1,16 @@ package org.who.gdhcnvalidator.views import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Intent import android.graphics.PorterDuff +import android.net.Uri import android.os.Bundle import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.LinearLayout -import android.widget.TextView +import android.widget.* import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -21,6 +23,7 @@ import org.who.gdhcnvalidator.R import org.who.gdhcnvalidator.databinding.FragmentResultBinding import org.who.gdhcnvalidator.services.DDCCFormatter import org.who.gdhcnvalidator.trust.TrustRegistry +import org.who.gdhcnvalidator.verify.hcert.healthlink.VhlVerifier import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue @@ -45,6 +48,10 @@ class ResultFragment : Fragment() { QRDecoder.Status.REVOKED_KEYS to R.string.verification_status_revoked_keys, QRDecoder.Status.INVALID_SIGNATURE to R.string.verification_status_invalid_signature, QRDecoder.Status.VERIFIED to R.string.verification_status_verified, + // VHL specific statuses (we'll need to add these strings) + QRDecoder.Status.VHL_REQUIRES_PIN to R.string.vhl_status_requires_pin, + QRDecoder.Status.VHL_INVALID_URI to R.string.vhl_status_invalid_uri, + QRDecoder.Status.VHL_FETCH_ERROR to R.string.vhl_status_fetch_error, ) override fun onCreateView( @@ -152,42 +159,51 @@ class ResultFragment : Fragment() { if (DDCC.contents != null) { binding?.tvResultCard?.visibility = TextView.VISIBLE - val card = DDCCFormatter().run(DDCC.composition()!!) - - // Credential - setTextView(binding?.tvResultScanDate, card.cardTitle, binding?.tvResultScanDate) - setTextView(binding?.tvResultValidUntil, card.validUntil, binding?.llResultValidUntil) - - // Patient - setTextView(binding?.tvResultName, card.personName, binding?.tvResultName) - setTextView(binding?.tvResultPersonDetails, card.personDetails, binding?.tvResultPersonDetails) - setTextView(binding?.tvResultIdentifier, card.identifier, binding?.tvResultIdentifier) - - // Location, Practice, Practitioner - setTextView(binding?.tvResultHcid, card.hcid, binding?.llResultHcid) - setTextView(binding?.tvResultPha, card.pha, binding?.llResultPha) - setTextView(binding?.tvResultHw, card.hw, binding?.llResultHw) - - // Test Result - setTextView(binding?.tvResultTestType, card.testType, binding?.tvResultTestType) - setTextView(binding?.tvResultTestTypeDetail, card.testTypeDetail, binding?.llResultTestTypeDetail) - setTextView(binding?.tvResultTestDate, card.testDate, binding?.llResultTestDate) - setTextView(binding?.tvResultTestTitle, card.testResult, binding?.tvResultTestTitle) - - // Immunization - setTextView(binding?.tvResultVaccineType, card.vaccineType, binding?.tvResultVaccineType) - setTextView(binding?.tvResultDoseTitle, card.dose, binding?.tvResultDoseTitle) - setTextView(binding?.tvResultDoseDate, card.doseDate, binding?.llResultDoseDate) - setTextView(binding?.tvResultVaccineValid, card.vaccineValid, binding?.llResultVaccineValid) - setTextView(binding?.tvResultVaccineInfo, card.vaccineInfo, binding?.llResultVaccineInfo) - setTextView(binding?.tvResultVaccineInfo2, card.vaccineInfo2, binding?.llResultVaccineInfo2) - setTextView(binding?.tvResultCentre, card.location, binding?.llResultCentre) - - // Recommendation - setTextView(binding?.tvResultNextDose, card.nextDose, binding?.llResultNextDose) - - // Status - binding?.llResultStatus?.removeAllViews() + // Check if this is a VHL result with file list + if (DDCC.vhlInfo?.fileList != null) { + showVhlFileList(DDCC.vhlInfo.fileList) + } else { + // Traditional health certificate display + val card = DDCCFormatter().run(DDCC.composition()!!) + + // Credential + setTextView(binding?.tvResultScanDate, card.cardTitle, binding?.tvResultScanDate) + setTextView(binding?.tvResultValidUntil, card.validUntil, binding?.llResultValidUntil) + + // Patient + setTextView(binding?.tvResultName, card.personName, binding?.tvResultName) + setTextView(binding?.tvResultPersonDetails, card.personDetails, binding?.tvResultPersonDetails) + setTextView(binding?.tvResultIdentifier, card.identifier, binding?.tvResultIdentifier) + + // Location, Practice, Practitioner + setTextView(binding?.tvResultHcid, card.hcid, binding?.llResultHcid) + setTextView(binding?.tvResultPha, card.pha, binding?.llResultPha) + setTextView(binding?.tvResultHw, card.hw, binding?.llResultHw) + + // Test Result + setTextView(binding?.tvResultTestType, card.testType, binding?.tvResultTestType) + setTextView(binding?.tvResultTestTypeDetail, card.testTypeDetail, binding?.llResultTestTypeDetail) + setTextView(binding?.tvResultTestDate, card.testDate, binding?.llResultTestDate) + setTextView(binding?.tvResultTestTitle, card.testResult, binding?.tvResultTestTitle) + + // Immunization + setTextView(binding?.tvResultVaccineType, card.vaccineType, binding?.tvResultVaccineType) + setTextView(binding?.tvResultDoseTitle, card.dose, binding?.tvResultDoseTitle) + setTextView(binding?.tvResultDoseDate, card.doseDate, binding?.llResultDoseDate) + setTextView(binding?.tvResultVaccineValid, card.vaccineValid, binding?.llResultVaccineValid) + setTextView(binding?.tvResultVaccineInfo, card.vaccineInfo, binding?.llResultVaccineInfo) + setTextView(binding?.tvResultVaccineInfo2, card.vaccineInfo2, binding?.llResultVaccineInfo2) + setTextView(binding?.tvResultCentre, card.location, binding?.llResultCentre) + + // Recommendation + setTextView(binding?.tvResultNextDose, card.nextDose, binding?.llResultNextDose) + + // Status + binding?.llResultStatus?.removeAllViews() + } + } else if (DDCC.status == QRDecoder.Status.VHL_REQUIRES_PIN) { + // Show PIN entry interface + showVhlPinEntry(DDCC) } } @@ -281,6 +297,211 @@ class ResultFragment : Fragment() { null } } + + /** + * Shows PIN entry dialog for VHL + */ + private fun showVhlPinEntry(vhlResult: QRDecoder.VerificationResult) { + val input = EditText(requireContext()) + input.hint = "Enter PIN" + input.inputType = android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD + + AlertDialog.Builder(requireContext()) + .setTitle("PIN Required") + .setMessage("This Verifiable Health Link requires a PIN to access the manifest.") + .setView(input) + .setPositiveButton("OK") { _, _ -> + val pin = input.text.toString() + fetchVhlManifestWithPin(vhlResult, pin) + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.cancel() + } + .show() + } + + /** + * Fetches VHL manifest with PIN and updates the display + */ + private fun fetchVhlManifestWithPin(vhlResult: QRDecoder.VerificationResult, pin: String) { + CoroutineScope(Dispatchers.Main + Job()).launch { + val decodedLink = vhlResult.vhlInfo?.decodedLink + if (decodedLink != null) { + withContext(Dispatchers.IO) { + val vhlVerifier = VhlVerifier() + val request = VhlVerifier.VhlManifestRequest(decodedLink.url, pin) + val manifest = vhlVerifier.fetchManifest(request) + + withContext(Dispatchers.Main) { + if (manifest != null) { + val fileList = vhlVerifier.extractFileList(manifest) + showVhlFileList(fileList) + } else { + Toast.makeText(requireContext(), "Failed to fetch manifest. Check your PIN.", Toast.LENGTH_LONG).show() + } + } + } + } + } + } + + /** + * Shows the list of files available in the VHL manifest + */ + private fun showVhlFileList(fileList: List) { + // Clear existing content and show file list + clearResultFields() + + // Get the main content container + val resultCard = binding?.root?.findViewById(R.id.tv_result_card2) + resultCard?.removeAllViews() + + // Add header + val header = TextView(requireContext()) + header.text = "Available Files" + header.textSize = 18f + header.setTypeface(null, android.graphics.Typeface.BOLD) + header.setPadding(0, 0, 0, 16) + resultCard?.addView(header) + + // Add each file as a clickable item + fileList.forEach { file -> + val fileItem = createFileListItem(file) + resultCard?.addView(fileItem) + } + + binding?.tvResultCard?.visibility = View.VISIBLE + } + + /** + * Creates a clickable item for each file in the VHL manifest + */ + private fun createFileListItem(file: VhlVerifier.VhlFileInfo): View { + val fileItem = LinearLayout(requireContext()) + fileItem.orientation = LinearLayout.HORIZONTAL + fileItem.setPadding(16, 16, 16, 16) + fileItem.isClickable = true + + // Add some background styling + val typedValue = TypedValue() + requireContext().theme.resolveAttribute(android.R.attr.selectableItemBackground, typedValue, true) + fileItem.setBackgroundResource(typedValue.resourceId) + + // File icon based on type + val icon = ImageView(requireContext()) + when (file.type) { + "PDF" -> icon.setImageResource(android.R.drawable.ic_menu_edit) + "FHIR_IPS" -> icon.setImageResource(android.R.drawable.ic_menu_info_details) + else -> icon.setImageResource(android.R.drawable.ic_menu_help) + } + icon.layoutParams = LinearLayout.LayoutParams(64, 64) + fileItem.addView(icon) + + // File details + val textContainer = LinearLayout(requireContext()) + textContainer.orientation = LinearLayout.VERTICAL + textContainer.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + textContainer.setPadding(16, 0, 0, 0) + + val title = TextView(requireContext()) + title.text = file.title + title.textSize = 16f + title.setTypeface(null, android.graphics.Typeface.BOLD) + textContainer.addView(title) + + val subtitle = TextView(requireContext()) + subtitle.text = "Type: ${file.type}" + subtitle.textSize = 14f + textContainer.addView(subtitle) + + if (file.size != null) { + val size = TextView(requireContext()) + size.text = "Size: ${file.size} bytes" + size.textSize = 12f + textContainer.addView(size) + } + + fileItem.addView(textContainer) + + // Click handler + fileItem.setOnClickListener { + handleFileClick(file) + } + + // Add some margin between items + val layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + layoutParams.setMargins(0, 8, 0, 8) + fileItem.layoutParams = layoutParams + + return fileItem + } + + /** + * Handles clicks on file items + */ + private fun handleFileClick(file: VhlVerifier.VhlFileInfo) { + when (file.type) { + "PDF" -> { + if (file.url != null) { + // Open PDF in browser or external app + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(file.url)) + startActivity(intent) + } else { + Toast.makeText(requireContext(), "PDF URL not available", Toast.LENGTH_SHORT).show() + } + } + "FHIR_IPS" -> { + // Display FHIR IPS content + showFhirIpsDialog(file) + } + else -> { + Toast.makeText(requireContext(), "File type not supported: ${file.type}", Toast.LENGTH_SHORT).show() + } + } + } + + /** + * Shows FHIR IPS content in a dialog + */ + private fun showFhirIpsDialog(file: VhlVerifier.VhlFileInfo) { + AlertDialog.Builder(requireContext()) + .setTitle(file.title) + .setMessage("FHIR IPS Document\n\nThis contains structured health information that would be processed and displayed in a production implementation.") + .setPositiveButton("OK") { dialog, _ -> + dialog.dismiss() + } + .show() + } + + /** + * Clears all result fields for VHL display + */ + private fun clearResultFields() { + binding?.tvResultScanDate?.visibility = View.GONE + binding?.llResultValidUntil?.visibility = View.GONE + binding?.tvResultName?.visibility = View.GONE + binding?.tvResultPersonDetails?.visibility = View.GONE + binding?.tvResultIdentifier?.visibility = View.GONE + binding?.llResultHcid?.visibility = View.GONE + binding?.llResultPha?.visibility = View.GONE + binding?.llResultHw?.visibility = View.GONE + binding?.tvResultTestType?.visibility = View.GONE + binding?.llResultTestTypeDetail?.visibility = View.GONE + binding?.llResultTestDate?.visibility = View.GONE + binding?.tvResultTestTitle?.visibility = View.GONE + binding?.tvResultVaccineType?.visibility = View.GONE + binding?.tvResultDoseTitle?.visibility = View.GONE + binding?.llResultDoseDate?.visibility = View.GONE + binding?.llResultVaccineValid?.visibility = View.GONE + binding?.llResultVaccineInfo?.visibility = View.GONE + binding?.llResultVaccineInfo2?.visibility = View.GONE + binding?.llResultCentre?.visibility = View.GONE + binding?.llResultNextDose?.visibility = View.GONE + binding?.llResultStatus?.visibility = View.GONE + } override fun onDestroyView() { super.onDestroyView() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 043632d3..e5e26812 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,4 +51,9 @@ Invalid Signature Signature Verified Signature Verified (Dev) + + + PIN Required for VHL + Invalid VHL URI + VHL Manifest Fetch Error \ No newline at end of file diff --git a/docs/data-models.md b/docs/data-models.md index 8ab5b1ae..94481305 100644 --- a/docs/data-models.md +++ b/docs/data-models.md @@ -171,13 +171,32 @@ WHO's international vaccination certificate standard. **Mapper:** [`DvcMapper`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/icvp/DvcMapper.kt) -### 7. Smart Health Links +### 7. Smart Health Links and Verifiable Health Links (VHL) **Source:** [`verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt) -Support for Smart Health Links protocol for sharing health data. +Support for Smart Health Links (SHL) and Verifiable Health Links (VHL) protocol for sharing health data. -**Mapper:** [`HealthLinkMapper`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt) (TODO: Implementation pending) +#### SmartHealthLinkModel +**Key Fields:** +- `u: StringType` - URI containing the link to health data manifest + +**VHL Features:** +- Supports both `vhlink:/` and `shlink:/` URI formats +- Base64 decoding of URI payload to extract manifest URL +- PIN requirement detection and handling +- Manifest fetching with optional PIN parameter + +#### VHL Processing Flow +1. **URI Detection**: QR codes starting with `vhlink:/` or `shlink:/` are identified as VHL +2. **URI Decoding**: Base64 decode the URI payload to extract manifest URL and flags +3. **PIN Handling**: If PIN is required (P flag), prompt user for PIN entry +4. **Manifest Fetching**: HTTP request to retrieve FHIR SearchSet Bundle manifest +5. **File Extraction**: Parse manifest to extract available files (PDF, FHIR IPS) +6. **User Interface**: Display file list with download/view options + +**Mapper:** [`HealthLinkMapper`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt) +**Verifier:** [`VhlVerifier`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt) ## Data Flow and Transformation diff --git a/docs/user-workflows.md b/docs/user-workflows.md index 4924bd15..4f4cddc1 100644 --- a/docs/user-workflows.md +++ b/docs/user-workflows.md @@ -147,6 +147,16 @@ Each format parses into its logical data model: - **SHC** → [`JWTPayload`](../verify/src/main/java/org/who/gdhcnvalidator/verify/shc/ShcModel.kt) - **ICAO** → [`IJson`](../verify/src/main/java/org/who/gdhcnvalidator/verify/icao/IcaoModel.kt) - **ICVP** → [`DvcLogicalModel`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/icvp/DvcLogicalModel.kt) +- **VHL/SHL** → [`SmartHealthLinkModel`](../verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt) + +#### 3.4.1 VHL (Verifiable Health Link) Special Processing + +VHL follows a different workflow from traditional health certificates: + +1. **URI Decoding**: `vhlink:/` or `shlink:/` URIs are base64 decoded to extract manifest URL +2. **PIN Handling**: If required, user is prompted for PIN entry +3. **Manifest Fetching**: HTTP request retrieves FHIR SearchSet Bundle with file list +4. **File Presentation**: Available files (PDF, FHIR IPS) are displayed for user selection #### 3.5 FHIR Bundle Generation From 37513569bfe7931c5dff854d10739c59be0f9a97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:33:32 +0000 Subject: [PATCH 04/13] Add comprehensive VHL tests and example QR codes Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- .../src/main/resources/VHL_Examples.txt | 21 +++ .../gdhcnvalidator/verify/VhlExampleQRTest.kt | 73 ++++++++++ .../hcert/healthlink/VhlIntegrationTest.kt | 132 ++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 test-resources/src/main/resources/VHL_Examples.txt create mode 100644 verify/src/test/java/org/who/gdhcnvalidator/verify/VhlExampleQRTest.kt create mode 100644 verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlIntegrationTest.kt diff --git a/test-resources/src/main/resources/VHL_Examples.txt b/test-resources/src/main/resources/VHL_Examples.txt new file mode 100644 index 00000000..3e85c3bd --- /dev/null +++ b/test-resources/src/main/resources/VHL_Examples.txt @@ -0,0 +1,21 @@ +# VHL Example QR Codes for Testing + +## VHL with PIN Required +vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0IiwiZmxhZyI6IlAifQ== + +This decodes to: {"url":"https://example.com/manifest","flag":"P"} + +## VHL without PIN +vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0In0= + +This decodes to: {"url":"https://example.com/manifest"} + +## SHL Example +shlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL3NobC1tYW5pZmVzdCJ9 + +This decodes to: {"url":"https://example.com/shl-manifest"} + +## VHL with Additional Parameters +vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0IiwiZmxhZyI6IlAiLCJrZXkiOiJhYmNkZWYxMjM0NTYiLCJsYWJlbCI6IlZhY2NpbmF0aW9uIFJlY29yZCIsImV4cCI6MTY5ODc2ODAwMH0= + +This decodes to: {"url":"https://example.com/manifest","flag":"P","key":"abcdef123456","label":"Vaccination Record","exp":1698768000} \ No newline at end of file diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlExampleQRTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlExampleQRTest.kt new file mode 100644 index 00000000..607a6e16 --- /dev/null +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlExampleQRTest.kt @@ -0,0 +1,73 @@ +package org.who.gdhcnvalidator.verify + +import org.junit.Assert.* +import org.junit.Test +import org.who.gdhcnvalidator.QRDecoder +import org.who.gdhcnvalidator.test.BaseTrustRegistryTest + +class VhlExampleQRTest : BaseTrustRegistryTest() { + + @Test + fun testRealWorldVhlExamples() { + val decoder = QRDecoder(registry) + + // Test VHL with PIN required + val vhlWithPin = "vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0IiwiZmxhZyI6IlAifQ==" + val resultWithPin = decoder.decode(vhlWithPin) + + assertEquals("Should require PIN", QRDecoder.Status.VHL_REQUIRES_PIN, resultWithPin.status) + assertNotNull("Should have VHL info", resultWithPin.vhlInfo) + assertTrue("Should require PIN", resultWithPin.vhlInfo?.requiresPin == true) + assertEquals("Should extract URL", "https://example.com/manifest", resultWithPin.vhlInfo?.decodedLink?.url) + assertEquals("Should extract flag", "P", resultWithPin.vhlInfo?.decodedLink?.flag) + + // Test VHL without PIN + val vhlWithoutPin = "vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0In0=" + val resultWithoutPin = decoder.decode(vhlWithoutPin) + + assertEquals("Should fail to fetch manifest", QRDecoder.Status.VHL_FETCH_ERROR, resultWithoutPin.status) + assertNotNull("Should have VHL info", resultWithoutPin.vhlInfo) + assertFalse("Should not require PIN", resultWithoutPin.vhlInfo?.requiresPin == true) + assertEquals("Should extract URL", "https://example.com/manifest", resultWithoutPin.vhlInfo?.decodedLink?.url) + + // Test SHL format + val shlExample = "shlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL3NobC1tYW5pZmVzdCJ9" + val shlResult = decoder.decode(shlExample) + + assertEquals("Should fail to fetch SHL manifest", QRDecoder.Status.VHL_FETCH_ERROR, shlResult.status) + assertNotNull("Should have VHL info", shlResult.vhlInfo) + assertEquals("Should extract SHL URL", "https://example.com/shl-manifest", shlResult.vhlInfo?.decodedLink?.url) + + // Test VHL with additional parameters + val vhlFull = "vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0IiwiZmxhZyI6IlAiLCJrZXkiOiJhYmNkZWYxMjM0NTYiLCJsYWJlbCI6IlZhY2NpbmF0aW9uIFJlY29yZCIsImV4cCI6MTY5ODc2ODAwMH0=" + val fullResult = decoder.decode(vhlFull) + + assertEquals("Should require PIN", QRDecoder.Status.VHL_REQUIRES_PIN, fullResult.status) + assertNotNull("Should have VHL info", fullResult.vhlInfo) + assertEquals("Should extract URL", "https://example.com/manifest", fullResult.vhlInfo?.decodedLink?.url) + assertEquals("Should extract flag", "P", fullResult.vhlInfo?.decodedLink?.flag) + assertEquals("Should extract key", "abcdef123456", fullResult.vhlInfo?.decodedLink?.key) + assertEquals("Should extract label", "Vaccination Record", fullResult.vhlInfo?.decodedLink?.label) + assertEquals("Should extract expiration", 1698768000L, fullResult.vhlInfo?.decodedLink?.exp) + } + + @Test + fun testInvalidVhlExamples() { + val decoder = QRDecoder(registry) + + // Test invalid base64 + val invalidBase64 = "vhlink:/invalid-base64-data!!!" + val result1 = decoder.decode(invalidBase64) + assertEquals("Should detect invalid URI", QRDecoder.Status.VHL_INVALID_URI, result1.status) + + // Test invalid JSON + val invalidJson = "vhlink:/dGhpcyBpcyBub3QganNvbg==" // "this is not json" in base64 + val result2 = decoder.decode(invalidJson) + assertEquals("Should detect invalid URI", QRDecoder.Status.VHL_INVALID_URI, result2.status) + + // Test non-VHL URI + val notVhl = "https://example.com/regular-url" + val result3 = decoder.decode(notVhl) + assertEquals("Should not support regular URL", QRDecoder.Status.NOT_SUPPORTED, result3.status) + } +} \ No newline at end of file diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlIntegrationTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlIntegrationTest.kt new file mode 100644 index 00000000..7d01d72b --- /dev/null +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlIntegrationTest.kt @@ -0,0 +1,132 @@ +package org.who.gdhcnvalidator.verify.hcert.healthlink + +import org.hl7.fhir.r4.model.* +import org.junit.Assert.* +import org.junit.Test +import org.who.gdhcnvalidator.test.BaseTrustRegistryTest +import java.util.* + +class VhlIntegrationTest : BaseTrustRegistryTest() { + + @Test + fun testVhlWorkflowWithoutPin() { + val vhlVerifier = VhlVerifier() + + // Create a VHL URI without PIN requirement + val testData = """{"url":"https://example.com/manifest"}""" + val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) + val vhlUri = "vhlink:/$encodedData" + + // Test URI decoding + val decodedLink = vhlVerifier.decodeVhlUri(vhlUri) + assertNotNull("Should decode VHL URI", decodedLink) + assertEquals("Should extract URL", "https://example.com/manifest", decodedLink?.url) + assertFalse("Should not require PIN", vhlVerifier.isPinRequired(decodedLink!!)) + } + + @Test + fun testVhlWorkflowWithPin() { + val vhlVerifier = VhlVerifier() + + // Create a VHL URI with PIN requirement + val testData = """{"url":"https://example.com/manifest","flag":"P"}""" + val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) + val vhlUri = "vhlink:/$encodedData" + + // Test URI decoding + val decodedLink = vhlVerifier.decodeVhlUri(vhlUri) + assertNotNull("Should decode VHL URI", decodedLink) + assertEquals("Should extract URL", "https://example.com/manifest", decodedLink?.url) + assertEquals("Should extract flag", "P", decodedLink?.flag) + assertTrue("Should require PIN", vhlVerifier.isPinRequired(decodedLink!!)) + } + + @Test + fun testVhlManifestProcessing() { + val vhlVerifier = VhlVerifier() + + // Create a mock FHIR Bundle representing a VHL manifest + val manifest = createMockVhlManifest() + + // Test file extraction + val fileList = vhlVerifier.extractFileList(manifest) + + assertEquals("Should extract correct number of files", 2, fileList.size) + + // Verify PDF file + val pdfFile = fileList.find { it.type == "PDF" } + assertNotNull("Should have PDF file", pdfFile) + assertEquals("Should have correct PDF title", "Vaccination Certificate", pdfFile?.title) + + // Verify FHIR IPS file + val fhirFile = fileList.find { it.type == "FHIR_IPS" } + assertNotNull("Should have FHIR IPS file", fhirFile) + assertEquals("Should have correct FHIR title", "FHIR IPS Document", fhirFile?.title) + } + + @Test + fun testSmartHealthLinkModelVhlDetection() { + // Test VHL detection + val vhlModel = SmartHealthLinkModel(StringType("vhlink:/test")) + assertTrue("Should detect VHL URI", vhlModel.isVHL()) + + // Test SHL detection + val shlModel = SmartHealthLinkModel(StringType("shlink:/test")) + assertTrue("Should detect SHL URI as VHL", shlModel.isVHL()) + + // Test non-VHL URI + val otherModel = SmartHealthLinkModel(StringType("https://example.com")) + assertFalse("Should not detect regular URI as VHL", otherModel.isVHL()) + + // Test null URI + val nullModel = SmartHealthLinkModel(null) + assertFalse("Should not detect null URI as VHL", nullModel.isVHL()) + } + + /** + * Creates a mock VHL manifest Bundle for testing + */ + private fun createMockVhlManifest(): Bundle { + val bundle = Bundle() + bundle.id = "test-manifest" + bundle.type = Bundle.BundleType.SEARCHSET + + // Create a List resource + val listResource = ListResource() + listResource.id = "file-list" + listResource.status = ListResource.ListStatus.CURRENT + listResource.mode = ListResource.ListMode.WORKING + + // Add PDF document reference + val pdfDocRef = DocumentReference() + pdfDocRef.id = "pdf-doc" + pdfDocRef.description = "Vaccination Certificate" + val pdfAttachment = Attachment() + pdfAttachment.url = "https://example.com/cert.pdf" + pdfAttachment.size = 12345L + val pdfContent = DocumentReference.DocumentReferenceContentComponent() + pdfContent.attachment = pdfAttachment + pdfDocRef.content = listOf(pdfContent) + + // Add FHIR IPS Bundle + val ipsBundle = Bundle() + ipsBundle.id = "ips-bundle" + ipsBundle.type = Bundle.BundleType.DOCUMENT + + // Add references to List + val pdfListEntry = ListResource.ListEntryComponent() + pdfListEntry.item = Reference("DocumentReference/pdf-doc") + listResource.entry.add(pdfListEntry) + + val ipsListEntry = ListResource.ListEntryComponent() + ipsListEntry.item = Reference("Bundle/ips-bundle") + listResource.entry.add(ipsListEntry) + + // Add resources to Bundle + bundle.addEntry().setResource(listResource) + bundle.addEntry().setResource(pdfDocRef) + bundle.addEntry().setResource(ipsBundle) + + return bundle + } +} \ No newline at end of file From 11f2ed91ce442cc9da42caf39bc78adc25a5c1aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:59:55 +0000 Subject: [PATCH 05/13] Remove SHL support and placeholder bundles from VHL implementation Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- .../src/main/resources/VHL_Examples.txt | 5 - .../java/org/who/gdhcnvalidator/QRDecoder.kt | 4 +- .../hcert/healthlink/HealthLinkMapper.kt | 96 +------------------ .../hcert/healthlink/SmartHealthLinkModel.kt | 6 +- .../verify/hcert/healthlink/VhlVerifier.kt | 35 ++++++- .../gdhcnvalidator/verify/VhlQRDecoderTest.kt | 12 +-- 6 files changed, 46 insertions(+), 112 deletions(-) diff --git a/test-resources/src/main/resources/VHL_Examples.txt b/test-resources/src/main/resources/VHL_Examples.txt index 3e85c3bd..1012b828 100644 --- a/test-resources/src/main/resources/VHL_Examples.txt +++ b/test-resources/src/main/resources/VHL_Examples.txt @@ -10,11 +10,6 @@ vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0In0= This decodes to: {"url":"https://example.com/manifest"} -## SHL Example -shlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL3NobC1tYW5pZmVzdCJ9 - -This decodes to: {"url":"https://example.com/shl-manifest"} - ## VHL with Additional Parameters vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0IiwiZmxhZyI6IlAiLCJrZXkiOiJhYmNkZWYxMjM0NTYiLCJsYWJlbCI6IlZhY2NpbmF0aW9uIFJlY29yZCIsImV4cCI6MTY5ODc2ODAwMH0= diff --git a/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt b/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt index 9060ad25..5e5e6ad9 100644 --- a/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt +++ b/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt @@ -52,8 +52,8 @@ class QRDecoder(private val registry: TrustRegistry) { ) fun decode(qrPayload : String): VerificationResult { - // Check for VHL/SHL URIs first - if (qrPayload.startsWith("vhlink:/") || qrPayload.startsWith("shlink:/")) { + // Check for VHL URIs first + if (qrPayload.startsWith("vhlink:/")) { return processVhlUri(qrPayload) } diff --git a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt index 1824b805..331484dd 100644 --- a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt +++ b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt @@ -1,103 +1,17 @@ package org.who.gdhcnvalidator.verify.hcert.healthlink import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.Composition -import org.hl7.fhir.r4.model.Patient -import org.hl7.fhir.r4.model.Reference import org.who.gdhcnvalidator.verify.BaseMapper -import java.util.* /** - * Translates Smart Health Links and Verifiable Health Links (VHL) into FHIR Objects + * Translates Verifiable Health Links (VHL) into FHIR Objects + * Note: VHL processing is handled directly in VhlVerifier, not through this mapper */ class HealthLinkMapper: BaseMapper() { - private val vhlVerifier = VhlVerifier() - fun run(link: SmartHealthLinkModel): Bundle { - return if (link.isVHL()) { - processVhl(link) - } else { - processRegularShl(link) - } - } - - /** - * Process Verifiable Health Link (VHL) - * Creates a placeholder bundle that indicates VHL processing is needed - */ - private fun processVhl(link: SmartHealthLinkModel): Bundle { - val bundle = Bundle() - bundle.id = UUID.randomUUID().toString() - bundle.type = Bundle.BundleType.DOCUMENT - - // Create a composition to indicate this is a VHL - val composition = Composition() - composition.id = UUID.randomUUID().toString() - composition.status = Composition.CompositionStatus.FINAL - composition.type = createCodeableConcept("VHL", "Verifiable Health Link") - composition.title = "Verifiable Health Link" - composition.date = Date() - - // Add VHL URI as a section - val section = Composition.SectionComponent() - section.title = "VHL URI" - section.text = createNarrative("VHL URI: ${link.getUri()}") - composition.section = listOf(section) - - // Create placeholder patient - val patient = Patient() - patient.id = UUID.randomUUID().toString() - composition.subject = Reference("Patient/${patient.id}") - - // Add to bundle - bundle.addEntry().setResource(composition) - bundle.addEntry().setResource(patient) - - return bundle - } - - /** - * Process regular Smart Health Link (SHL) - * Currently returns placeholder bundle - */ - private fun processRegularShl(link: SmartHealthLinkModel): Bundle { - val bundle = Bundle() - bundle.id = UUID.randomUUID().toString() - bundle.type = Bundle.BundleType.DOCUMENT - - val composition = Composition() - composition.id = UUID.randomUUID().toString() - composition.status = Composition.CompositionStatus.FINAL - composition.type = createCodeableConcept("SHL", "Smart Health Link") - composition.title = "Smart Health Link" - composition.date = Date() - - // Create placeholder patient - val patient = Patient() - patient.id = UUID.randomUUID().toString() - composition.subject = Reference("Patient/${patient.id}") - - bundle.addEntry().setResource(composition) - bundle.addEntry().setResource(patient) - - return bundle - } - - private fun createCodeableConcept(code: String, display: String): org.hl7.fhir.r4.model.CodeableConcept { - val concept = org.hl7.fhir.r4.model.CodeableConcept() - val coding = org.hl7.fhir.r4.model.Coding() - coding.code = code - coding.display = display - concept.coding = listOf(coding) - return concept - } - - private fun createNarrative(text: String): org.hl7.fhir.r4.model.Narrative { - val narrative = org.hl7.fhir.r4.model.Narrative() - narrative.status = org.hl7.fhir.r4.model.Narrative.NarrativeStatus.GENERATED - narrative.div = org.hl7.fhir.utilities.xhtml.XhtmlNode() - narrative.div.addTag("div").addText(text) - return narrative + // VHL processing is handled directly in QRDecoder and VhlVerifier + // This mapper is maintained for compatibility but not used in VHL workflow + throw UnsupportedOperationException("VHL processing is handled directly in VhlVerifier") } } \ No newline at end of file diff --git a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt index 0e33524e..283aa02e 100644 --- a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt +++ b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/SmartHealthLinkModel.kt @@ -4,8 +4,8 @@ import org.hl7.fhir.r4.model.StringType import org.who.gdhcnvalidator.verify.BaseModel /** - * Model for Smart Health Links and Verifiable Health Links (VHL) - * Supports both SHL (shlink:/) and VHL (vhlink:/) URI formats + * Model for Verifiable Health Links (VHL) + * Supports only VHL (vhlink:/) URI format */ open class SmartHealthLinkModel ( val u: StringType? @@ -16,7 +16,7 @@ open class SmartHealthLinkModel ( */ fun isVHL(): Boolean { val uri = u?.value ?: return false - return uri.startsWith("vhlink:/") || uri.startsWith("shlink:/") + return uri.startsWith("vhlink:/") } /** diff --git a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt index b8503f4d..9d81bdee 100644 --- a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt +++ b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifier.kt @@ -37,13 +37,12 @@ class VhlVerifier { .build() /** - * Decodes a VHL URI (vhlink:/ or shlink:/) to extract the manifest URL + * Decodes a VHL URI (vhlink:/) to extract the manifest URL */ fun decodeVhlUri(uri: String): VhlDecodedLink? { return try { val payload = when { uri.startsWith("vhlink:/") -> uri.substring(8) - uri.startsWith("shlink:/") -> uri.substring(8) else -> return null } @@ -98,11 +97,27 @@ class VhlVerifier { /** * Extracts file information from the VHL manifest + * Supports both current VHL manifest format and deprecated SHL manifest format * Returns list of file metadata for display to user */ fun extractFileList(manifest: Bundle): List { val files = mutableListOf() + // Check if this is a current VHL manifest (FHIR SearchSet Bundle with List resources) + if (manifest.type == Bundle.BundleType.SEARCHSET) { + extractFromCurrentVhlManifest(manifest, files) + } else { + // Try deprecated SHL manifest format (files array in Bundle entries) + extractFromDeprecatedShlManifest(manifest, files) + } + + return files + } + + /** + * Extract files from current VHL manifest format (FHIR SearchSet Bundle with List resources) + */ + private fun extractFromCurrentVhlManifest(manifest: Bundle, files: MutableList) { // Process List resources and their included items manifest.entry?.forEach { entry -> when (val resource = entry.resource) { @@ -123,8 +138,20 @@ class VhlVerifier { } } } - - return files + } + + /** + * Extract files from deprecated SHL manifest format + * Based on https://build.fhir.org/ig/HL7/smart-health-cards-and-links/StructureDefinition-ShlManifest.html + */ + private fun extractFromDeprecatedShlManifest(manifest: Bundle, files: MutableList) { + // In deprecated format, files are directly in Bundle entries + manifest.entry?.forEach { entry -> + val resource = entry.resource + if (resource != null) { + files.add(extractFileInfo(resource)) + } + } } private fun extractFileInfo(resource: org.hl7.fhir.r4.model.Resource): VhlFileInfo { diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlQRDecoderTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlQRDecoderTest.kt index a96bf5ae..e5f756ba 100644 --- a/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlQRDecoderTest.kt +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlQRDecoderTest.kt @@ -26,21 +26,19 @@ class VhlQRDecoderTest : BaseTrustRegistryTest() { } @Test - fun testShlUriDetection() { + fun testInvalidShlUri() { val decoder = QRDecoder(registry) - // Create a sample SHL URI (no PIN required) + // SHL URIs should not be processed as VHL val testData = """{"url":"https://example.com/manifest"}""" val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) val shlUri = "shlink:/$encodedData" val result = decoder.decode(shlUri) - // Since we can't actually fetch the manifest, expect fetch error - assertEquals("Should detect SHL but fail to fetch", QRDecoder.Status.VHL_FETCH_ERROR, result.status) - assertNotNull("Should have VHL info", result.vhlInfo) - assertFalse("Should not require PIN", result.vhlInfo?.requiresPin == true) - assertEquals("Should preserve original QR", shlUri, result.qr) + // SHL should not be supported + assertEquals("Should not support SHL", QRDecoder.Status.NOT_SUPPORTED, result.status) + assertNull("Should not have VHL info", result.vhlInfo) } @Test From b4957a87e747e11daafdfcb4fed1bff235c2e901 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 Aug 2025 20:14:06 +0000 Subject: [PATCH 06/13] Fix HealthLinkMapper to handle legacy cases without throwing exceptions Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- .../hcert/healthlink/HealthLinkMapper.kt | 41 ++++++++++-- .../hcert/healthlink/HealthLinkMapperTest.kt | 63 +++++++++++++++++++ .../hcert/healthlink/VhlIntegrationTest.kt | 4 +- 3 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapperTest.kt diff --git a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt index 331484dd..26942021 100644 --- a/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt +++ b/verify/src/main/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapper.kt @@ -1,17 +1,48 @@ package org.who.gdhcnvalidator.verify.hcert.healthlink import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Composition +import org.hl7.fhir.r4.model.Narrative import org.who.gdhcnvalidator.verify.BaseMapper /** - * Translates Verifiable Health Links (VHL) into FHIR Objects - * Note: VHL processing is handled directly in VhlVerifier, not through this mapper + * Translates legacy Health Links found in CBOR payloads into FHIR Objects + * Note: Modern VHL processing (vhlink:/ URIs) is handled directly in VhlVerifier */ class HealthLinkMapper: BaseMapper() { fun run(link: SmartHealthLinkModel): Bundle { - // VHL processing is handled directly in QRDecoder and VhlVerifier - // This mapper is maintained for compatibility but not used in VHL workflow - throw UnsupportedOperationException("VHL processing is handled directly in VhlVerifier") + // Create a basic bundle for legacy healthLink fields found in CBOR payloads + // Modern VHL URIs (vhlink:/) are processed directly in QRDecoder/VhlVerifier + val bundle = Bundle().apply { + type = Bundle.BundleType.DOCUMENT + id = "healthlink-legacy" + } + + // Create a composition indicating this is a legacy health link + val composition = Composition().apply { + id = "composition-healthlink" + status = Composition.CompositionStatus.FINAL + type = org.hl7.fhir.r4.model.CodeableConcept().apply { + text = "Legacy Health Link" + } + title = "Health Link Reference" + + // Add the link URI as narrative text + text = Narrative().apply { + status = Narrative.NarrativeStatus.GENERATED + div = org.hl7.fhir.utilities.xhtml.XhtmlNode().apply { + addTag("div") + addTag("p").addText("Legacy health link found in certificate: ${link.getUri() ?: "Unknown URI"}") + addTag("p").addText("Modern VHL processing requires vhlink:/ URI format.") + } + } + } + + bundle.addEntry().apply { + resource = composition + } + + return bundle } } \ No newline at end of file diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapperTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapperTest.kt new file mode 100644 index 00000000..2776b6f1 --- /dev/null +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/HealthLinkMapperTest.kt @@ -0,0 +1,63 @@ +package org.who.gdhcnvalidator.verify.hcert.healthlink + +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Composition +import org.hl7.fhir.r4.model.StringType +import org.junit.Assert.* +import org.junit.Test + +class HealthLinkMapperTest { + + @Test + fun testHealthLinkMapperDoesNotThrowException() { + val mapper = HealthLinkMapper() + val model = SmartHealthLinkModel(StringType("vhlink:/test")) + + // Should not throw UnsupportedOperationException anymore + val result = mapper.run(model) + + assertNotNull("Should return a Bundle", result) + assertEquals("Should be document bundle", Bundle.BundleType.DOCUMENT, result.type) + assertEquals("Should have correct ID", "healthlink-legacy", result.id) + + // Should have a composition entry + val compositionEntry = result.entry?.find { it.resource is Composition } + assertNotNull("Should have composition entry", compositionEntry) + + val composition = compositionEntry?.resource as Composition + assertEquals("Should have correct status", Composition.CompositionStatus.FINAL, composition.status) + assertEquals("Should have correct title", "Health Link Reference", composition.title) + } + + @Test + fun testHealthLinkMapperWithNullUri() { + val mapper = HealthLinkMapper() + val model = SmartHealthLinkModel(null) + + // Should handle null URI gracefully + val result = mapper.run(model) + + assertNotNull("Should return a Bundle", result) + assertTrue("Should contain 'Unknown URI' in narrative", + result.entry?.any { entry -> + val composition = entry.resource as? Composition + composition?.text?.div?.allText?.contains("Unknown URI") == true + } ?: false) + } + + @Test + fun testHealthLinkMapperWithShlUri() { + val mapper = HealthLinkMapper() + val model = SmartHealthLinkModel(StringType("shlink:/example")) + + // Should handle SHL URIs gracefully (legacy case) + val result = mapper.run(model) + + assertNotNull("Should return a Bundle", result) + assertTrue("Should contain the SHL URI in narrative", + result.entry?.any { entry -> + val composition = entry.resource as? Composition + composition?.text?.div?.allText?.contains("shlink:/example") == true + } ?: false) + } +} \ No newline at end of file diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlIntegrationTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlIntegrationTest.kt index 7d01d72b..2a165b81 100644 --- a/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlIntegrationTest.kt +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlIntegrationTest.kt @@ -70,9 +70,9 @@ class VhlIntegrationTest : BaseTrustRegistryTest() { val vhlModel = SmartHealthLinkModel(StringType("vhlink:/test")) assertTrue("Should detect VHL URI", vhlModel.isVHL()) - // Test SHL detection + // Test SHL detection - should NOT be detected as VHL val shlModel = SmartHealthLinkModel(StringType("shlink:/test")) - assertTrue("Should detect SHL URI as VHL", shlModel.isVHL()) + assertFalse("Should NOT detect SHL URI as VHL", shlModel.isVHL()) // Test non-VHL URI val otherModel = SmartHealthLinkModel(StringType("https://example.com")) From e66da5a449dd37c14d1756238549d223a2895e41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 Aug 2025 22:20:22 +0000 Subject: [PATCH 07/13] Fix incorrect VHL tests to properly handle SHL rejection Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt | 7 ++++++- .../java/org/who/gdhcnvalidator/verify/VhlExampleQRTest.kt | 7 +++---- .../verify/hcert/healthlink/VhlVerifierTest.kt | 5 ++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt b/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt index 5e5e6ad9..e6dffc31 100644 --- a/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt +++ b/verify/src/main/java/org/who/gdhcnvalidator/QRDecoder.kt @@ -52,11 +52,16 @@ class QRDecoder(private val registry: TrustRegistry) { ) fun decode(qrPayload : String): VerificationResult { - // Check for VHL URIs first + // Check for VHL URIs first - only vhlink:/ is supported if (qrPayload.startsWith("vhlink:/")) { return processVhlUri(qrPayload) } + // Explicitly reject SHL URIs as not supported per requirements + if (qrPayload.startsWith("shlink:/")) { + return VerificationResult(Status.NOT_SUPPORTED, null, null, qrPayload, null) + } + if (qrPayload.uppercase().startsWith("HC1:")) { return HCertVerifier(registry).unpackAndVerify(qrPayload) } diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlExampleQRTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlExampleQRTest.kt index 607a6e16..3e855943 100644 --- a/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlExampleQRTest.kt +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/VhlExampleQRTest.kt @@ -30,13 +30,12 @@ class VhlExampleQRTest : BaseTrustRegistryTest() { assertFalse("Should not require PIN", resultWithoutPin.vhlInfo?.requiresPin == true) assertEquals("Should extract URL", "https://example.com/manifest", resultWithoutPin.vhlInfo?.decodedLink?.url) - // Test SHL format + // Test SHL format - should not be supported per requirements val shlExample = "shlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL3NobC1tYW5pZmVzdCJ9" val shlResult = decoder.decode(shlExample) - assertEquals("Should fail to fetch SHL manifest", QRDecoder.Status.VHL_FETCH_ERROR, shlResult.status) - assertNotNull("Should have VHL info", shlResult.vhlInfo) - assertEquals("Should extract SHL URL", "https://example.com/shl-manifest", shlResult.vhlInfo?.decodedLink?.url) + assertEquals("Should not support SHL", QRDecoder.Status.NOT_SUPPORTED, shlResult.status) + assertNull("Should not have VHL info", shlResult.vhlInfo) // Test VHL with additional parameters val vhlFull = "vhlink:/eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL21hbmlmZXN0IiwiZmxhZyI6IlAiLCJrZXkiOiJhYmNkZWYxMjM0NTYiLCJsYWJlbCI6IlZhY2NpbmF0aW9uIFJlY29yZCIsImV4cCI6MTY5ODc2ODAwMH0=" diff --git a/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifierTest.kt b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifierTest.kt index 050d586a..e8d23fc2 100644 --- a/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifierTest.kt +++ b/verify/src/test/java/org/who/gdhcnvalidator/verify/hcert/healthlink/VhlVerifierTest.kt @@ -27,15 +27,14 @@ class VhlVerifierTest : BaseTrustRegistryTest() { fun testDecodeShlUri() { val vhlVerifier = VhlVerifier() - // Create a sample SHL URI + // SHL URIs should not be processed by VHL verifier val testData = """{"url":"https://example.com/shl-manifest"}""" val encodedData = Base64.getUrlEncoder().encodeToString(testData.toByteArray()) val shlUri = "shlink:/$encodedData" val result = vhlVerifier.decodeVhlUri(shlUri) - assertNotNull("Should decode SHL URI successfully", result) - assertEquals("Should extract correct URL", "https://example.com/shl-manifest", result?.url) + assertNull("Should not decode SHL URI (VHL verifier only processes vhlink:/ URIs)", result) } @Test From 9854e7128a35ba0ff5097e58e3f8c97f55771e5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 Aug 2025 22:21:23 +0000 Subject: [PATCH 08/13] Implement native Android FHIR IPS display with structured content parsing Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- .../gdhcnvalidator/views/ResultFragment.kt | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt b/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt index d73fb1ef..9e0659f7 100644 --- a/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt +++ b/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt @@ -464,18 +464,89 @@ class ResultFragment : Fragment() { } /** - * Shows FHIR IPS content in a dialog + * Shows FHIR IPS content in a dialog with structured information */ private fun showFhirIpsDialog(file: VhlVerifier.VhlFileInfo) { + val ipsBundle = file.content as? org.hl7.fhir.r4.model.Bundle + + val message = if (ipsBundle != null) { + buildIpsDisplayText(ipsBundle) + } else { + "FHIR IPS Document\n\nContent is not available for display." + } + AlertDialog.Builder(requireContext()) .setTitle(file.title) - .setMessage("FHIR IPS Document\n\nThis contains structured health information that would be processed and displayed in a production implementation.") + .setMessage(message) .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } .show() } + /** + * Builds a human-readable text representation of IPS Bundle content + */ + private fun buildIpsDisplayText(ipsBundle: org.hl7.fhir.r4.model.Bundle): String { + val builder = StringBuilder() + builder.append("FHIR International Patient Summary\n\n") + + // Extract patient information + val patient = ipsBundle.entry?.find { + it.resource is org.hl7.fhir.r4.model.Patient + }?.resource as? org.hl7.fhir.r4.model.Patient + + if (patient != null) { + builder.append("Patient Information:\n") + patient.name?.firstOrNull()?.let { name -> + val fullName = listOfNotNull( + name.given?.joinToString(" "), + name.family + ).joinToString(" ") + if (fullName.isNotEmpty()) { + builder.append("• Name: $fullName\n") + } + } + + patient.birthDate?.let { birthDate -> + builder.append("• Birth Date: ${birthDate}\n") + } + + patient.gender?.let { gender -> + builder.append("• Gender: ${gender.display ?: gender.name}\n") + } + + builder.append("\n") + } + + // Count other resources + val resourceCounts = ipsBundle.entry?.groupBy { + it.resource?.fhirType() + }?.mapValues { it.value.size } + + if (resourceCounts != null && resourceCounts.isNotEmpty()) { + builder.append("Document Contents:\n") + resourceCounts.forEach { (type, count) -> + when (type) { + "Composition" -> builder.append("• Document Structure: $count entry\n") + "Patient" -> {} // Already handled above + "Medication", "MedicationStatement" -> builder.append("• Medications: $count entries\n") + "AllergyIntolerance" -> builder.append("• Allergies: $count entries\n") + "Condition" -> builder.append("• Conditions: $count entries\n") + "Immunization" -> builder.append("• Immunizations: $count entries\n") + "Procedure" -> builder.append("• Procedures: $count entries\n") + "DiagnosticReport" -> builder.append("• Lab Results: $count entries\n") + "Observation" -> builder.append("• Observations: $count entries\n") + else -> builder.append("• $type: $count entries\n") + } + } + } + + builder.append("\nThis is a structured health summary document following international standards.") + + return builder.toString() + } + /** * Clears all result fields for VHL display */ From 1853688a27e511cee3a39b52b7741009d48a1b3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 Aug 2025 23:17:04 +0000 Subject: [PATCH 09/13] Add comprehensive Heroku web deployment documentation Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- README.md | 1 + docs/heroku-web-deployment.md | 195 ++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 docs/heroku-web-deployment.md diff --git a/README.md b/README.md index 0ed074f0..89fc451a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ anywhere. Our goal is to make a Verifier App with the widest possible verificati - [**Data Models**](docs/data-models.md) - Comprehensive documentation of all supported certificate data models (DDCC, DCC, DIVOC, SHC, ICAO, ICVP) - [**User Workflows**](docs/user-workflows.md) - User experience and technical workflow documentation - [**Adding New Schemas**](NEW_SCHEMAS.md) - Guide for adding support for new certificate formats +- [**Heroku Web Deployment**](docs/heroku-web-deployment.md) - Step-by-step guide for deploying the web validator to Heroku using the web interface ### Reference Documentation - https://worldhealthorganization.github.io/smart-trust/ diff --git a/docs/heroku-web-deployment.md b/docs/heroku-web-deployment.md new file mode 100644 index 00000000..9995a7bf --- /dev/null +++ b/docs/heroku-web-deployment.md @@ -0,0 +1,195 @@ +# Heroku Web Deployment Guide + +This guide provides step-by-step instructions for deploying the GDHCN Validator web application to Heroku using the web interface (no CLI required). + +## Prerequisites + +Before starting, ensure you have: +- A GitHub account with access to this repository +- A Heroku account (free tier is sufficient for testing) +- Repository contains the required files: + - `Procfile` (already present) + - `system.properties` (already present) + - Web module with Spring Boot application + +## Step-by-Step Deployment + +### 1. Create Heroku Account + +1. Visit [https://signup.heroku.com/](https://signup.heroku.com/) +2. Fill in your details and create a free account +3. Verify your email address +4. Log in to your Heroku account + +### 2. Create New Heroku App + +1. After logging in, you'll see the Heroku Dashboard +2. Click the **"New"** button in the top-right corner +3. Select **"Create new app"** from the dropdown +4. Choose an app name (must be unique across all Heroku apps) + - Example: `gdhcn-validator-[your-name]` or `who-validator-demo` + - App name can only contain lowercase letters, numbers, and dashes +5. Select a region (choose the one closest to your users) + - **United States** or **Europe** +6. Click **"Create app"** + +### 3. Configure GitHub Integration + +1. In your new app's dashboard, navigate to the **"Deploy"** tab +2. In the **"Deployment method"** section, select **"GitHub"** +3. If this is your first time, you'll need to connect your GitHub account: + - Click **"Connect to GitHub"** + - Authorize Heroku to access your GitHub account + - You may be redirected to GitHub to confirm permissions + +### 4. Connect Repository + +1. In the **"Connect to GitHub"** section: + - Search for repository name: `gdhcn-validator` + - Make sure you're searching in the correct organization/account +2. Find your repository in the search results +3. Click **"Connect"** next to the repository name + +### 5. Configure Deployment Settings + +#### Option A: Automatic Deploys (Recommended) +1. Scroll down to the **"Automatic deploys"** section +2. Select the branch you want to deploy from (typically `main` or `master`) +3. Optional: Check **"Wait for CI to pass before deploy"** if you have GitHub Actions/CI setup +4. Click **"Enable Automatic Deploys"** + +#### Option B: Manual Deploys +1. Scroll down to the **"Manual deploy"** section +2. Select the branch you want to deploy +3. Click **"Deploy Branch"** + +### 6. Monitor Initial Deployment + +1. After triggering a deployment, you'll see the build logs in real-time +2. The build process will: + - Download your code from GitHub + - Detect it's a Java application + - Install Java 17 (as specified in `system.properties`) + - Run `./gradlew build` to build the web module + - Start the application using the `Procfile` command + +3. Look for these success indicators: + ``` + -----> Java app detected + -----> Installing OpenJDK 17 + -----> Executing ./gradlew build + -----> Discovering process types + Procfile declares types -> web + -----> Compressing... + -----> Launching... + ``` + +### 7. Access Your Deployed Application + +1. Once deployment is successful, click **"View"** button or +2. Visit `https://[your-app-name].herokuapp.com` +3. You should see the GDHCN Validator web interface + +### 8. Configure Environment Variables (If Needed) + +If your application requires environment variables: + +1. Go to the **"Settings"** tab in your Heroku app dashboard +2. Scroll down to **"Config Vars"** section +3. Click **"Reveal Config Vars"** +4. Add any required environment variables: + - Click **"Add"** + - Enter **KEY** and **VALUE** + - Click **"Add"** to save +5. Common variables might include: + - Database URLs + - API keys + - Trust registry configurations + +### 9. View Application Logs + +To troubleshoot issues: + +1. In your app dashboard, click **"More"** in the top-right +2. Select **"View logs"** +3. Or go to the **"Activity"** tab to see deployment history + +## Common Issues and Solutions + +### Build Failures + +**Problem**: Gradle build fails during deployment +**Solution**: +- Check that `gradlew` has execute permissions in your repository +- Ensure all dependencies are properly specified in `build.gradle` +- Verify Java version in `system.properties` matches your project requirements + +**Problem**: "Application error" when accessing the app +**Solution**: +- Check the logs for error messages +- Verify the `Procfile` is correctly configured +- Ensure your web module builds successfully locally + +### Memory Issues + +**Problem**: Application crashes due to memory limits +**Solution**: +- Add `JAVA_OPTS` config variable with memory settings: + - Key: `JAVA_OPTS` + - Value: `-Xmx512m -Xms256m` + +### Port Configuration + +**Problem**: Application doesn't start due to port issues +**Solution**: +- Ensure your Spring Boot application uses `$PORT` environment variable +- This should be automatic with Spring Boot on Heroku + +## Updating Your Deployment + +### Automatic Updates (if enabled) +- Simply push changes to your connected GitHub branch +- Heroku will automatically detect changes and redeploy + +### Manual Updates +1. Go to **"Deploy"** tab +2. Scroll to **"Manual deploy"** section +3. Select the branch with your changes +4. Click **"Deploy Branch"** + +## Managing Your App + +### Scaling +1. Go to **"Resources"** tab +2. Adjust dyno types and quantities +3. Free tier includes 550-1000 free dyno hours per month + +### Custom Domain (Optional) +1. Go to **"Settings"** tab +2. Scroll to **"Domains"** section +3. Click **"Add domain"** to use a custom domain + +### SSL/HTTPS +- Heroku provides automatic SSL for all `*.herokuapp.com` domains +- Custom domains require SSL certificates (available in paid plans) + +## Security Considerations + +1. **Environment Variables**: Never commit sensitive data to your repository +2. **Dependencies**: Keep dependencies updated +3. **Access Control**: Use Heroku's team features to manage access +4. **Monitoring**: Set up log monitoring for production applications + +## Support and Troubleshooting + +- **Heroku Documentation**: [https://devcenter.heroku.com/](https://devcenter.heroku.com/) +- **Application Logs**: Available in Heroku dashboard under "More" → "View logs" +- **GitHub Integration**: [https://devcenter.heroku.com/articles/github-integration](https://devcenter.heroku.com/articles/github-integration) + +## Cost Information + +- **Free Tier**: 550-1000 free dyno hours per month +- **Hobby Tier**: $7/month for always-on apps +- **Production Plans**: Start at $25/month with enhanced features + +The free tier is perfect for development and testing, while production deployments should consider paid plans for better performance and uptime guarantees. \ No newline at end of file From 8b7a7f661619fb8a748d42858570dde86e65ca6a Mon Sep 17 00:00:00 2001 From: Carl Leitner Date: Wed, 20 Aug 2025 07:01:41 -0400 Subject: [PATCH 10/13] Delete docs/heroku-web-deployment.md --- docs/heroku-web-deployment.md | 195 ---------------------------------- 1 file changed, 195 deletions(-) delete mode 100644 docs/heroku-web-deployment.md diff --git a/docs/heroku-web-deployment.md b/docs/heroku-web-deployment.md deleted file mode 100644 index 9995a7bf..00000000 --- a/docs/heroku-web-deployment.md +++ /dev/null @@ -1,195 +0,0 @@ -# Heroku Web Deployment Guide - -This guide provides step-by-step instructions for deploying the GDHCN Validator web application to Heroku using the web interface (no CLI required). - -## Prerequisites - -Before starting, ensure you have: -- A GitHub account with access to this repository -- A Heroku account (free tier is sufficient for testing) -- Repository contains the required files: - - `Procfile` (already present) - - `system.properties` (already present) - - Web module with Spring Boot application - -## Step-by-Step Deployment - -### 1. Create Heroku Account - -1. Visit [https://signup.heroku.com/](https://signup.heroku.com/) -2. Fill in your details and create a free account -3. Verify your email address -4. Log in to your Heroku account - -### 2. Create New Heroku App - -1. After logging in, you'll see the Heroku Dashboard -2. Click the **"New"** button in the top-right corner -3. Select **"Create new app"** from the dropdown -4. Choose an app name (must be unique across all Heroku apps) - - Example: `gdhcn-validator-[your-name]` or `who-validator-demo` - - App name can only contain lowercase letters, numbers, and dashes -5. Select a region (choose the one closest to your users) - - **United States** or **Europe** -6. Click **"Create app"** - -### 3. Configure GitHub Integration - -1. In your new app's dashboard, navigate to the **"Deploy"** tab -2. In the **"Deployment method"** section, select **"GitHub"** -3. If this is your first time, you'll need to connect your GitHub account: - - Click **"Connect to GitHub"** - - Authorize Heroku to access your GitHub account - - You may be redirected to GitHub to confirm permissions - -### 4. Connect Repository - -1. In the **"Connect to GitHub"** section: - - Search for repository name: `gdhcn-validator` - - Make sure you're searching in the correct organization/account -2. Find your repository in the search results -3. Click **"Connect"** next to the repository name - -### 5. Configure Deployment Settings - -#### Option A: Automatic Deploys (Recommended) -1. Scroll down to the **"Automatic deploys"** section -2. Select the branch you want to deploy from (typically `main` or `master`) -3. Optional: Check **"Wait for CI to pass before deploy"** if you have GitHub Actions/CI setup -4. Click **"Enable Automatic Deploys"** - -#### Option B: Manual Deploys -1. Scroll down to the **"Manual deploy"** section -2. Select the branch you want to deploy -3. Click **"Deploy Branch"** - -### 6. Monitor Initial Deployment - -1. After triggering a deployment, you'll see the build logs in real-time -2. The build process will: - - Download your code from GitHub - - Detect it's a Java application - - Install Java 17 (as specified in `system.properties`) - - Run `./gradlew build` to build the web module - - Start the application using the `Procfile` command - -3. Look for these success indicators: - ``` - -----> Java app detected - -----> Installing OpenJDK 17 - -----> Executing ./gradlew build - -----> Discovering process types - Procfile declares types -> web - -----> Compressing... - -----> Launching... - ``` - -### 7. Access Your Deployed Application - -1. Once deployment is successful, click **"View"** button or -2. Visit `https://[your-app-name].herokuapp.com` -3. You should see the GDHCN Validator web interface - -### 8. Configure Environment Variables (If Needed) - -If your application requires environment variables: - -1. Go to the **"Settings"** tab in your Heroku app dashboard -2. Scroll down to **"Config Vars"** section -3. Click **"Reveal Config Vars"** -4. Add any required environment variables: - - Click **"Add"** - - Enter **KEY** and **VALUE** - - Click **"Add"** to save -5. Common variables might include: - - Database URLs - - API keys - - Trust registry configurations - -### 9. View Application Logs - -To troubleshoot issues: - -1. In your app dashboard, click **"More"** in the top-right -2. Select **"View logs"** -3. Or go to the **"Activity"** tab to see deployment history - -## Common Issues and Solutions - -### Build Failures - -**Problem**: Gradle build fails during deployment -**Solution**: -- Check that `gradlew` has execute permissions in your repository -- Ensure all dependencies are properly specified in `build.gradle` -- Verify Java version in `system.properties` matches your project requirements - -**Problem**: "Application error" when accessing the app -**Solution**: -- Check the logs for error messages -- Verify the `Procfile` is correctly configured -- Ensure your web module builds successfully locally - -### Memory Issues - -**Problem**: Application crashes due to memory limits -**Solution**: -- Add `JAVA_OPTS` config variable with memory settings: - - Key: `JAVA_OPTS` - - Value: `-Xmx512m -Xms256m` - -### Port Configuration - -**Problem**: Application doesn't start due to port issues -**Solution**: -- Ensure your Spring Boot application uses `$PORT` environment variable -- This should be automatic with Spring Boot on Heroku - -## Updating Your Deployment - -### Automatic Updates (if enabled) -- Simply push changes to your connected GitHub branch -- Heroku will automatically detect changes and redeploy - -### Manual Updates -1. Go to **"Deploy"** tab -2. Scroll to **"Manual deploy"** section -3. Select the branch with your changes -4. Click **"Deploy Branch"** - -## Managing Your App - -### Scaling -1. Go to **"Resources"** tab -2. Adjust dyno types and quantities -3. Free tier includes 550-1000 free dyno hours per month - -### Custom Domain (Optional) -1. Go to **"Settings"** tab -2. Scroll to **"Domains"** section -3. Click **"Add domain"** to use a custom domain - -### SSL/HTTPS -- Heroku provides automatic SSL for all `*.herokuapp.com` domains -- Custom domains require SSL certificates (available in paid plans) - -## Security Considerations - -1. **Environment Variables**: Never commit sensitive data to your repository -2. **Dependencies**: Keep dependencies updated -3. **Access Control**: Use Heroku's team features to manage access -4. **Monitoring**: Set up log monitoring for production applications - -## Support and Troubleshooting - -- **Heroku Documentation**: [https://devcenter.heroku.com/](https://devcenter.heroku.com/) -- **Application Logs**: Available in Heroku dashboard under "More" → "View logs" -- **GitHub Integration**: [https://devcenter.heroku.com/articles/github-integration](https://devcenter.heroku.com/articles/github-integration) - -## Cost Information - -- **Free Tier**: 550-1000 free dyno hours per month -- **Hobby Tier**: $7/month for always-on apps -- **Production Plans**: Start at $25/month with enhanced features - -The free tier is perfect for development and testing, while production deployments should consider paid plans for better performance and uptime guarantees. \ No newline at end of file From 8a27e101ad0c239930a8b1dd3cef890955f7a2f6 Mon Sep 17 00:00:00 2001 From: Carl Leitner Date: Wed, 20 Aug 2025 07:02:15 -0400 Subject: [PATCH 11/13] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 89fc451a..5bfa13bc 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,6 @@ anywhere. Our goal is to make a Verifier App with the widest possible verificati - [**Data Models**](docs/data-models.md) - Comprehensive documentation of all supported certificate data models (DDCC, DCC, DIVOC, SHC, ICAO, ICVP) - [**User Workflows**](docs/user-workflows.md) - User experience and technical workflow documentation - [**Adding New Schemas**](NEW_SCHEMAS.md) - Guide for adding support for new certificate formats -- [**Heroku Web Deployment**](docs/heroku-web-deployment.md) - Step-by-step guide for deploying the web validator to Heroku using the web interface ### Reference Documentation - https://worldhealthorganization.github.io/smart-trust/ @@ -180,4 +179,4 @@ 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. \ No newline at end of file +limitations under the License. From 2ec6884c4662463be92e89982a3d7f8089cc894e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 Aug 2025 11:22:13 +0000 Subject: [PATCH 12/13] Add comprehensive IPS Kotlin library for native Android FHIR IPS display Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- app/build.gradle | 2 + .../gdhcnvalidator/views/ResultFragment.kt | 325 ++++++++++++++++-- ips-viewer/README.md | 244 +++++++++++++ ips-viewer/build.gradle | 24 ++ .../gdhcnvalidator/ipsviewer/IpsDataModel.kt | 204 +++++++++++ .../gdhcnvalidator/ipsviewer/IpsFormatter.kt | 214 ++++++++++++ .../who/gdhcnvalidator/ipsviewer/IpsParser.kt | 300 ++++++++++++++++ .../gdhcnvalidator/ipsviewer/IpsProcessor.kt | 272 +++++++++++++++ .../who/gdhcnvalidator/ipsviewer/IpsViewer.kt | 151 ++++++++ .../gdhcnvalidator/ipsviewer/IpsViewerTest.kt | 138 ++++++++ settings.gradle | 1 + 11 files changed, 1846 insertions(+), 29 deletions(-) create mode 100644 ips-viewer/README.md create mode 100644 ips-viewer/build.gradle create mode 100644 ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsDataModel.kt create mode 100644 ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsFormatter.kt create mode 100644 ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsParser.kt create mode 100644 ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsProcessor.kt create mode 100644 ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewer.kt create mode 100644 ips-viewer/src/test/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewerTest.kt diff --git a/app/build.gradle b/app/build.gradle index 8accc4d8..b9d5afad 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -117,6 +117,8 @@ configurations.configureEach { dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2' + implementation project(':ips-viewer') + implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'com.google.android.material:material:1.12.0' diff --git a/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt b/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt index 9e0659f7..fc4dcee6 100644 --- a/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt +++ b/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt @@ -21,6 +21,8 @@ import org.who.gdhcnvalidator.FhirApplication import org.who.gdhcnvalidator.QRDecoder import org.who.gdhcnvalidator.R import org.who.gdhcnvalidator.databinding.FragmentResultBinding +import org.who.gdhcnvalidator.ipsviewer.IpsViewer +import org.who.gdhcnvalidator.ipsviewer.AlertLevel import org.who.gdhcnvalidator.services.DDCCFormatter import org.who.gdhcnvalidator.trust.TrustRegistry import org.who.gdhcnvalidator.verify.hcert.healthlink.VhlVerifier @@ -464,35 +466,311 @@ class ResultFragment : Fragment() { } /** - * Shows FHIR IPS content in a dialog with structured information + * Shows FHIR IPS content in a dialog with structured information using the IPS library */ private fun showFhirIpsDialog(file: VhlVerifier.VhlFileInfo) { val ipsBundle = file.content as? org.hl7.fhir.r4.model.Bundle - val message = if (ipsBundle != null) { - buildIpsDisplayText(ipsBundle) - } else { - "FHIR IPS Document\n\nContent is not available for display." + if (ipsBundle == null) { + AlertDialog.Builder(requireContext()) + .setTitle(file.title) + .setMessage("FHIR IPS Document\n\nContent is not available for display.") + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .show() + return } + + try { + val ipsViewer = IpsViewer() + + // Check if it's a valid IPS bundle + if (!ipsViewer.isValidIpsBundle(ipsBundle)) { + showBasicBundleInfo(file, ipsBundle) + return + } + + // Process the IPS document + val processedIps = ipsViewer.processIpsBundle(ipsBundle) + + // Create a custom dialog with structured content + showStructuredIpsDialog(file, processedIps) + + } catch (e: Exception) { + // Fallback to basic display if IPS processing fails + showBasicBundleInfo(file, ipsBundle) + } + } + + /** + * Shows a structured IPS dialog with rich content + */ + private fun showStructuredIpsDialog(file: VhlVerifier.VhlFileInfo, processedIps: org.who.gdhcnvalidator.ipsviewer.ProcessedIpsDocument) { + val context = requireContext() + + // Create a scrollable view for the content + val scrollView = ScrollView(context) + val container = LinearLayout(context) + container.orientation = LinearLayout.VERTICAL + container.setPadding(24, 16, 24, 16) + + // Patient header + addPatientHeader(container, processedIps.patient) + + // Clinical alerts + if (processedIps.alerts.isNotEmpty()) { + addClinicalAlerts(container, processedIps.alerts) + } + + // Document sections + addDocumentSections(container, processedIps.sections) + + // Document summary + addDocumentSummary(container, processedIps.summary, processedIps.metadata) + + scrollView.addView(container) + + AlertDialog.Builder(context) + .setTitle(file.title) + .setView(scrollView) + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .setNeutralButton("Details") { _, _ -> + // Show detailed text view + showDetailedIpsText(file, processedIps) + } + .show() + } + + /** + * Shows detailed text representation of the IPS + */ + private fun showDetailedIpsText(file: VhlVerifier.VhlFileInfo, processedIps: org.who.gdhcnvalidator.ipsviewer.ProcessedIpsDocument) { + val ipsViewer = IpsViewer() + val ipsBundle = file.content as org.hl7.fhir.r4.model.Bundle + val detailedText = ipsViewer.formatIpsAsText(ipsBundle) + + val scrollView = ScrollView(requireContext()) + val textView = TextView(requireContext()) + textView.text = detailedText + textView.setTextIsSelectable(true) + textView.typeface = android.graphics.Typeface.MONOSPACE + textView.textSize = 12f + textView.setPadding(16, 16, 16, 16) + scrollView.addView(textView) + + AlertDialog.Builder(requireContext()) + .setTitle("${file.title} - Details") + .setView(scrollView) + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .show() + } + + private fun addPatientHeader(container: LinearLayout, patient: org.who.gdhcnvalidator.ipsviewer.ProcessedPatientInfo) { + val context = container.context + + // Patient name + val nameView = TextView(context) + nameView.text = patient.displayName + nameView.textSize = 18f + nameView.setTypeface(null, android.graphics.Typeface.BOLD) + nameView.setTextColor(context.getColor(android.R.color.black)) + container.addView(nameView) + + // Patient details + val detailsLayout = LinearLayout(context) + detailsLayout.orientation = LinearLayout.HORIZONTAL + + val details = mutableListOf() + if (patient.ageInfo != null) details.add(patient.ageInfo) + if (patient.gender != null) details.add(patient.gender) + + if (details.isNotEmpty()) { + val detailsView = TextView(context) + detailsView.text = details.joinToString(" • ") + detailsView.textSize = 14f + detailsView.setTextColor(context.getColor(android.R.color.darker_gray)) + container.addView(detailsView) + } + + // Identifier + if (patient.primaryIdentifier != null) { + val idView = TextView(context) + val idType = patient.primaryIdentifier.type ?: "ID" + idView.text = "$idType: ${patient.primaryIdentifier.value}" + idView.textSize = 12f + idView.setTextColor(context.getColor(android.R.color.darker_gray)) + container.addView(idView) + } + + // Add separator + addSeparator(container) + } + + private fun addClinicalAlerts(container: LinearLayout, alerts: List) { + val context = container.context + + val alertHeader = TextView(context) + alertHeader.text = "Clinical Alerts" + alertHeader.textSize = 16f + alertHeader.setTypeface(null, android.graphics.Typeface.BOLD) + container.addView(alertHeader) + + alerts.forEach { alert -> + val alertLayout = LinearLayout(context) + alertLayout.orientation = LinearLayout.HORIZONTAL + alertLayout.setPadding(0, 8, 0, 8) + + // Alert icon + val iconView = TextView(context) + iconView.text = when (alert.level) { + AlertLevel.HIGH -> "🚨" + AlertLevel.MEDIUM -> "⚠️" + AlertLevel.LOW -> "ℹ️" + } + iconView.textSize = 16f + iconView.setPadding(0, 0, 8, 0) + alertLayout.addView(iconView) + + // Alert content + val alertContent = LinearLayout(context) + alertContent.orientation = LinearLayout.VERTICAL + alertContent.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + + val titleView = TextView(context) + titleView.text = alert.title + titleView.textSize = 14f + titleView.setTypeface(null, android.graphics.Typeface.BOLD) + alertContent.addView(titleView) + + val messageView = TextView(context) + messageView.text = alert.message + messageView.textSize = 12f + alertContent.addView(messageView) + + alertLayout.addView(alertContent) + container.addView(alertLayout) + } + + addSeparator(container) + } + + private fun addDocumentSections(container: LinearLayout, sections: List) { + val context = container.context + + if (sections.isNotEmpty()) { + val sectionHeader = TextView(context) + sectionHeader.text = "Document Contents" + sectionHeader.textSize = 16f + sectionHeader.setTypeface(null, android.graphics.Typeface.BOLD) + container.addView(sectionHeader) + + sections.forEach { section -> + val sectionLayout = LinearLayout(context) + sectionLayout.orientation = LinearLayout.HORIZONTAL + sectionLayout.setPadding(0, 4, 0, 4) + + val bullet = TextView(context) + bullet.text = "•" + bullet.textSize = 14f + bullet.setPadding(0, 0, 8, 0) + sectionLayout.addView(bullet) + + val sectionText = TextView(context) + sectionText.text = "${section.title}: ${section.text}" + sectionText.textSize = 14f + sectionText.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + sectionLayout.addView(sectionText) + + container.addView(sectionLayout) + } + + addSeparator(container) + } + } + + private fun addDocumentSummary( + container: LinearLayout, + summary: org.who.gdhcnvalidator.ipsviewer.DocumentSummary, + metadata: org.who.gdhcnvalidator.ipsviewer.IpsMetadata + ) { + val context = container.context + + val summaryHeader = TextView(context) + summaryHeader.text = "Document Information" + summaryHeader.textSize = 16f + summaryHeader.setTypeface(null, android.graphics.Typeface.BOLD) + container.addView(summaryHeader) + + if (summary.totalClinicalItems > 0) { + addInfoItem(container, "Clinical Items", summary.totalClinicalItems.toString()) + } + + if (summary.lastUpdated != null) { + addInfoItem(container, "Last Updated", summary.lastUpdated) + } + + if (summary.author != null) { + addInfoItem(container, "Author", summary.author) + } + + addInfoItem(container, "Document Type", metadata.documentType) + addInfoItem(container, "FHIR Version", metadata.fhirVersion) + addInfoItem(container, "Total Resources", metadata.totalResources.toString()) + } + + private fun addInfoItem(container: LinearLayout, label: String, value: String) { + val context = container.context + val layout = LinearLayout(context) + layout.orientation = LinearLayout.HORIZONTAL + layout.setPadding(0, 2, 0, 2) + + val labelView = TextView(context) + labelView.text = "$label:" + labelView.textSize = 12f + labelView.setTypeface(null, android.graphics.Typeface.BOLD) + labelView.setPadding(0, 0, 8, 0) + layout.addView(labelView) + + val valueView = TextView(context) + valueView.text = value + valueView.textSize = 12f + valueView.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + layout.addView(valueView) + + container.addView(layout) + } + + private fun addSeparator(container: LinearLayout) { + val context = container.context + val separator = View(context) + separator.setBackgroundColor(context.getColor(android.R.color.darker_gray)) + val params = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1) + params.setMargins(0, 16, 0, 16) + separator.layoutParams = params + container.addView(separator) + } + + /** + * Fallback method for non-IPS bundles or when IPS processing fails + */ + private fun showBasicBundleInfo(file: VhlVerifier.VhlFileInfo, bundle: org.hl7.fhir.r4.model.Bundle) { + val message = buildBasicBundleText(bundle) AlertDialog.Builder(requireContext()) .setTitle(file.title) .setMessage(message) - .setPositiveButton("OK") { dialog, _ -> - dialog.dismiss() - } + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } .show() } /** - * Builds a human-readable text representation of IPS Bundle content + * Builds basic text representation for non-IPS FHIR bundles */ - private fun buildIpsDisplayText(ipsBundle: org.hl7.fhir.r4.model.Bundle): String { + private fun buildBasicBundleText(bundle: org.hl7.fhir.r4.model.Bundle): String { val builder = StringBuilder() - builder.append("FHIR International Patient Summary\n\n") + builder.append("FHIR Bundle Document\n\n") - // Extract patient information - val patient = ipsBundle.entry?.find { + // Extract patient information if available + val patient = bundle.entry?.find { it.resource is org.hl7.fhir.r4.model.Patient }?.resource as? org.hl7.fhir.r4.model.Patient @@ -519,30 +797,19 @@ class ResultFragment : Fragment() { builder.append("\n") } - // Count other resources - val resourceCounts = ipsBundle.entry?.groupBy { + // Count resources + val resourceCounts = bundle.entry?.groupBy { it.resource?.fhirType() }?.mapValues { it.value.size } if (resourceCounts != null && resourceCounts.isNotEmpty()) { - builder.append("Document Contents:\n") + builder.append("Bundle Contents:\n") resourceCounts.forEach { (type, count) -> - when (type) { - "Composition" -> builder.append("• Document Structure: $count entry\n") - "Patient" -> {} // Already handled above - "Medication", "MedicationStatement" -> builder.append("• Medications: $count entries\n") - "AllergyIntolerance" -> builder.append("• Allergies: $count entries\n") - "Condition" -> builder.append("• Conditions: $count entries\n") - "Immunization" -> builder.append("• Immunizations: $count entries\n") - "Procedure" -> builder.append("• Procedures: $count entries\n") - "DiagnosticReport" -> builder.append("• Lab Results: $count entries\n") - "Observation" -> builder.append("• Observations: $count entries\n") - else -> builder.append("• $type: $count entries\n") - } + builder.append("• $type: $count entries\n") } } - builder.append("\nThis is a structured health summary document following international standards.") + builder.append("\nThis document contains structured health information in FHIR format.") return builder.toString() } diff --git a/ips-viewer/README.md b/ips-viewer/README.md new file mode 100644 index 00000000..cde0d1a6 --- /dev/null +++ b/ips-viewer/README.md @@ -0,0 +1,244 @@ +# IPS Viewer - Kotlin Library for FHIR International Patient Summary + +A comprehensive Kotlin library for parsing, processing, and displaying FHIR International Patient Summary (IPS) documents. This library provides structured data extraction, clinical alert detection, and formatted display capabilities for IPS bundles. + +## Features + +### 🔍 **FHIR IPS Parsing** +- Extracts structured data from FHIR R4 Bundle resources +- Parses patient demographics, medications, allergies, conditions, immunizations, and more +- Handles multiple date formats and resource relationships +- Validates IPS bundle structure + +### 🧠 **Intelligent Processing** +- Applies clinical business rules for data organization +- Generates clinical alerts for critical information (severe allergies, conditions) +- Prioritizes active vs inactive medical information +- Creates structured sections for easy consumption + +### 📊 **Rich Data Models** +- Comprehensive data classes for all IPS components +- Type-safe representations of clinical concepts +- Structured patient information with demographics and identifiers +- Clinical alerts with severity levels (HIGH, MEDIUM, LOW) + +### 📝 **Flexible Display** +- Multiple output formats: structured data, formatted text, brief summaries +- Customizable formatting for different display contexts +- Human-readable text generation with proper medical terminology +- Platform-agnostic output suitable for Android, web, or console display + +## Architecture + +The library follows a clean, modular architecture: + +``` +IpsViewer (Facade) +├── IpsParser # Raw FHIR → Structured Data +├── IpsProcessor # Business Logic & Organization +└── IpsFormatter # Display & Text Generation +``` + +### Core Components + +- **`IpsParser`**: Converts FHIR Bundle resources into structured Kotlin data classes +- **`IpsProcessor`**: Applies clinical business rules and generates alerts +- **`IpsFormatter`**: Creates human-readable text representations +- **`IpsViewer`**: Main facade providing simple API access +- **`SafeIpsViewer`**: Error-safe version returning Result types + +## Usage Examples + +### Basic IPS Processing + +```kotlin +import org.who.gdhcnvalidator.ipsviewer.IpsViewer +import org.hl7.fhir.r4.model.Bundle + +val ipsViewer = IpsViewer() +val ipsBundle: Bundle = // ... your FHIR Bundle + +// Validate IPS bundle +if (ipsViewer.isValidIpsBundle(ipsBundle)) { + // Process the IPS document + val processedIps = ipsViewer.processIpsBundle(ipsBundle) + + // Access structured data + println("Patient: ${processedIps.patient.displayName}") + println("Age: ${processedIps.patient.ageInfo}") + + // Check for clinical alerts + processedIps.alerts.forEach { alert -> + println("${alert.level}: ${alert.title} - ${alert.message}") + } +} +``` + +### Text Formatting + +```kotlin +// Generate comprehensive text summary +val fullText = ipsViewer.formatIpsAsText(ipsBundle) +println(fullText) + +// Generate brief summary +val briefSummary = ipsViewer.formatIpsBrief(ipsBundle) +println(briefSummary) +``` + +### Safe Error Handling + +```kotlin +import org.who.gdhcnvalidator.ipsviewer.SafeIpsViewer +import org.who.gdhcnvalidator.ipsviewer.IpsResult + +val safeViewer = SafeIpsViewer() + +when (val result = safeViewer.processIpsBundle(ipsBundle)) { + is IpsResult.Success -> { + val processedIps = result.data + // Handle successful processing + } + is IpsResult.Error -> { + println("Error: ${result.message}") + // Handle error case + } +} +``` + +### Clinical Information Access + +```kotlin +// Extract specific clinical data +val patientInfo = ipsViewer.extractPatientInfo(ipsBundle) +val alerts = ipsViewer.getClinicalAlerts(ipsBundle) +val metadata = ipsViewer.getIpsMetadata(ipsBundle) + +// Access detailed clinical data from processed IPS +val processedIps = ipsViewer.processIpsBundle(ipsBundle) + +processedIps.sections.forEach { section -> + println("${section.title}: ${section.text}") +} +``` + +## Data Models + +### Key Data Classes + +- **`ProcessedIpsDocument`**: Complete processed IPS with all components +- **`ProcessedPatientInfo`**: Patient demographics optimized for display +- **`ClinicalAlert`**: Important clinical information with severity levels +- **`IpsSection`**: Organized clinical content sections +- **`IpsMetadata`**: Document metadata and resource counts + +### Supported FHIR Resources + +- **Patient**: Demographics, identifiers, contact information +- **Composition**: Document structure and metadata +- **MedicationStatement/Medication**: Current and past medications +- **AllergyIntolerance**: Allergies and intolerances with reactions +- **Condition**: Medical conditions and problems +- **Immunization**: Vaccination records +- **Procedure**: Medical procedures +- **Observation**: Clinical observations and vital signs +- **DiagnosticReport**: Laboratory results and reports + +## Clinical Alerts + +The library automatically generates clinical alerts for: + +- **HIGH Priority**: Critical allergies with high criticality +- **MEDIUM Priority**: Severe active medical conditions +- **LOW Priority**: Incomplete vaccination series, informational items + +## Integration + +### Android Integration + +```kotlin +// In your Android fragment/activity +private fun displayIpsContent(ipsBundle: Bundle) { + val ipsViewer = IpsViewer() + + try { + val processedIps = ipsViewer.processIpsBundle(ipsBundle) + + // Create Android UI components + createPatientHeader(processedIps.patient) + createClinicalAlerts(processedIps.alerts) + createContentSections(processedIps.sections) + + } catch (e: Exception) { + // Fallback to basic bundle display + showBasicBundleInfo(ipsBundle) + } +} +``` + +### Web Integration + +The library's data models and text output can be easily adapted for web display: + +```kotlin +// Generate JSON for web frontend +val processedIps = ipsViewer.processIpsBundle(ipsBundle) +val jsonData = gson.toJson(processedIps) + +// Or use formatted text +val htmlContent = ipsViewer.formatIpsAsText(ipsBundle) + .replace("\n", "
") + .replace("•", "•") +``` + +## Dependencies + +- **FHIR R4**: `ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.6.0` +- **Kotlin Standard Library**: For data classes and processing +- **Java Time API**: For date handling (desugaring supported) + +## Testing + +The library includes comprehensive tests covering: + +- IPS bundle parsing and validation +- Clinical alert generation +- Text formatting accuracy +- Error handling scenarios +- Edge cases and malformed data + +Run tests with: +```bash +./gradlew :ips-viewer:test +``` + +## Future Enhancements + +### Potential IPSViewer.com Integration + +This Kotlin library is designed to potentially replace or complement the TypeScript business logic in the [IPSViewer](https://github.com/jddamore/IPSviewer) project: + +1. **Kotlin/JS Compilation**: The library could be compiled to JavaScript for direct web use +2. **Shared Data Models**: Common data structures between Android and web versions +3. **Consistent Business Logic**: Same IPS interpretation rules across platforms +4. **API Compatibility**: RESTful endpoints using this library for processing + +### Planned Features + +- **Template-based Output**: Customizable display templates +- **Localization Support**: Multi-language clinical terminology +- **Enhanced Validation**: Deeper IPS conformance checking +- **Performance Optimization**: Large bundle handling improvements + +## Contributing + +The library follows standard Kotlin coding conventions and includes: + +- Comprehensive documentation +- Unit tests for all public APIs +- Error handling best practices +- Clean architecture principles + +## License + +This library is part of the WHO Global Digital Health Certification Network (GDHCN) Validator project and follows the same licensing terms. \ No newline at end of file diff --git a/ips-viewer/build.gradle b/ips-viewer/build.gradle new file mode 100644 index 00000000..d771dca5 --- /dev/null +++ b/ips-viewer/build.gradle @@ -0,0 +1,24 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib" + + // FHIR R4 dependency (same as other modules) + implementation 'ca.uhn.hapi.fhir:hapi-fhir-base:6.6.0' + implementation 'ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.6.0' + + // Testing + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0' + testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.0' + testImplementation "org.jetbrains.kotlin:kotlin-test-junit5" +} + +test { + useJUnitPlatform() +} + +kotlin { + jvmToolchain(11) +} \ No newline at end of file diff --git a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsDataModel.kt b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsDataModel.kt new file mode 100644 index 00000000..d777fc40 --- /dev/null +++ b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsDataModel.kt @@ -0,0 +1,204 @@ +package org.who.gdhcnvalidator.ipsviewer + +import java.time.LocalDate + +/** + * Structured data models for FHIR International Patient Summary content + */ + +/** + * Complete IPS document representation + */ +data class IpsDocument( + val patient: IpsPatient, + val composition: IpsComposition?, + val medications: List = emptyList(), + val allergies: List = emptyList(), + val conditions: List = emptyList(), + val immunizations: List = emptyList(), + val procedures: List = emptyList(), + val observations: List = emptyList(), + val diagnosticReports: List = emptyList(), + val otherResources: Map = emptyMap() +) + +/** + * Patient demographic information + */ +data class IpsPatient( + val name: String?, + val givenNames: List = emptyList(), + val familyName: String?, + val birthDate: LocalDate?, + val gender: String?, + val identifiers: List = emptyList(), + val telecom: List = emptyList(), + val addresses: List = emptyList() +) + +/** + * Document composition metadata + */ +data class IpsComposition( + val title: String?, + val date: String?, + val author: String?, + val sections: List = emptyList() +) + +/** + * IPS document section + */ +data class IpsSection( + val title: String?, + val code: String?, + val text: String?, + val resourceCount: Int = 0 +) + +/** + * Medication information + */ +data class IpsMedication( + val name: String?, + val status: String?, + val dosage: String?, + val route: String?, + val frequency: String?, + val effectiveDate: String?, + val note: String? +) + +/** + * Allergy/Intolerance information + */ +data class IpsAllergy( + val substance: String?, + val category: String?, + val criticality: String?, + val type: String?, + val clinicalStatus: String?, + val verificationStatus: String?, + val reactions: List = emptyList(), + val onset: String?, + val note: String? +) + +/** + * Allergic reaction details + */ +data class IpsReaction( + val manifestation: String?, + val severity: String?, + val note: String? +) + +/** + * Medical condition/problem + */ +data class IpsCondition( + val name: String?, + val category: String?, + val severity: String?, + val clinicalStatus: String?, + val verificationStatus: String?, + val onsetDate: String?, + val abatementDate: String?, + val note: String? +) + +/** + * Immunization record + */ +data class IpsImmunization( + val vaccineCode: String?, + val vaccineName: String?, + val status: String?, + val occurrenceDate: String?, + val doseNumber: Int?, + val seriesDoses: Int?, + val lotNumber: String?, + val manufacturer: String?, + val site: String?, + val route: String?, + val performer: String?, + val note: String? +) + +/** + * Medical procedure + */ +data class IpsProcedure( + val name: String?, + val status: String?, + val category: String?, + val performedDate: String?, + val performer: String?, + val bodySite: String?, + val outcome: String?, + val note: String? +) + +/** + * Clinical observation + */ +data class IpsObservation( + val name: String?, + val category: String?, + val status: String?, + val value: String?, + val unit: String?, + val interpretation: String?, + val effectiveDate: String?, + val note: String?, + val referenceRange: String? +) + +/** + * Diagnostic report + */ +data class IpsDiagnosticReport( + val name: String?, + val category: String?, + val status: String?, + val effectiveDate: String?, + val issued: String?, + val performer: String?, + val conclusion: String?, + val presentedForm: String? +) + +/** + * Patient identifier + */ +data class IpsIdentifier( + val system: String?, + val value: String?, + val type: String?, + val use: String? +) + +/** + * Contact point (phone, email, etc.) + */ +data class IpsContactPoint( + val system: String?, + val value: String?, + val use: String?, + val rank: Int? +) + +/** + * Address information + */ +data class IpsAddress( + val use: String?, + val type: String?, + val text: String?, + val line: List = emptyList(), + val city: String?, + val district: String?, + val state: String?, + val postalCode: String?, + val country: String? +) \ No newline at end of file diff --git a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsFormatter.kt b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsFormatter.kt new file mode 100644 index 00000000..f104e7c4 --- /dev/null +++ b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsFormatter.kt @@ -0,0 +1,214 @@ +package org.who.gdhcnvalidator.ipsviewer + +/** + * Formatter for creating human-readable text representations of IPS data + */ +class IpsFormatter { + + /** + * Creates a comprehensive text summary of the IPS document + */ + fun formatIpsDocument(processedIps: ProcessedIpsDocument): String { + val builder = StringBuilder() + + // Document header + builder.append("${processedIps.summary.documentTitle}\n") + builder.append("=" * processedIps.summary.documentTitle.length).append("\n\n") + + // Patient information + builder.append(formatPatientInfo(processedIps.patient)) + builder.append("\n") + + // Clinical alerts + if (processedIps.alerts.isNotEmpty()) { + builder.append(formatAlerts(processedIps.alerts)) + builder.append("\n") + } + + // Document summary + builder.append(formatDocumentSummary(processedIps.summary)) + builder.append("\n") + + // Content sections + if (processedIps.sections.isNotEmpty()) { + builder.append("Document Contents:\n") + processedIps.sections.forEach { section -> + builder.append("• ${section.title}: ${section.text}\n") + } + builder.append("\n") + } + + // Document metadata + builder.append(formatMetadata(processedIps.metadata)) + + return builder.toString() + } + + /** + * Creates a brief summary of the IPS document + */ + fun formatBriefSummary(processedIps: ProcessedIpsDocument): String { + val builder = StringBuilder() + + builder.append("Patient: ${processedIps.patient.displayName}\n") + + if (processedIps.patient.ageInfo != null) { + builder.append("${processedIps.patient.ageInfo}") + if (processedIps.patient.gender != null) { + builder.append(", ${processedIps.patient.gender}") + } + builder.append("\n") + } else if (processedIps.patient.gender != null) { + builder.append("Gender: ${processedIps.patient.gender}\n") + } + + if (processedIps.summary.totalClinicalItems > 0) { + builder.append("Clinical Items: ${processedIps.summary.totalClinicalItems}\n") + } + + if (processedIps.alerts.isNotEmpty()) { + val highAlerts = processedIps.alerts.count { it.level == AlertLevel.HIGH } + if (highAlerts > 0) { + builder.append("⚠️ $highAlerts critical alerts\n") + } + } + + return builder.toString() + } + + private fun formatPatientInfo(patient: ProcessedPatientInfo): String { + val builder = StringBuilder() + builder.append("Patient Information:\n") + + builder.append("• Name: ${patient.displayName}\n") + + if (patient.birthDate != null) { + builder.append("• Birth Date: ${patient.birthDate}") + if (patient.ageInfo != null) { + builder.append(" (${patient.ageInfo})") + } + builder.append("\n") + } + + if (patient.gender != null) { + builder.append("• Gender: ${patient.gender}\n") + } + + if (patient.primaryIdentifier != null) { + val id = patient.primaryIdentifier + val idType = id.type ?: "ID" + builder.append("• $idType: ${id.value}\n") + } + + if (patient.contactInfo.isNotEmpty()) { + patient.contactInfo.forEach { contact -> + val system = contact.system?.replaceFirstChar { it.uppercaseChar() } ?: "Contact" + builder.append("• $system: ${contact.value}\n") + } + } + + if (patient.address != null) { + val addr = patient.address + val addressText = addr.text ?: buildAddressText(addr) + if (addressText.isNotEmpty()) { + builder.append("• Address: $addressText\n") + } + } + + return builder.toString() + } + + private fun formatAlerts(alerts: List): String { + val builder = StringBuilder() + builder.append("Clinical Alerts:\n") + + // Sort by severity: HIGH -> MEDIUM -> LOW + val sortedAlerts = alerts.sortedBy { + when (it.level) { + AlertLevel.HIGH -> 0 + AlertLevel.MEDIUM -> 1 + AlertLevel.LOW -> 2 + } + } + + sortedAlerts.forEach { alert -> + val icon = when (alert.level) { + AlertLevel.HIGH -> "🚨" + AlertLevel.MEDIUM -> "⚠️" + AlertLevel.LOW -> "ℹ️" + } + + builder.append("$icon ${alert.title}: ${alert.message}\n") + + if (alert.details.isNotEmpty()) { + alert.details.take(3).forEach { detail -> // Limit to 3 details + builder.append(" - $detail\n") + } + if (alert.details.size > 3) { + builder.append(" - ... and ${alert.details.size - 3} more\n") + } + } + } + + return builder.toString() + } + + private fun formatDocumentSummary(summary: DocumentSummary): String { + val builder = StringBuilder() + builder.append("Summary:\n") + + if (summary.totalClinicalItems > 0) { + builder.append("• Total Clinical Items: ${summary.totalClinicalItems}\n") + } + + if (summary.lastUpdated != null) { + builder.append("• Last Updated: ${summary.lastUpdated}\n") + } + + if (summary.author != null) { + builder.append("• Author: ${summary.author}\n") + } + + return builder.toString() + } + + private fun formatMetadata(metadata: IpsMetadata): String { + val builder = StringBuilder() + builder.append("Document Details:\n") + builder.append("• Type: ${metadata.documentType}\n") + builder.append("• FHIR Version: ${metadata.fhirVersion}\n") + builder.append("• Total Resources: ${metadata.totalResources}\n") + + if (metadata.resourceCounts.isNotEmpty()) { + builder.append("• Resource Breakdown:\n") + metadata.resourceCounts.forEach { (type, count) -> + builder.append(" - $type: $count\n") + } + } + + return builder.toString() + } + + private fun buildAddressText(address: IpsAddress): String { + val parts = mutableListOf() + + if (address.line.isNotEmpty()) { + parts.addAll(address.line) + } + + listOfNotNull(address.city, address.state, address.postalCode, address.country).let { + if (it.isNotEmpty()) { + parts.add(it.joinToString(", ")) + } + } + + return parts.joinToString(", ") + } + + /** + * Helper extension for string repetition + */ + private operator fun String.times(times: Int): String { + return this.repeat(times) + } +} \ No newline at end of file diff --git a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsParser.kt b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsParser.kt new file mode 100644 index 00000000..39c23638 --- /dev/null +++ b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsParser.kt @@ -0,0 +1,300 @@ +package org.who.gdhcnvalidator.ipsviewer + +import org.hl7.fhir.r4.model.* +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +/** + * Parser for FHIR International Patient Summary bundles + * Extracts structured data from IPS Bundle resources + */ +class IpsParser { + + companion object { + private val DATE_FORMATTERS = listOf( + DateTimeFormatter.ofPattern("yyyy-MM-dd"), + DateTimeFormatter.ofPattern("yyyy-MM"), + DateTimeFormatter.ofPattern("yyyy") + ) + } + + /** + * Parse a FHIR Bundle into structured IPS data + */ + fun parseIpsBundle(bundle: Bundle): IpsDocument { + val entries = bundle.entry ?: emptyList() + + // Extract patient (required for IPS) + val patientResource = entries.find { it.resource is Patient }?.resource as? Patient + val patient = patientResource?.let { parsePatient(it) } + ?: throw IllegalArgumentException("IPS Bundle must contain a Patient resource") + + // Extract composition + val compositionResource = entries.find { it.resource is Composition }?.resource as? Composition + val composition = compositionResource?.let { parseComposition(it) } + + // Extract clinical resources + val medications = entries.filter { it.resource is MedicationStatement || it.resource is Medication } + .mapNotNull { parseMedication(it.resource) } + + val allergies = entries.filter { it.resource is AllergyIntolerance } + .mapNotNull { parseAllergy(it.resource as AllergyIntolerance) } + + val conditions = entries.filter { it.resource is Condition } + .mapNotNull { parseCondition(it.resource as Condition) } + + val immunizations = entries.filter { it.resource is Immunization } + .mapNotNull { parseImmunization(it.resource as Immunization) } + + val procedures = entries.filter { it.resource is Procedure } + .mapNotNull { parseProcedure(it.resource as Procedure) } + + val observations = entries.filter { it.resource is Observation } + .mapNotNull { parseObservation(it.resource as Observation) } + + val diagnosticReports = entries.filter { it.resource is DiagnosticReport } + .mapNotNull { parseDiagnosticReport(it.resource as DiagnosticReport) } + + // Count other resource types + val handledTypes = setOf("Patient", "Composition", "MedicationStatement", "Medication", + "AllergyIntolerance", "Condition", "Immunization", "Procedure", "Observation", "DiagnosticReport") + + val otherResources = entries.groupBy { it.resource?.fhirType() } + .filterKeys { it != null && !handledTypes.contains(it) } + .mapValues { it.value.size } + .mapKeys { it.key!! } + + return IpsDocument( + patient = patient, + composition = composition, + medications = medications, + allergies = allergies, + conditions = conditions, + immunizations = immunizations, + procedures = procedures, + observations = observations, + diagnosticReports = diagnosticReports, + otherResources = otherResources + ) + } + + private fun parsePatient(patient: Patient): IpsPatient { + val name = patient.name?.firstOrNull() + val givenNames = name?.given?.map { it.value } ?: emptyList() + val familyName = name?.family + val fullName = (givenNames + listOfNotNull(familyName)).joinToString(" ").takeIf { it.isNotBlank() } + + val birthDate = patient.birthDate?.let { parseDate(it.toString()) } + val gender = patient.gender?.display ?: patient.gender?.name + + val identifiers = patient.identifier?.map { parseIdentifier(it) } ?: emptyList() + val telecom = patient.telecom?.map { parseContactPoint(it) } ?: emptyList() + val addresses = patient.address?.map { parseAddress(it) } ?: emptyList() + + return IpsPatient( + name = fullName, + givenNames = givenNames, + familyName = familyName, + birthDate = birthDate, + gender = gender, + identifiers = identifiers, + telecom = telecom, + addresses = addresses + ) + } + + private fun parseComposition(composition: Composition): IpsComposition { + val sections = composition.section?.map { section -> + IpsSection( + title = section.title, + code = section.code?.coding?.firstOrNull()?.code, + text = section.text?.div?.toString(), + resourceCount = section.entry?.size ?: 0 + ) + } ?: emptyList() + + return IpsComposition( + title = composition.title, + date = composition.date?.toString(), + author = composition.author?.firstOrNull()?.display, + sections = sections + ) + } + + private fun parseMedication(resource: Resource): IpsMedication? { + return when (resource) { + is MedicationStatement -> { + val medication = resource.medicationCodeableConcept?.text + ?: resource.medicationCodeableConcept?.coding?.firstOrNull()?.display + + IpsMedication( + name = medication, + status = resource.status?.display, + dosage = resource.dosage?.firstOrNull()?.text, + route = resource.dosage?.firstOrNull()?.route?.text, + frequency = resource.dosage?.firstOrNull()?.timing?.code?.text, + effectiveDate = resource.effective?.toString(), + note = resource.note?.firstOrNull()?.text + ) + } + is Medication -> { + IpsMedication( + name = resource.code?.text ?: resource.code?.coding?.firstOrNull()?.display, + status = null, + dosage = null, + route = null, + frequency = null, + effectiveDate = null, + note = null + ) + } + else -> null + } + } + + private fun parseAllergy(allergy: AllergyIntolerance): IpsAllergy { + val reactions = allergy.reaction?.map { reaction -> + IpsReaction( + manifestation = reaction.manifestation?.firstOrNull()?.text, + severity = reaction.severity?.display, + note = reaction.note?.firstOrNull()?.text + ) + } ?: emptyList() + + return IpsAllergy( + substance = allergy.code?.text ?: allergy.code?.coding?.firstOrNull()?.display, + category = allergy.category?.firstOrNull()?.display, + criticality = allergy.criticality?.display, + type = allergy.type?.display, + clinicalStatus = allergy.clinicalStatus?.coding?.firstOrNull()?.display, + verificationStatus = allergy.verificationStatus?.coding?.firstOrNull()?.display, + reactions = reactions, + onset = allergy.onset?.toString(), + note = allergy.note?.firstOrNull()?.text + ) + } + + private fun parseCondition(condition: Condition): IpsCondition { + return IpsCondition( + name = condition.code?.text ?: condition.code?.coding?.firstOrNull()?.display, + category = condition.category?.firstOrNull()?.coding?.firstOrNull()?.display, + severity = condition.severity?.coding?.firstOrNull()?.display, + clinicalStatus = condition.clinicalStatus?.coding?.firstOrNull()?.display, + verificationStatus = condition.verificationStatus?.coding?.firstOrNull()?.display, + onsetDate = condition.onset?.toString(), + abatementDate = condition.abatement?.toString(), + note = condition.note?.firstOrNull()?.text + ) + } + + private fun parseImmunization(immunization: Immunization): IpsImmunization { + return IpsImmunization( + vaccineCode = immunization.vaccineCode?.coding?.firstOrNull()?.code, + vaccineName = immunization.vaccineCode?.text ?: immunization.vaccineCode?.coding?.firstOrNull()?.display, + status = immunization.status?.display, + occurrenceDate = immunization.occurrence?.toString(), + doseNumber = immunization.protocolApplied?.firstOrNull()?.doseNumber?.value?.toIntOrNull(), + seriesDoses = immunization.protocolApplied?.firstOrNull()?.seriesDoses?.value?.toIntOrNull(), + lotNumber = immunization.lotNumber, + manufacturer = immunization.manufacturer?.display, + site = immunization.site?.text, + route = immunization.route?.text, + performer = immunization.performer?.firstOrNull()?.actor?.display, + note = immunization.note?.firstOrNull()?.text + ) + } + + private fun parseProcedure(procedure: Procedure): IpsProcedure { + return IpsProcedure( + name = procedure.code?.text ?: procedure.code?.coding?.firstOrNull()?.display, + status = procedure.status?.display, + category = procedure.category?.text, + performedDate = procedure.performed?.toString(), + performer = procedure.performer?.firstOrNull()?.actor?.display, + bodySite = procedure.bodySite?.firstOrNull()?.text, + outcome = procedure.outcome?.text, + note = procedure.note?.firstOrNull()?.text + ) + } + + private fun parseObservation(observation: Observation): IpsObservation { + val value = when { + observation.hasValueQuantity() -> "${observation.valueQuantity.value} ${observation.valueQuantity.unit ?: observation.valueQuantity.code}" + observation.hasValueString() -> observation.valueStringType.value + observation.hasValueCodeableConcept() -> observation.valueCodeableConcept.text + observation.hasValueBoolean() -> observation.valueBooleanType.value.toString() + observation.hasValueInteger() -> observation.valueIntegerType.value.toString() + observation.hasValueDateTime() -> observation.valueDateTimeType.value.toString() + else -> null + } + + return IpsObservation( + name = observation.code?.text ?: observation.code?.coding?.firstOrNull()?.display, + category = observation.category?.firstOrNull()?.coding?.firstOrNull()?.display, + status = observation.status?.display, + value = value, + unit = observation.valueQuantity?.unit, + interpretation = observation.interpretation?.firstOrNull()?.text, + effectiveDate = observation.effective?.toString(), + note = observation.note?.firstOrNull()?.text, + referenceRange = observation.referenceRange?.firstOrNull()?.text?.div?.toString() + ) + } + + private fun parseDiagnosticReport(report: DiagnosticReport): IpsDiagnosticReport { + return IpsDiagnosticReport( + name = report.code?.text ?: report.code?.coding?.firstOrNull()?.display, + category = report.category?.firstOrNull()?.coding?.firstOrNull()?.display, + status = report.status?.display, + effectiveDate = report.effective?.toString(), + issued = report.issued?.toString(), + performer = report.performer?.firstOrNull()?.display, + conclusion = report.conclusion, + presentedForm = report.presentedForm?.firstOrNull()?.title + ) + } + + private fun parseIdentifier(identifier: Identifier): IpsIdentifier { + return IpsIdentifier( + system = identifier.system, + value = identifier.value, + type = identifier.type?.text, + use = identifier.use?.display + ) + } + + private fun parseContactPoint(contact: ContactPoint): IpsContactPoint { + return IpsContactPoint( + system = contact.system?.display, + value = contact.value, + use = contact.use?.display, + rank = contact.rank + ) + } + + private fun parseAddress(address: Address): IpsAddress { + return IpsAddress( + use = address.use?.display, + type = address.type?.display, + text = address.text, + line = address.line?.map { it.value } ?: emptyList(), + city = address.city, + district = address.district, + state = address.state, + postalCode = address.postalCode, + country = address.country + ) + } + + private fun parseDate(dateString: String): LocalDate? { + for (formatter in DATE_FORMATTERS) { + try { + return LocalDate.parse(dateString, formatter) + } catch (e: DateTimeParseException) { + // Try next formatter + } + } + return null + } +} \ No newline at end of file diff --git a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsProcessor.kt b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsProcessor.kt new file mode 100644 index 00000000..ecad7a71 --- /dev/null +++ b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsProcessor.kt @@ -0,0 +1,272 @@ +package org.who.gdhcnvalidator.ipsviewer + +/** + * Processor for IPS documents that applies business logic and organizes data + */ +class IpsProcessor { + + /** + * Processes an IPS document and returns organized data with business rules applied + */ + fun processIpsDocument(ipsDocument: IpsDocument): ProcessedIpsDocument { + return ProcessedIpsDocument( + patient = processPatientInfo(ipsDocument.patient), + summary = createDocumentSummary(ipsDocument), + sections = createSections(ipsDocument), + alerts = generateAlerts(ipsDocument), + metadata = extractMetadata(ipsDocument) + ) + } + + private fun processPatientInfo(patient: IpsPatient): ProcessedPatientInfo { + val displayName = patient.name ?: "Unknown Patient" + val ageInfo = patient.birthDate?.let { birthDate -> + val age = java.time.Period.between(birthDate, java.time.LocalDate.now()).years + "Age $age" + } + + val primaryIdentifier = patient.identifiers.find { it.use == "usual" || it.use == "official" } + ?: patient.identifiers.firstOrNull() + + val contactInfo = patient.telecom.filter { it.system in listOf("phone", "email") } + .sortedBy { if (it.system == "phone") 0 else 1 } + + return ProcessedPatientInfo( + displayName = displayName, + birthDate = patient.birthDate?.toString(), + ageInfo = ageInfo, + gender = patient.gender, + primaryIdentifier = primaryIdentifier, + contactInfo = contactInfo.take(2), // Limit to 2 most relevant contacts + address = patient.addresses.find { it.use == "home" } ?: patient.addresses.firstOrNull() + ) + } + + private fun createDocumentSummary(ipsDocument: IpsDocument): DocumentSummary { + val totalItems = ipsDocument.medications.size + ipsDocument.allergies.size + + ipsDocument.conditions.size + ipsDocument.immunizations.size + + ipsDocument.procedures.size + ipsDocument.observations.size + + ipsDocument.diagnosticReports.size + + val lastUpdated = ipsDocument.composition?.date + + return DocumentSummary( + totalClinicalItems = totalItems, + lastUpdated = lastUpdated, + documentTitle = ipsDocument.composition?.title ?: "International Patient Summary", + author = ipsDocument.composition?.author + ) + } + + private fun createSections(ipsDocument: IpsDocument): List { + val sections = mutableListOf() + + // Active medications + if (ipsDocument.medications.isNotEmpty()) { + val activeMeds = ipsDocument.medications.filter { + it.status == null || it.status.equals("active", ignoreCase = true) + } + sections.add(IpsSection( + title = "Current Medications", + code = "medications", + text = if (activeMeds.isNotEmpty()) + "${activeMeds.size} active medications" + else "No active medications", + resourceCount = activeMeds.size + )) + } + + // Active allergies + if (ipsDocument.allergies.isNotEmpty()) { + val activeAllergies = ipsDocument.allergies.filter { + it.clinicalStatus == null || it.clinicalStatus.equals("active", ignoreCase = true) + } + sections.add(IpsSection( + title = "Allergies & Intolerances", + code = "allergies", + text = if (activeAllergies.isNotEmpty()) + "${activeAllergies.size} known allergies/intolerances" + else "No known allergies", + resourceCount = activeAllergies.size + )) + } + + // Active conditions + if (ipsDocument.conditions.isNotEmpty()) { + val activeConditions = ipsDocument.conditions.filter { + it.clinicalStatus == null || it.clinicalStatus.equals("active", ignoreCase = true) + } + sections.add(IpsSection( + title = "Medical Conditions", + code = "conditions", + text = "${activeConditions.size} active conditions", + resourceCount = activeConditions.size + )) + } + + // Immunizations + if (ipsDocument.immunizations.isNotEmpty()) { + sections.add(IpsSection( + title = "Immunizations", + code = "immunizations", + text = "${ipsDocument.immunizations.size} vaccination records", + resourceCount = ipsDocument.immunizations.size + )) + } + + // Recent procedures + if (ipsDocument.procedures.isNotEmpty()) { + sections.add(IpsSection( + title = "Procedures", + code = "procedures", + text = "${ipsDocument.procedures.size} recorded procedures", + resourceCount = ipsDocument.procedures.size + )) + } + + // Lab results and observations + if (ipsDocument.observations.isNotEmpty() || ipsDocument.diagnosticReports.isNotEmpty()) { + val totalLabItems = ipsDocument.observations.size + ipsDocument.diagnosticReports.size + sections.add(IpsSection( + title = "Laboratory Results", + code = "laboratory", + text = "$totalLabItems lab results and observations", + resourceCount = totalLabItems + )) + } + + return sections + } + + private fun generateAlerts(ipsDocument: IpsDocument): List { + val alerts = mutableListOf() + + // Critical allergies + val criticalAllergies = ipsDocument.allergies.filter { + it.criticality?.equals("high", ignoreCase = true) == true + } + if (criticalAllergies.isNotEmpty()) { + alerts.add(ClinicalAlert( + level = AlertLevel.HIGH, + title = "Critical Allergies", + message = "${criticalAllergies.size} high-criticality allergies noted", + details = criticalAllergies.map { it.substance ?: "Unknown substance" } + )) + } + + // Active high-severity conditions + val severeConditions = ipsDocument.conditions.filter { + it.severity?.contains("severe", ignoreCase = true) == true && + (it.clinicalStatus == null || it.clinicalStatus.equals("active", ignoreCase = true)) + } + if (severeConditions.isNotEmpty()) { + alerts.add(ClinicalAlert( + level = AlertLevel.MEDIUM, + title = "Severe Conditions", + message = "${severeConditions.size} severe medical conditions", + details = severeConditions.map { it.name ?: "Unknown condition" } + )) + } + + // Incomplete vaccination series + val incompleteVaccinations = ipsDocument.immunizations.filter { immunization -> + immunization.doseNumber != null && immunization.seriesDoses != null && + immunization.doseNumber!! < immunization.seriesDoses!! + }.groupBy { it.vaccineName } + + if (incompleteVaccinations.isNotEmpty()) { + alerts.add(ClinicalAlert( + level = AlertLevel.LOW, + title = "Incomplete Vaccinations", + message = "${incompleteVaccinations.size} vaccine series may be incomplete", + details = incompleteVaccinations.keys.filterNotNull() + )) + } + + return alerts + } + + private fun extractMetadata(ipsDocument: IpsDocument): IpsMetadata { + val resourceCounts = mutableMapOf() + + if (ipsDocument.medications.isNotEmpty()) resourceCounts["Medications"] = ipsDocument.medications.size + if (ipsDocument.allergies.isNotEmpty()) resourceCounts["Allergies"] = ipsDocument.allergies.size + if (ipsDocument.conditions.isNotEmpty()) resourceCounts["Conditions"] = ipsDocument.conditions.size + if (ipsDocument.immunizations.isNotEmpty()) resourceCounts["Immunizations"] = ipsDocument.immunizations.size + if (ipsDocument.procedures.isNotEmpty()) resourceCounts["Procedures"] = ipsDocument.procedures.size + if (ipsDocument.observations.isNotEmpty()) resourceCounts["Observations"] = ipsDocument.observations.size + if (ipsDocument.diagnosticReports.isNotEmpty()) resourceCounts["Diagnostic Reports"] = ipsDocument.diagnosticReports.size + + resourceCounts.putAll(ipsDocument.otherResources) + + return IpsMetadata( + documentType = "International Patient Summary", + fhirVersion = "R4", + resourceCounts = resourceCounts, + totalResources = resourceCounts.values.sum() + 1 // +1 for patient + ) + } +} + +/** + * Processed IPS document with organized data and business rules applied + */ +data class ProcessedIpsDocument( + val patient: ProcessedPatientInfo, + val summary: DocumentSummary, + val sections: List, + val alerts: List, + val metadata: IpsMetadata +) + +/** + * Processed patient information optimized for display + */ +data class ProcessedPatientInfo( + val displayName: String, + val birthDate: String?, + val ageInfo: String?, + val gender: String?, + val primaryIdentifier: IpsIdentifier?, + val contactInfo: List, + val address: IpsAddress? +) + +/** + * Document summary information + */ +data class DocumentSummary( + val totalClinicalItems: Int, + val lastUpdated: String?, + val documentTitle: String, + val author: String? +) + +/** + * Clinical alerts for important patient information + */ +data class ClinicalAlert( + val level: AlertLevel, + val title: String, + val message: String, + val details: List = emptyList() +) + +/** + * Alert severity levels + */ +enum class AlertLevel { + HIGH, // Critical information (severe allergies, etc.) + MEDIUM, // Important information (severe conditions, etc.) + LOW // Informational (incomplete vaccinations, etc.) +} + +/** + * Document metadata + */ +data class IpsMetadata( + val documentType: String, + val fhirVersion: String, + val resourceCounts: Map, + val totalResources: Int +) \ No newline at end of file diff --git a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewer.kt b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewer.kt new file mode 100644 index 00000000..b59aa48f --- /dev/null +++ b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewer.kt @@ -0,0 +1,151 @@ +package org.who.gdhcnvalidator.ipsviewer + +import org.hl7.fhir.r4.model.Bundle + +/** + * Main facade for IPS viewing functionality + * Provides a simple interface for parsing and displaying FHIR IPS documents + */ +class IpsViewer { + + private val parser = IpsParser() + private val processor = IpsProcessor() + private val formatter = IpsFormatter() + + /** + * Parse and process a FHIR Bundle as an IPS document + * + * @param bundle FHIR Bundle containing IPS data + * @return Processed IPS document ready for display + * @throws IllegalArgumentException if bundle is not a valid IPS document + */ + fun processIpsBundle(bundle: Bundle): ProcessedIpsDocument { + val ipsDocument = parser.parseIpsBundle(bundle) + return processor.processIpsDocument(ipsDocument) + } + + /** + * Create a comprehensive text representation of an IPS document + * + * @param bundle FHIR Bundle containing IPS data + * @return Formatted text suitable for display + */ + fun formatIpsAsText(bundle: Bundle): String { + val processedIps = processIpsBundle(bundle) + return formatter.formatIpsDocument(processedIps) + } + + /** + * Create a brief summary of an IPS document + * + * @param bundle FHIR Bundle containing IPS data + * @return Brief formatted text summary + */ + fun formatIpsBrief(bundle: Bundle): String { + val processedIps = processIpsBundle(bundle) + return formatter.formatBriefSummary(processedIps) + } + + /** + * Extract just the patient information from an IPS bundle + * + * @param bundle FHIR Bundle containing IPS data + * @return Processed patient information + */ + fun extractPatientInfo(bundle: Bundle): ProcessedPatientInfo { + val processedIps = processIpsBundle(bundle) + return processedIps.patient + } + + /** + * Check if a FHIR Bundle contains the minimum required resources for an IPS + * + * @param bundle FHIR Bundle to validate + * @return true if bundle appears to be a valid IPS document + */ + fun isValidIpsBundle(bundle: Bundle): Boolean { + return try { + val entries = bundle.entry ?: return false + + // Must have a Patient resource + val hasPatient = entries.any { it.resource is org.hl7.fhir.r4.model.Patient } + + // Should have a Composition resource for a complete IPS + val hasComposition = entries.any { it.resource is org.hl7.fhir.r4.model.Composition } + + // Basic validation - must have patient, composition is recommended but not required + hasPatient + } catch (e: Exception) { + false + } + } + + /** + * Get clinical alerts from an IPS document + * + * @param bundle FHIR Bundle containing IPS data + * @return List of clinical alerts + */ + fun getClinicalAlerts(bundle: Bundle): List { + val processedIps = processIpsBundle(bundle) + return processedIps.alerts + } + + /** + * Get document metadata from an IPS bundle + * + * @param bundle FHIR Bundle containing IPS data + * @return IPS metadata + */ + fun getIpsMetadata(bundle: Bundle): IpsMetadata { + val processedIps = processIpsBundle(bundle) + return processedIps.metadata + } +} + +/** + * Result of IPS processing operations + */ +sealed class IpsResult { + data class Success(val data: T) : IpsResult() + data class Error(val message: String, val cause: Throwable? = null) : IpsResult() +} + +/** + * Safe version of IpsViewer that returns results instead of throwing exceptions + */ +class SafeIpsViewer { + + private val ipsViewer = IpsViewer() + + /** + * Safely process an IPS bundle, returning a result instead of throwing exceptions + */ + fun processIpsBundle(bundle: Bundle): IpsResult { + return try { + val result = ipsViewer.processIpsBundle(bundle) + IpsResult.Success(result) + } catch (e: Exception) { + IpsResult.Error("Failed to process IPS bundle: ${e.message}", e) + } + } + + /** + * Safely format an IPS bundle as text + */ + fun formatIpsAsText(bundle: Bundle): IpsResult { + return try { + val result = ipsViewer.formatIpsAsText(bundle) + IpsResult.Success(result) + } catch (e: Exception) { + IpsResult.Error("Failed to format IPS bundle: ${e.message}", e) + } + } + + /** + * Safely check if a bundle is a valid IPS + */ + fun isValidIpsBundle(bundle: Bundle): Boolean { + return ipsViewer.isValidIpsBundle(bundle) + } +} \ No newline at end of file diff --git a/ips-viewer/src/test/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewerTest.kt b/ips-viewer/src/test/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewerTest.kt new file mode 100644 index 00000000..53f614f0 --- /dev/null +++ b/ips-viewer/src/test/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewerTest.kt @@ -0,0 +1,138 @@ +package org.who.gdhcnvalidator.ipsviewer + +import org.hl7.fhir.r4.model.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* +import java.time.LocalDate + +/** + * Tests for the IPS Viewer library + */ +class IpsViewerTest { + + @Test + fun `test IPS viewer creation and basic functionality`() { + val ipsViewer = IpsViewer() + assertNotNull(ipsViewer) + } + + @Test + fun `test basic patient parsing`() { + val bundle = createTestIpsBundle() + val ipsViewer = IpsViewer() + + assertTrue(ipsViewer.isValidIpsBundle(bundle)) + + val processedIps = ipsViewer.processIpsBundle(bundle) + + assertEquals("John Doe", processedIps.patient.displayName) + assertEquals("male", processedIps.patient.gender) + assertNotNull(processedIps.patient.birthDate) + + val formattedText = ipsViewer.formatIpsAsText(bundle) + assertTrue(formattedText.contains("John Doe")) + assertTrue(formattedText.contains("Patient Information")) + } + + @Test + fun `test clinical alerts detection`() { + val bundle = createTestIpsBundleWithAllergies() + val ipsViewer = IpsViewer() + + val alerts = ipsViewer.getClinicalAlerts(bundle) + + assertTrue(alerts.isNotEmpty()) + assertTrue(alerts.any { it.level == AlertLevel.HIGH }) + assertTrue(alerts.any { it.title.contains("Critical Allergies") }) + } + + @Test + fun `test metadata extraction`() { + val bundle = createTestIpsBundle() + val ipsViewer = IpsViewer() + + val metadata = ipsViewer.getIpsMetadata(bundle) + + assertEquals("International Patient Summary", metadata.documentType) + assertEquals("R4", metadata.fhirVersion) + assertTrue(metadata.totalResources > 0) + } + + @Test + fun `test safe IPS viewer`() { + val safeViewer = SafeIpsViewer() + val bundle = createTestIpsBundle() + + val result = safeViewer.processIpsBundle(bundle) + assertTrue(result is IpsResult.Success) + + when (result) { + is IpsResult.Success -> { + assertEquals("John Doe", result.data.patient.displayName) + } + is IpsResult.Error -> fail("Expected success, got error: ${result.message}") + } + } + + @Test + fun `test invalid bundle handling`() { + val invalidBundle = Bundle() // Empty bundle + val safeViewer = SafeIpsViewer() + + assertFalse(safeViewer.isValidIpsBundle(invalidBundle)) + + val result = safeViewer.processIpsBundle(invalidBundle) + assertTrue(result is IpsResult.Error) + } + + private fun createTestIpsBundle(): Bundle { + val bundle = Bundle() + bundle.type = Bundle.BundleType.DOCUMENT + + // Add patient + val patient = Patient() + patient.id = "patient-1" + patient.addName().addGiven("John").family = "Doe" + patient.gender = Enumerations.AdministrativeGender.MALE + patient.birthDate = java.util.Date(90, 0, 1) // Jan 1, 1990 + + val patientEntry = Bundle.BundleEntryComponent() + patientEntry.resource = patient + bundle.addEntry(patientEntry) + + // Add composition + val composition = Composition() + composition.id = "composition-1" + composition.status = Composition.CompositionStatus.FINAL + composition.type = CodeableConcept() + composition.type.addCoding().code = "60591-5" + composition.title = "International Patient Summary" + composition.subject = Reference("Patient/patient-1") + + val compositionEntry = Bundle.BundleEntryComponent() + compositionEntry.resource = composition + bundle.addEntry(compositionEntry) + + return bundle + } + + private fun createTestIpsBundleWithAllergies(): Bundle { + val bundle = createTestIpsBundle() + + // Add critical allergy + val allergy = AllergyIntolerance() + allergy.id = "allergy-1" + allergy.clinicalStatus = CodeableConcept() + allergy.clinicalStatus.addCoding().code = "active" + allergy.criticality = AllergyIntolerance.AllergyIntoleranceCriticality.HIGH + allergy.code = CodeableConcept() + allergy.code.text = "Penicillin" + allergy.patient = Reference("Patient/patient-1") + + val allergyEntry = Bundle.BundleEntryComponent() + allergyEntry.resource = allergy + bundle.addEntry(allergyEntry) + + return bundle + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index eeb23bbc..8ed11800 100644 --- a/settings.gradle +++ b/settings.gradle @@ -16,3 +16,4 @@ include ':trust-pathcheck' include ':trust-didweb' include ':test-resources' include ':web' +include ':ips-viewer' From 7bc3e96c268087acfc8a462c8c9b1c6ce46e29cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 Aug 2025 11:30:15 +0000 Subject: [PATCH 13/13] Revert IPS viewer development work as requested Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- app/build.gradle | 2 - .../gdhcnvalidator/views/ResultFragment.kt | 348 +----------------- ips-viewer/README.md | 244 ------------ ips-viewer/build.gradle | 24 -- .../gdhcnvalidator/ipsviewer/IpsDataModel.kt | 204 ---------- .../gdhcnvalidator/ipsviewer/IpsFormatter.kt | 214 ----------- .../who/gdhcnvalidator/ipsviewer/IpsParser.kt | 300 --------------- .../gdhcnvalidator/ipsviewer/IpsProcessor.kt | 272 -------------- .../who/gdhcnvalidator/ipsviewer/IpsViewer.kt | 151 -------- .../gdhcnvalidator/ipsviewer/IpsViewerTest.kt | 138 ------- settings.gradle | 1 - 11 files changed, 5 insertions(+), 1893 deletions(-) delete mode 100644 ips-viewer/README.md delete mode 100644 ips-viewer/build.gradle delete mode 100644 ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsDataModel.kt delete mode 100644 ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsFormatter.kt delete mode 100644 ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsParser.kt delete mode 100644 ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsProcessor.kt delete mode 100644 ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewer.kt delete mode 100644 ips-viewer/src/test/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewerTest.kt diff --git a/app/build.gradle b/app/build.gradle index b9d5afad..8accc4d8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -117,8 +117,6 @@ configurations.configureEach { dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2' - implementation project(':ips-viewer') - implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'com.google.android.material:material:1.12.0' diff --git a/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt b/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt index fc4dcee6..d73fb1ef 100644 --- a/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt +++ b/app/src/main/java/org/who/gdhcnvalidator/views/ResultFragment.kt @@ -21,8 +21,6 @@ import org.who.gdhcnvalidator.FhirApplication import org.who.gdhcnvalidator.QRDecoder import org.who.gdhcnvalidator.R import org.who.gdhcnvalidator.databinding.FragmentResultBinding -import org.who.gdhcnvalidator.ipsviewer.IpsViewer -import org.who.gdhcnvalidator.ipsviewer.AlertLevel import org.who.gdhcnvalidator.services.DDCCFormatter import org.who.gdhcnvalidator.trust.TrustRegistry import org.who.gdhcnvalidator.verify.hcert.healthlink.VhlVerifier @@ -466,352 +464,16 @@ class ResultFragment : Fragment() { } /** - * Shows FHIR IPS content in a dialog with structured information using the IPS library + * Shows FHIR IPS content in a dialog */ private fun showFhirIpsDialog(file: VhlVerifier.VhlFileInfo) { - val ipsBundle = file.content as? org.hl7.fhir.r4.model.Bundle - - if (ipsBundle == null) { - AlertDialog.Builder(requireContext()) - .setTitle(file.title) - .setMessage("FHIR IPS Document\n\nContent is not available for display.") - .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } - .show() - return - } - - try { - val ipsViewer = IpsViewer() - - // Check if it's a valid IPS bundle - if (!ipsViewer.isValidIpsBundle(ipsBundle)) { - showBasicBundleInfo(file, ipsBundle) - return - } - - // Process the IPS document - val processedIps = ipsViewer.processIpsBundle(ipsBundle) - - // Create a custom dialog with structured content - showStructuredIpsDialog(file, processedIps) - - } catch (e: Exception) { - // Fallback to basic display if IPS processing fails - showBasicBundleInfo(file, ipsBundle) - } - } - - /** - * Shows a structured IPS dialog with rich content - */ - private fun showStructuredIpsDialog(file: VhlVerifier.VhlFileInfo, processedIps: org.who.gdhcnvalidator.ipsviewer.ProcessedIpsDocument) { - val context = requireContext() - - // Create a scrollable view for the content - val scrollView = ScrollView(context) - val container = LinearLayout(context) - container.orientation = LinearLayout.VERTICAL - container.setPadding(24, 16, 24, 16) - - // Patient header - addPatientHeader(container, processedIps.patient) - - // Clinical alerts - if (processedIps.alerts.isNotEmpty()) { - addClinicalAlerts(container, processedIps.alerts) - } - - // Document sections - addDocumentSections(container, processedIps.sections) - - // Document summary - addDocumentSummary(container, processedIps.summary, processedIps.metadata) - - scrollView.addView(container) - - AlertDialog.Builder(context) - .setTitle(file.title) - .setView(scrollView) - .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } - .setNeutralButton("Details") { _, _ -> - // Show detailed text view - showDetailedIpsText(file, processedIps) - } - .show() - } - - /** - * Shows detailed text representation of the IPS - */ - private fun showDetailedIpsText(file: VhlVerifier.VhlFileInfo, processedIps: org.who.gdhcnvalidator.ipsviewer.ProcessedIpsDocument) { - val ipsViewer = IpsViewer() - val ipsBundle = file.content as org.hl7.fhir.r4.model.Bundle - val detailedText = ipsViewer.formatIpsAsText(ipsBundle) - - val scrollView = ScrollView(requireContext()) - val textView = TextView(requireContext()) - textView.text = detailedText - textView.setTextIsSelectable(true) - textView.typeface = android.graphics.Typeface.MONOSPACE - textView.textSize = 12f - textView.setPadding(16, 16, 16, 16) - scrollView.addView(textView) - - AlertDialog.Builder(requireContext()) - .setTitle("${file.title} - Details") - .setView(scrollView) - .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } - .show() - } - - private fun addPatientHeader(container: LinearLayout, patient: org.who.gdhcnvalidator.ipsviewer.ProcessedPatientInfo) { - val context = container.context - - // Patient name - val nameView = TextView(context) - nameView.text = patient.displayName - nameView.textSize = 18f - nameView.setTypeface(null, android.graphics.Typeface.BOLD) - nameView.setTextColor(context.getColor(android.R.color.black)) - container.addView(nameView) - - // Patient details - val detailsLayout = LinearLayout(context) - detailsLayout.orientation = LinearLayout.HORIZONTAL - - val details = mutableListOf() - if (patient.ageInfo != null) details.add(patient.ageInfo) - if (patient.gender != null) details.add(patient.gender) - - if (details.isNotEmpty()) { - val detailsView = TextView(context) - detailsView.text = details.joinToString(" • ") - detailsView.textSize = 14f - detailsView.setTextColor(context.getColor(android.R.color.darker_gray)) - container.addView(detailsView) - } - - // Identifier - if (patient.primaryIdentifier != null) { - val idView = TextView(context) - val idType = patient.primaryIdentifier.type ?: "ID" - idView.text = "$idType: ${patient.primaryIdentifier.value}" - idView.textSize = 12f - idView.setTextColor(context.getColor(android.R.color.darker_gray)) - container.addView(idView) - } - - // Add separator - addSeparator(container) - } - - private fun addClinicalAlerts(container: LinearLayout, alerts: List) { - val context = container.context - - val alertHeader = TextView(context) - alertHeader.text = "Clinical Alerts" - alertHeader.textSize = 16f - alertHeader.setTypeface(null, android.graphics.Typeface.BOLD) - container.addView(alertHeader) - - alerts.forEach { alert -> - val alertLayout = LinearLayout(context) - alertLayout.orientation = LinearLayout.HORIZONTAL - alertLayout.setPadding(0, 8, 0, 8) - - // Alert icon - val iconView = TextView(context) - iconView.text = when (alert.level) { - AlertLevel.HIGH -> "🚨" - AlertLevel.MEDIUM -> "⚠️" - AlertLevel.LOW -> "ℹ️" - } - iconView.textSize = 16f - iconView.setPadding(0, 0, 8, 0) - alertLayout.addView(iconView) - - // Alert content - val alertContent = LinearLayout(context) - alertContent.orientation = LinearLayout.VERTICAL - alertContent.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - - val titleView = TextView(context) - titleView.text = alert.title - titleView.textSize = 14f - titleView.setTypeface(null, android.graphics.Typeface.BOLD) - alertContent.addView(titleView) - - val messageView = TextView(context) - messageView.text = alert.message - messageView.textSize = 12f - alertContent.addView(messageView) - - alertLayout.addView(alertContent) - container.addView(alertLayout) - } - - addSeparator(container) - } - - private fun addDocumentSections(container: LinearLayout, sections: List) { - val context = container.context - - if (sections.isNotEmpty()) { - val sectionHeader = TextView(context) - sectionHeader.text = "Document Contents" - sectionHeader.textSize = 16f - sectionHeader.setTypeface(null, android.graphics.Typeface.BOLD) - container.addView(sectionHeader) - - sections.forEach { section -> - val sectionLayout = LinearLayout(context) - sectionLayout.orientation = LinearLayout.HORIZONTAL - sectionLayout.setPadding(0, 4, 0, 4) - - val bullet = TextView(context) - bullet.text = "•" - bullet.textSize = 14f - bullet.setPadding(0, 0, 8, 0) - sectionLayout.addView(bullet) - - val sectionText = TextView(context) - sectionText.text = "${section.title}: ${section.text}" - sectionText.textSize = 14f - sectionText.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - sectionLayout.addView(sectionText) - - container.addView(sectionLayout) - } - - addSeparator(container) - } - } - - private fun addDocumentSummary( - container: LinearLayout, - summary: org.who.gdhcnvalidator.ipsviewer.DocumentSummary, - metadata: org.who.gdhcnvalidator.ipsviewer.IpsMetadata - ) { - val context = container.context - - val summaryHeader = TextView(context) - summaryHeader.text = "Document Information" - summaryHeader.textSize = 16f - summaryHeader.setTypeface(null, android.graphics.Typeface.BOLD) - container.addView(summaryHeader) - - if (summary.totalClinicalItems > 0) { - addInfoItem(container, "Clinical Items", summary.totalClinicalItems.toString()) - } - - if (summary.lastUpdated != null) { - addInfoItem(container, "Last Updated", summary.lastUpdated) - } - - if (summary.author != null) { - addInfoItem(container, "Author", summary.author) - } - - addInfoItem(container, "Document Type", metadata.documentType) - addInfoItem(container, "FHIR Version", metadata.fhirVersion) - addInfoItem(container, "Total Resources", metadata.totalResources.toString()) - } - - private fun addInfoItem(container: LinearLayout, label: String, value: String) { - val context = container.context - val layout = LinearLayout(context) - layout.orientation = LinearLayout.HORIZONTAL - layout.setPadding(0, 2, 0, 2) - - val labelView = TextView(context) - labelView.text = "$label:" - labelView.textSize = 12f - labelView.setTypeface(null, android.graphics.Typeface.BOLD) - labelView.setPadding(0, 0, 8, 0) - layout.addView(labelView) - - val valueView = TextView(context) - valueView.text = value - valueView.textSize = 12f - valueView.layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - layout.addView(valueView) - - container.addView(layout) - } - - private fun addSeparator(container: LinearLayout) { - val context = container.context - val separator = View(context) - separator.setBackgroundColor(context.getColor(android.R.color.darker_gray)) - val params = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1) - params.setMargins(0, 16, 0, 16) - separator.layoutParams = params - container.addView(separator) - } - - /** - * Fallback method for non-IPS bundles or when IPS processing fails - */ - private fun showBasicBundleInfo(file: VhlVerifier.VhlFileInfo, bundle: org.hl7.fhir.r4.model.Bundle) { - val message = buildBasicBundleText(bundle) - AlertDialog.Builder(requireContext()) .setTitle(file.title) - .setMessage(message) - .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } - .show() - } - - /** - * Builds basic text representation for non-IPS FHIR bundles - */ - private fun buildBasicBundleText(bundle: org.hl7.fhir.r4.model.Bundle): String { - val builder = StringBuilder() - builder.append("FHIR Bundle Document\n\n") - - // Extract patient information if available - val patient = bundle.entry?.find { - it.resource is org.hl7.fhir.r4.model.Patient - }?.resource as? org.hl7.fhir.r4.model.Patient - - if (patient != null) { - builder.append("Patient Information:\n") - patient.name?.firstOrNull()?.let { name -> - val fullName = listOfNotNull( - name.given?.joinToString(" "), - name.family - ).joinToString(" ") - if (fullName.isNotEmpty()) { - builder.append("• Name: $fullName\n") - } - } - - patient.birthDate?.let { birthDate -> - builder.append("• Birth Date: ${birthDate}\n") - } - - patient.gender?.let { gender -> - builder.append("• Gender: ${gender.display ?: gender.name}\n") + .setMessage("FHIR IPS Document\n\nThis contains structured health information that would be processed and displayed in a production implementation.") + .setPositiveButton("OK") { dialog, _ -> + dialog.dismiss() } - - builder.append("\n") - } - - // Count resources - val resourceCounts = bundle.entry?.groupBy { - it.resource?.fhirType() - }?.mapValues { it.value.size } - - if (resourceCounts != null && resourceCounts.isNotEmpty()) { - builder.append("Bundle Contents:\n") - resourceCounts.forEach { (type, count) -> - builder.append("• $type: $count entries\n") - } - } - - builder.append("\nThis document contains structured health information in FHIR format.") - - return builder.toString() + .show() } /** diff --git a/ips-viewer/README.md b/ips-viewer/README.md deleted file mode 100644 index cde0d1a6..00000000 --- a/ips-viewer/README.md +++ /dev/null @@ -1,244 +0,0 @@ -# IPS Viewer - Kotlin Library for FHIR International Patient Summary - -A comprehensive Kotlin library for parsing, processing, and displaying FHIR International Patient Summary (IPS) documents. This library provides structured data extraction, clinical alert detection, and formatted display capabilities for IPS bundles. - -## Features - -### 🔍 **FHIR IPS Parsing** -- Extracts structured data from FHIR R4 Bundle resources -- Parses patient demographics, medications, allergies, conditions, immunizations, and more -- Handles multiple date formats and resource relationships -- Validates IPS bundle structure - -### 🧠 **Intelligent Processing** -- Applies clinical business rules for data organization -- Generates clinical alerts for critical information (severe allergies, conditions) -- Prioritizes active vs inactive medical information -- Creates structured sections for easy consumption - -### 📊 **Rich Data Models** -- Comprehensive data classes for all IPS components -- Type-safe representations of clinical concepts -- Structured patient information with demographics and identifiers -- Clinical alerts with severity levels (HIGH, MEDIUM, LOW) - -### 📝 **Flexible Display** -- Multiple output formats: structured data, formatted text, brief summaries -- Customizable formatting for different display contexts -- Human-readable text generation with proper medical terminology -- Platform-agnostic output suitable for Android, web, or console display - -## Architecture - -The library follows a clean, modular architecture: - -``` -IpsViewer (Facade) -├── IpsParser # Raw FHIR → Structured Data -├── IpsProcessor # Business Logic & Organization -└── IpsFormatter # Display & Text Generation -``` - -### Core Components - -- **`IpsParser`**: Converts FHIR Bundle resources into structured Kotlin data classes -- **`IpsProcessor`**: Applies clinical business rules and generates alerts -- **`IpsFormatter`**: Creates human-readable text representations -- **`IpsViewer`**: Main facade providing simple API access -- **`SafeIpsViewer`**: Error-safe version returning Result types - -## Usage Examples - -### Basic IPS Processing - -```kotlin -import org.who.gdhcnvalidator.ipsviewer.IpsViewer -import org.hl7.fhir.r4.model.Bundle - -val ipsViewer = IpsViewer() -val ipsBundle: Bundle = // ... your FHIR Bundle - -// Validate IPS bundle -if (ipsViewer.isValidIpsBundle(ipsBundle)) { - // Process the IPS document - val processedIps = ipsViewer.processIpsBundle(ipsBundle) - - // Access structured data - println("Patient: ${processedIps.patient.displayName}") - println("Age: ${processedIps.patient.ageInfo}") - - // Check for clinical alerts - processedIps.alerts.forEach { alert -> - println("${alert.level}: ${alert.title} - ${alert.message}") - } -} -``` - -### Text Formatting - -```kotlin -// Generate comprehensive text summary -val fullText = ipsViewer.formatIpsAsText(ipsBundle) -println(fullText) - -// Generate brief summary -val briefSummary = ipsViewer.formatIpsBrief(ipsBundle) -println(briefSummary) -``` - -### Safe Error Handling - -```kotlin -import org.who.gdhcnvalidator.ipsviewer.SafeIpsViewer -import org.who.gdhcnvalidator.ipsviewer.IpsResult - -val safeViewer = SafeIpsViewer() - -when (val result = safeViewer.processIpsBundle(ipsBundle)) { - is IpsResult.Success -> { - val processedIps = result.data - // Handle successful processing - } - is IpsResult.Error -> { - println("Error: ${result.message}") - // Handle error case - } -} -``` - -### Clinical Information Access - -```kotlin -// Extract specific clinical data -val patientInfo = ipsViewer.extractPatientInfo(ipsBundle) -val alerts = ipsViewer.getClinicalAlerts(ipsBundle) -val metadata = ipsViewer.getIpsMetadata(ipsBundle) - -// Access detailed clinical data from processed IPS -val processedIps = ipsViewer.processIpsBundle(ipsBundle) - -processedIps.sections.forEach { section -> - println("${section.title}: ${section.text}") -} -``` - -## Data Models - -### Key Data Classes - -- **`ProcessedIpsDocument`**: Complete processed IPS with all components -- **`ProcessedPatientInfo`**: Patient demographics optimized for display -- **`ClinicalAlert`**: Important clinical information with severity levels -- **`IpsSection`**: Organized clinical content sections -- **`IpsMetadata`**: Document metadata and resource counts - -### Supported FHIR Resources - -- **Patient**: Demographics, identifiers, contact information -- **Composition**: Document structure and metadata -- **MedicationStatement/Medication**: Current and past medications -- **AllergyIntolerance**: Allergies and intolerances with reactions -- **Condition**: Medical conditions and problems -- **Immunization**: Vaccination records -- **Procedure**: Medical procedures -- **Observation**: Clinical observations and vital signs -- **DiagnosticReport**: Laboratory results and reports - -## Clinical Alerts - -The library automatically generates clinical alerts for: - -- **HIGH Priority**: Critical allergies with high criticality -- **MEDIUM Priority**: Severe active medical conditions -- **LOW Priority**: Incomplete vaccination series, informational items - -## Integration - -### Android Integration - -```kotlin -// In your Android fragment/activity -private fun displayIpsContent(ipsBundle: Bundle) { - val ipsViewer = IpsViewer() - - try { - val processedIps = ipsViewer.processIpsBundle(ipsBundle) - - // Create Android UI components - createPatientHeader(processedIps.patient) - createClinicalAlerts(processedIps.alerts) - createContentSections(processedIps.sections) - - } catch (e: Exception) { - // Fallback to basic bundle display - showBasicBundleInfo(ipsBundle) - } -} -``` - -### Web Integration - -The library's data models and text output can be easily adapted for web display: - -```kotlin -// Generate JSON for web frontend -val processedIps = ipsViewer.processIpsBundle(ipsBundle) -val jsonData = gson.toJson(processedIps) - -// Or use formatted text -val htmlContent = ipsViewer.formatIpsAsText(ipsBundle) - .replace("\n", "
") - .replace("•", "•") -``` - -## Dependencies - -- **FHIR R4**: `ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.6.0` -- **Kotlin Standard Library**: For data classes and processing -- **Java Time API**: For date handling (desugaring supported) - -## Testing - -The library includes comprehensive tests covering: - -- IPS bundle parsing and validation -- Clinical alert generation -- Text formatting accuracy -- Error handling scenarios -- Edge cases and malformed data - -Run tests with: -```bash -./gradlew :ips-viewer:test -``` - -## Future Enhancements - -### Potential IPSViewer.com Integration - -This Kotlin library is designed to potentially replace or complement the TypeScript business logic in the [IPSViewer](https://github.com/jddamore/IPSviewer) project: - -1. **Kotlin/JS Compilation**: The library could be compiled to JavaScript for direct web use -2. **Shared Data Models**: Common data structures between Android and web versions -3. **Consistent Business Logic**: Same IPS interpretation rules across platforms -4. **API Compatibility**: RESTful endpoints using this library for processing - -### Planned Features - -- **Template-based Output**: Customizable display templates -- **Localization Support**: Multi-language clinical terminology -- **Enhanced Validation**: Deeper IPS conformance checking -- **Performance Optimization**: Large bundle handling improvements - -## Contributing - -The library follows standard Kotlin coding conventions and includes: - -- Comprehensive documentation -- Unit tests for all public APIs -- Error handling best practices -- Clean architecture principles - -## License - -This library is part of the WHO Global Digital Health Certification Network (GDHCN) Validator project and follows the same licensing terms. \ No newline at end of file diff --git a/ips-viewer/build.gradle b/ips-viewer/build.gradle deleted file mode 100644 index d771dca5..00000000 --- a/ips-viewer/build.gradle +++ /dev/null @@ -1,24 +0,0 @@ -plugins { - id 'org.jetbrains.kotlin.jvm' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib" - - // FHIR R4 dependency (same as other modules) - implementation 'ca.uhn.hapi.fhir:hapi-fhir-base:6.6.0' - implementation 'ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.6.0' - - // Testing - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0' - testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.0' - testImplementation "org.jetbrains.kotlin:kotlin-test-junit5" -} - -test { - useJUnitPlatform() -} - -kotlin { - jvmToolchain(11) -} \ No newline at end of file diff --git a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsDataModel.kt b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsDataModel.kt deleted file mode 100644 index d777fc40..00000000 --- a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsDataModel.kt +++ /dev/null @@ -1,204 +0,0 @@ -package org.who.gdhcnvalidator.ipsviewer - -import java.time.LocalDate - -/** - * Structured data models for FHIR International Patient Summary content - */ - -/** - * Complete IPS document representation - */ -data class IpsDocument( - val patient: IpsPatient, - val composition: IpsComposition?, - val medications: List = emptyList(), - val allergies: List = emptyList(), - val conditions: List = emptyList(), - val immunizations: List = emptyList(), - val procedures: List = emptyList(), - val observations: List = emptyList(), - val diagnosticReports: List = emptyList(), - val otherResources: Map = emptyMap() -) - -/** - * Patient demographic information - */ -data class IpsPatient( - val name: String?, - val givenNames: List = emptyList(), - val familyName: String?, - val birthDate: LocalDate?, - val gender: String?, - val identifiers: List = emptyList(), - val telecom: List = emptyList(), - val addresses: List = emptyList() -) - -/** - * Document composition metadata - */ -data class IpsComposition( - val title: String?, - val date: String?, - val author: String?, - val sections: List = emptyList() -) - -/** - * IPS document section - */ -data class IpsSection( - val title: String?, - val code: String?, - val text: String?, - val resourceCount: Int = 0 -) - -/** - * Medication information - */ -data class IpsMedication( - val name: String?, - val status: String?, - val dosage: String?, - val route: String?, - val frequency: String?, - val effectiveDate: String?, - val note: String? -) - -/** - * Allergy/Intolerance information - */ -data class IpsAllergy( - val substance: String?, - val category: String?, - val criticality: String?, - val type: String?, - val clinicalStatus: String?, - val verificationStatus: String?, - val reactions: List = emptyList(), - val onset: String?, - val note: String? -) - -/** - * Allergic reaction details - */ -data class IpsReaction( - val manifestation: String?, - val severity: String?, - val note: String? -) - -/** - * Medical condition/problem - */ -data class IpsCondition( - val name: String?, - val category: String?, - val severity: String?, - val clinicalStatus: String?, - val verificationStatus: String?, - val onsetDate: String?, - val abatementDate: String?, - val note: String? -) - -/** - * Immunization record - */ -data class IpsImmunization( - val vaccineCode: String?, - val vaccineName: String?, - val status: String?, - val occurrenceDate: String?, - val doseNumber: Int?, - val seriesDoses: Int?, - val lotNumber: String?, - val manufacturer: String?, - val site: String?, - val route: String?, - val performer: String?, - val note: String? -) - -/** - * Medical procedure - */ -data class IpsProcedure( - val name: String?, - val status: String?, - val category: String?, - val performedDate: String?, - val performer: String?, - val bodySite: String?, - val outcome: String?, - val note: String? -) - -/** - * Clinical observation - */ -data class IpsObservation( - val name: String?, - val category: String?, - val status: String?, - val value: String?, - val unit: String?, - val interpretation: String?, - val effectiveDate: String?, - val note: String?, - val referenceRange: String? -) - -/** - * Diagnostic report - */ -data class IpsDiagnosticReport( - val name: String?, - val category: String?, - val status: String?, - val effectiveDate: String?, - val issued: String?, - val performer: String?, - val conclusion: String?, - val presentedForm: String? -) - -/** - * Patient identifier - */ -data class IpsIdentifier( - val system: String?, - val value: String?, - val type: String?, - val use: String? -) - -/** - * Contact point (phone, email, etc.) - */ -data class IpsContactPoint( - val system: String?, - val value: String?, - val use: String?, - val rank: Int? -) - -/** - * Address information - */ -data class IpsAddress( - val use: String?, - val type: String?, - val text: String?, - val line: List = emptyList(), - val city: String?, - val district: String?, - val state: String?, - val postalCode: String?, - val country: String? -) \ No newline at end of file diff --git a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsFormatter.kt b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsFormatter.kt deleted file mode 100644 index f104e7c4..00000000 --- a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsFormatter.kt +++ /dev/null @@ -1,214 +0,0 @@ -package org.who.gdhcnvalidator.ipsviewer - -/** - * Formatter for creating human-readable text representations of IPS data - */ -class IpsFormatter { - - /** - * Creates a comprehensive text summary of the IPS document - */ - fun formatIpsDocument(processedIps: ProcessedIpsDocument): String { - val builder = StringBuilder() - - // Document header - builder.append("${processedIps.summary.documentTitle}\n") - builder.append("=" * processedIps.summary.documentTitle.length).append("\n\n") - - // Patient information - builder.append(formatPatientInfo(processedIps.patient)) - builder.append("\n") - - // Clinical alerts - if (processedIps.alerts.isNotEmpty()) { - builder.append(formatAlerts(processedIps.alerts)) - builder.append("\n") - } - - // Document summary - builder.append(formatDocumentSummary(processedIps.summary)) - builder.append("\n") - - // Content sections - if (processedIps.sections.isNotEmpty()) { - builder.append("Document Contents:\n") - processedIps.sections.forEach { section -> - builder.append("• ${section.title}: ${section.text}\n") - } - builder.append("\n") - } - - // Document metadata - builder.append(formatMetadata(processedIps.metadata)) - - return builder.toString() - } - - /** - * Creates a brief summary of the IPS document - */ - fun formatBriefSummary(processedIps: ProcessedIpsDocument): String { - val builder = StringBuilder() - - builder.append("Patient: ${processedIps.patient.displayName}\n") - - if (processedIps.patient.ageInfo != null) { - builder.append("${processedIps.patient.ageInfo}") - if (processedIps.patient.gender != null) { - builder.append(", ${processedIps.patient.gender}") - } - builder.append("\n") - } else if (processedIps.patient.gender != null) { - builder.append("Gender: ${processedIps.patient.gender}\n") - } - - if (processedIps.summary.totalClinicalItems > 0) { - builder.append("Clinical Items: ${processedIps.summary.totalClinicalItems}\n") - } - - if (processedIps.alerts.isNotEmpty()) { - val highAlerts = processedIps.alerts.count { it.level == AlertLevel.HIGH } - if (highAlerts > 0) { - builder.append("⚠️ $highAlerts critical alerts\n") - } - } - - return builder.toString() - } - - private fun formatPatientInfo(patient: ProcessedPatientInfo): String { - val builder = StringBuilder() - builder.append("Patient Information:\n") - - builder.append("• Name: ${patient.displayName}\n") - - if (patient.birthDate != null) { - builder.append("• Birth Date: ${patient.birthDate}") - if (patient.ageInfo != null) { - builder.append(" (${patient.ageInfo})") - } - builder.append("\n") - } - - if (patient.gender != null) { - builder.append("• Gender: ${patient.gender}\n") - } - - if (patient.primaryIdentifier != null) { - val id = patient.primaryIdentifier - val idType = id.type ?: "ID" - builder.append("• $idType: ${id.value}\n") - } - - if (patient.contactInfo.isNotEmpty()) { - patient.contactInfo.forEach { contact -> - val system = contact.system?.replaceFirstChar { it.uppercaseChar() } ?: "Contact" - builder.append("• $system: ${contact.value}\n") - } - } - - if (patient.address != null) { - val addr = patient.address - val addressText = addr.text ?: buildAddressText(addr) - if (addressText.isNotEmpty()) { - builder.append("• Address: $addressText\n") - } - } - - return builder.toString() - } - - private fun formatAlerts(alerts: List): String { - val builder = StringBuilder() - builder.append("Clinical Alerts:\n") - - // Sort by severity: HIGH -> MEDIUM -> LOW - val sortedAlerts = alerts.sortedBy { - when (it.level) { - AlertLevel.HIGH -> 0 - AlertLevel.MEDIUM -> 1 - AlertLevel.LOW -> 2 - } - } - - sortedAlerts.forEach { alert -> - val icon = when (alert.level) { - AlertLevel.HIGH -> "🚨" - AlertLevel.MEDIUM -> "⚠️" - AlertLevel.LOW -> "ℹ️" - } - - builder.append("$icon ${alert.title}: ${alert.message}\n") - - if (alert.details.isNotEmpty()) { - alert.details.take(3).forEach { detail -> // Limit to 3 details - builder.append(" - $detail\n") - } - if (alert.details.size > 3) { - builder.append(" - ... and ${alert.details.size - 3} more\n") - } - } - } - - return builder.toString() - } - - private fun formatDocumentSummary(summary: DocumentSummary): String { - val builder = StringBuilder() - builder.append("Summary:\n") - - if (summary.totalClinicalItems > 0) { - builder.append("• Total Clinical Items: ${summary.totalClinicalItems}\n") - } - - if (summary.lastUpdated != null) { - builder.append("• Last Updated: ${summary.lastUpdated}\n") - } - - if (summary.author != null) { - builder.append("• Author: ${summary.author}\n") - } - - return builder.toString() - } - - private fun formatMetadata(metadata: IpsMetadata): String { - val builder = StringBuilder() - builder.append("Document Details:\n") - builder.append("• Type: ${metadata.documentType}\n") - builder.append("• FHIR Version: ${metadata.fhirVersion}\n") - builder.append("• Total Resources: ${metadata.totalResources}\n") - - if (metadata.resourceCounts.isNotEmpty()) { - builder.append("• Resource Breakdown:\n") - metadata.resourceCounts.forEach { (type, count) -> - builder.append(" - $type: $count\n") - } - } - - return builder.toString() - } - - private fun buildAddressText(address: IpsAddress): String { - val parts = mutableListOf() - - if (address.line.isNotEmpty()) { - parts.addAll(address.line) - } - - listOfNotNull(address.city, address.state, address.postalCode, address.country).let { - if (it.isNotEmpty()) { - parts.add(it.joinToString(", ")) - } - } - - return parts.joinToString(", ") - } - - /** - * Helper extension for string repetition - */ - private operator fun String.times(times: Int): String { - return this.repeat(times) - } -} \ No newline at end of file diff --git a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsParser.kt b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsParser.kt deleted file mode 100644 index 39c23638..00000000 --- a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsParser.kt +++ /dev/null @@ -1,300 +0,0 @@ -package org.who.gdhcnvalidator.ipsviewer - -import org.hl7.fhir.r4.model.* -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.time.format.DateTimeParseException - -/** - * Parser for FHIR International Patient Summary bundles - * Extracts structured data from IPS Bundle resources - */ -class IpsParser { - - companion object { - private val DATE_FORMATTERS = listOf( - DateTimeFormatter.ofPattern("yyyy-MM-dd"), - DateTimeFormatter.ofPattern("yyyy-MM"), - DateTimeFormatter.ofPattern("yyyy") - ) - } - - /** - * Parse a FHIR Bundle into structured IPS data - */ - fun parseIpsBundle(bundle: Bundle): IpsDocument { - val entries = bundle.entry ?: emptyList() - - // Extract patient (required for IPS) - val patientResource = entries.find { it.resource is Patient }?.resource as? Patient - val patient = patientResource?.let { parsePatient(it) } - ?: throw IllegalArgumentException("IPS Bundle must contain a Patient resource") - - // Extract composition - val compositionResource = entries.find { it.resource is Composition }?.resource as? Composition - val composition = compositionResource?.let { parseComposition(it) } - - // Extract clinical resources - val medications = entries.filter { it.resource is MedicationStatement || it.resource is Medication } - .mapNotNull { parseMedication(it.resource) } - - val allergies = entries.filter { it.resource is AllergyIntolerance } - .mapNotNull { parseAllergy(it.resource as AllergyIntolerance) } - - val conditions = entries.filter { it.resource is Condition } - .mapNotNull { parseCondition(it.resource as Condition) } - - val immunizations = entries.filter { it.resource is Immunization } - .mapNotNull { parseImmunization(it.resource as Immunization) } - - val procedures = entries.filter { it.resource is Procedure } - .mapNotNull { parseProcedure(it.resource as Procedure) } - - val observations = entries.filter { it.resource is Observation } - .mapNotNull { parseObservation(it.resource as Observation) } - - val diagnosticReports = entries.filter { it.resource is DiagnosticReport } - .mapNotNull { parseDiagnosticReport(it.resource as DiagnosticReport) } - - // Count other resource types - val handledTypes = setOf("Patient", "Composition", "MedicationStatement", "Medication", - "AllergyIntolerance", "Condition", "Immunization", "Procedure", "Observation", "DiagnosticReport") - - val otherResources = entries.groupBy { it.resource?.fhirType() } - .filterKeys { it != null && !handledTypes.contains(it) } - .mapValues { it.value.size } - .mapKeys { it.key!! } - - return IpsDocument( - patient = patient, - composition = composition, - medications = medications, - allergies = allergies, - conditions = conditions, - immunizations = immunizations, - procedures = procedures, - observations = observations, - diagnosticReports = diagnosticReports, - otherResources = otherResources - ) - } - - private fun parsePatient(patient: Patient): IpsPatient { - val name = patient.name?.firstOrNull() - val givenNames = name?.given?.map { it.value } ?: emptyList() - val familyName = name?.family - val fullName = (givenNames + listOfNotNull(familyName)).joinToString(" ").takeIf { it.isNotBlank() } - - val birthDate = patient.birthDate?.let { parseDate(it.toString()) } - val gender = patient.gender?.display ?: patient.gender?.name - - val identifiers = patient.identifier?.map { parseIdentifier(it) } ?: emptyList() - val telecom = patient.telecom?.map { parseContactPoint(it) } ?: emptyList() - val addresses = patient.address?.map { parseAddress(it) } ?: emptyList() - - return IpsPatient( - name = fullName, - givenNames = givenNames, - familyName = familyName, - birthDate = birthDate, - gender = gender, - identifiers = identifiers, - telecom = telecom, - addresses = addresses - ) - } - - private fun parseComposition(composition: Composition): IpsComposition { - val sections = composition.section?.map { section -> - IpsSection( - title = section.title, - code = section.code?.coding?.firstOrNull()?.code, - text = section.text?.div?.toString(), - resourceCount = section.entry?.size ?: 0 - ) - } ?: emptyList() - - return IpsComposition( - title = composition.title, - date = composition.date?.toString(), - author = composition.author?.firstOrNull()?.display, - sections = sections - ) - } - - private fun parseMedication(resource: Resource): IpsMedication? { - return when (resource) { - is MedicationStatement -> { - val medication = resource.medicationCodeableConcept?.text - ?: resource.medicationCodeableConcept?.coding?.firstOrNull()?.display - - IpsMedication( - name = medication, - status = resource.status?.display, - dosage = resource.dosage?.firstOrNull()?.text, - route = resource.dosage?.firstOrNull()?.route?.text, - frequency = resource.dosage?.firstOrNull()?.timing?.code?.text, - effectiveDate = resource.effective?.toString(), - note = resource.note?.firstOrNull()?.text - ) - } - is Medication -> { - IpsMedication( - name = resource.code?.text ?: resource.code?.coding?.firstOrNull()?.display, - status = null, - dosage = null, - route = null, - frequency = null, - effectiveDate = null, - note = null - ) - } - else -> null - } - } - - private fun parseAllergy(allergy: AllergyIntolerance): IpsAllergy { - val reactions = allergy.reaction?.map { reaction -> - IpsReaction( - manifestation = reaction.manifestation?.firstOrNull()?.text, - severity = reaction.severity?.display, - note = reaction.note?.firstOrNull()?.text - ) - } ?: emptyList() - - return IpsAllergy( - substance = allergy.code?.text ?: allergy.code?.coding?.firstOrNull()?.display, - category = allergy.category?.firstOrNull()?.display, - criticality = allergy.criticality?.display, - type = allergy.type?.display, - clinicalStatus = allergy.clinicalStatus?.coding?.firstOrNull()?.display, - verificationStatus = allergy.verificationStatus?.coding?.firstOrNull()?.display, - reactions = reactions, - onset = allergy.onset?.toString(), - note = allergy.note?.firstOrNull()?.text - ) - } - - private fun parseCondition(condition: Condition): IpsCondition { - return IpsCondition( - name = condition.code?.text ?: condition.code?.coding?.firstOrNull()?.display, - category = condition.category?.firstOrNull()?.coding?.firstOrNull()?.display, - severity = condition.severity?.coding?.firstOrNull()?.display, - clinicalStatus = condition.clinicalStatus?.coding?.firstOrNull()?.display, - verificationStatus = condition.verificationStatus?.coding?.firstOrNull()?.display, - onsetDate = condition.onset?.toString(), - abatementDate = condition.abatement?.toString(), - note = condition.note?.firstOrNull()?.text - ) - } - - private fun parseImmunization(immunization: Immunization): IpsImmunization { - return IpsImmunization( - vaccineCode = immunization.vaccineCode?.coding?.firstOrNull()?.code, - vaccineName = immunization.vaccineCode?.text ?: immunization.vaccineCode?.coding?.firstOrNull()?.display, - status = immunization.status?.display, - occurrenceDate = immunization.occurrence?.toString(), - doseNumber = immunization.protocolApplied?.firstOrNull()?.doseNumber?.value?.toIntOrNull(), - seriesDoses = immunization.protocolApplied?.firstOrNull()?.seriesDoses?.value?.toIntOrNull(), - lotNumber = immunization.lotNumber, - manufacturer = immunization.manufacturer?.display, - site = immunization.site?.text, - route = immunization.route?.text, - performer = immunization.performer?.firstOrNull()?.actor?.display, - note = immunization.note?.firstOrNull()?.text - ) - } - - private fun parseProcedure(procedure: Procedure): IpsProcedure { - return IpsProcedure( - name = procedure.code?.text ?: procedure.code?.coding?.firstOrNull()?.display, - status = procedure.status?.display, - category = procedure.category?.text, - performedDate = procedure.performed?.toString(), - performer = procedure.performer?.firstOrNull()?.actor?.display, - bodySite = procedure.bodySite?.firstOrNull()?.text, - outcome = procedure.outcome?.text, - note = procedure.note?.firstOrNull()?.text - ) - } - - private fun parseObservation(observation: Observation): IpsObservation { - val value = when { - observation.hasValueQuantity() -> "${observation.valueQuantity.value} ${observation.valueQuantity.unit ?: observation.valueQuantity.code}" - observation.hasValueString() -> observation.valueStringType.value - observation.hasValueCodeableConcept() -> observation.valueCodeableConcept.text - observation.hasValueBoolean() -> observation.valueBooleanType.value.toString() - observation.hasValueInteger() -> observation.valueIntegerType.value.toString() - observation.hasValueDateTime() -> observation.valueDateTimeType.value.toString() - else -> null - } - - return IpsObservation( - name = observation.code?.text ?: observation.code?.coding?.firstOrNull()?.display, - category = observation.category?.firstOrNull()?.coding?.firstOrNull()?.display, - status = observation.status?.display, - value = value, - unit = observation.valueQuantity?.unit, - interpretation = observation.interpretation?.firstOrNull()?.text, - effectiveDate = observation.effective?.toString(), - note = observation.note?.firstOrNull()?.text, - referenceRange = observation.referenceRange?.firstOrNull()?.text?.div?.toString() - ) - } - - private fun parseDiagnosticReport(report: DiagnosticReport): IpsDiagnosticReport { - return IpsDiagnosticReport( - name = report.code?.text ?: report.code?.coding?.firstOrNull()?.display, - category = report.category?.firstOrNull()?.coding?.firstOrNull()?.display, - status = report.status?.display, - effectiveDate = report.effective?.toString(), - issued = report.issued?.toString(), - performer = report.performer?.firstOrNull()?.display, - conclusion = report.conclusion, - presentedForm = report.presentedForm?.firstOrNull()?.title - ) - } - - private fun parseIdentifier(identifier: Identifier): IpsIdentifier { - return IpsIdentifier( - system = identifier.system, - value = identifier.value, - type = identifier.type?.text, - use = identifier.use?.display - ) - } - - private fun parseContactPoint(contact: ContactPoint): IpsContactPoint { - return IpsContactPoint( - system = contact.system?.display, - value = contact.value, - use = contact.use?.display, - rank = contact.rank - ) - } - - private fun parseAddress(address: Address): IpsAddress { - return IpsAddress( - use = address.use?.display, - type = address.type?.display, - text = address.text, - line = address.line?.map { it.value } ?: emptyList(), - city = address.city, - district = address.district, - state = address.state, - postalCode = address.postalCode, - country = address.country - ) - } - - private fun parseDate(dateString: String): LocalDate? { - for (formatter in DATE_FORMATTERS) { - try { - return LocalDate.parse(dateString, formatter) - } catch (e: DateTimeParseException) { - // Try next formatter - } - } - return null - } -} \ No newline at end of file diff --git a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsProcessor.kt b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsProcessor.kt deleted file mode 100644 index ecad7a71..00000000 --- a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsProcessor.kt +++ /dev/null @@ -1,272 +0,0 @@ -package org.who.gdhcnvalidator.ipsviewer - -/** - * Processor for IPS documents that applies business logic and organizes data - */ -class IpsProcessor { - - /** - * Processes an IPS document and returns organized data with business rules applied - */ - fun processIpsDocument(ipsDocument: IpsDocument): ProcessedIpsDocument { - return ProcessedIpsDocument( - patient = processPatientInfo(ipsDocument.patient), - summary = createDocumentSummary(ipsDocument), - sections = createSections(ipsDocument), - alerts = generateAlerts(ipsDocument), - metadata = extractMetadata(ipsDocument) - ) - } - - private fun processPatientInfo(patient: IpsPatient): ProcessedPatientInfo { - val displayName = patient.name ?: "Unknown Patient" - val ageInfo = patient.birthDate?.let { birthDate -> - val age = java.time.Period.between(birthDate, java.time.LocalDate.now()).years - "Age $age" - } - - val primaryIdentifier = patient.identifiers.find { it.use == "usual" || it.use == "official" } - ?: patient.identifiers.firstOrNull() - - val contactInfo = patient.telecom.filter { it.system in listOf("phone", "email") } - .sortedBy { if (it.system == "phone") 0 else 1 } - - return ProcessedPatientInfo( - displayName = displayName, - birthDate = patient.birthDate?.toString(), - ageInfo = ageInfo, - gender = patient.gender, - primaryIdentifier = primaryIdentifier, - contactInfo = contactInfo.take(2), // Limit to 2 most relevant contacts - address = patient.addresses.find { it.use == "home" } ?: patient.addresses.firstOrNull() - ) - } - - private fun createDocumentSummary(ipsDocument: IpsDocument): DocumentSummary { - val totalItems = ipsDocument.medications.size + ipsDocument.allergies.size + - ipsDocument.conditions.size + ipsDocument.immunizations.size + - ipsDocument.procedures.size + ipsDocument.observations.size + - ipsDocument.diagnosticReports.size - - val lastUpdated = ipsDocument.composition?.date - - return DocumentSummary( - totalClinicalItems = totalItems, - lastUpdated = lastUpdated, - documentTitle = ipsDocument.composition?.title ?: "International Patient Summary", - author = ipsDocument.composition?.author - ) - } - - private fun createSections(ipsDocument: IpsDocument): List { - val sections = mutableListOf() - - // Active medications - if (ipsDocument.medications.isNotEmpty()) { - val activeMeds = ipsDocument.medications.filter { - it.status == null || it.status.equals("active", ignoreCase = true) - } - sections.add(IpsSection( - title = "Current Medications", - code = "medications", - text = if (activeMeds.isNotEmpty()) - "${activeMeds.size} active medications" - else "No active medications", - resourceCount = activeMeds.size - )) - } - - // Active allergies - if (ipsDocument.allergies.isNotEmpty()) { - val activeAllergies = ipsDocument.allergies.filter { - it.clinicalStatus == null || it.clinicalStatus.equals("active", ignoreCase = true) - } - sections.add(IpsSection( - title = "Allergies & Intolerances", - code = "allergies", - text = if (activeAllergies.isNotEmpty()) - "${activeAllergies.size} known allergies/intolerances" - else "No known allergies", - resourceCount = activeAllergies.size - )) - } - - // Active conditions - if (ipsDocument.conditions.isNotEmpty()) { - val activeConditions = ipsDocument.conditions.filter { - it.clinicalStatus == null || it.clinicalStatus.equals("active", ignoreCase = true) - } - sections.add(IpsSection( - title = "Medical Conditions", - code = "conditions", - text = "${activeConditions.size} active conditions", - resourceCount = activeConditions.size - )) - } - - // Immunizations - if (ipsDocument.immunizations.isNotEmpty()) { - sections.add(IpsSection( - title = "Immunizations", - code = "immunizations", - text = "${ipsDocument.immunizations.size} vaccination records", - resourceCount = ipsDocument.immunizations.size - )) - } - - // Recent procedures - if (ipsDocument.procedures.isNotEmpty()) { - sections.add(IpsSection( - title = "Procedures", - code = "procedures", - text = "${ipsDocument.procedures.size} recorded procedures", - resourceCount = ipsDocument.procedures.size - )) - } - - // Lab results and observations - if (ipsDocument.observations.isNotEmpty() || ipsDocument.diagnosticReports.isNotEmpty()) { - val totalLabItems = ipsDocument.observations.size + ipsDocument.diagnosticReports.size - sections.add(IpsSection( - title = "Laboratory Results", - code = "laboratory", - text = "$totalLabItems lab results and observations", - resourceCount = totalLabItems - )) - } - - return sections - } - - private fun generateAlerts(ipsDocument: IpsDocument): List { - val alerts = mutableListOf() - - // Critical allergies - val criticalAllergies = ipsDocument.allergies.filter { - it.criticality?.equals("high", ignoreCase = true) == true - } - if (criticalAllergies.isNotEmpty()) { - alerts.add(ClinicalAlert( - level = AlertLevel.HIGH, - title = "Critical Allergies", - message = "${criticalAllergies.size} high-criticality allergies noted", - details = criticalAllergies.map { it.substance ?: "Unknown substance" } - )) - } - - // Active high-severity conditions - val severeConditions = ipsDocument.conditions.filter { - it.severity?.contains("severe", ignoreCase = true) == true && - (it.clinicalStatus == null || it.clinicalStatus.equals("active", ignoreCase = true)) - } - if (severeConditions.isNotEmpty()) { - alerts.add(ClinicalAlert( - level = AlertLevel.MEDIUM, - title = "Severe Conditions", - message = "${severeConditions.size} severe medical conditions", - details = severeConditions.map { it.name ?: "Unknown condition" } - )) - } - - // Incomplete vaccination series - val incompleteVaccinations = ipsDocument.immunizations.filter { immunization -> - immunization.doseNumber != null && immunization.seriesDoses != null && - immunization.doseNumber!! < immunization.seriesDoses!! - }.groupBy { it.vaccineName } - - if (incompleteVaccinations.isNotEmpty()) { - alerts.add(ClinicalAlert( - level = AlertLevel.LOW, - title = "Incomplete Vaccinations", - message = "${incompleteVaccinations.size} vaccine series may be incomplete", - details = incompleteVaccinations.keys.filterNotNull() - )) - } - - return alerts - } - - private fun extractMetadata(ipsDocument: IpsDocument): IpsMetadata { - val resourceCounts = mutableMapOf() - - if (ipsDocument.medications.isNotEmpty()) resourceCounts["Medications"] = ipsDocument.medications.size - if (ipsDocument.allergies.isNotEmpty()) resourceCounts["Allergies"] = ipsDocument.allergies.size - if (ipsDocument.conditions.isNotEmpty()) resourceCounts["Conditions"] = ipsDocument.conditions.size - if (ipsDocument.immunizations.isNotEmpty()) resourceCounts["Immunizations"] = ipsDocument.immunizations.size - if (ipsDocument.procedures.isNotEmpty()) resourceCounts["Procedures"] = ipsDocument.procedures.size - if (ipsDocument.observations.isNotEmpty()) resourceCounts["Observations"] = ipsDocument.observations.size - if (ipsDocument.diagnosticReports.isNotEmpty()) resourceCounts["Diagnostic Reports"] = ipsDocument.diagnosticReports.size - - resourceCounts.putAll(ipsDocument.otherResources) - - return IpsMetadata( - documentType = "International Patient Summary", - fhirVersion = "R4", - resourceCounts = resourceCounts, - totalResources = resourceCounts.values.sum() + 1 // +1 for patient - ) - } -} - -/** - * Processed IPS document with organized data and business rules applied - */ -data class ProcessedIpsDocument( - val patient: ProcessedPatientInfo, - val summary: DocumentSummary, - val sections: List, - val alerts: List, - val metadata: IpsMetadata -) - -/** - * Processed patient information optimized for display - */ -data class ProcessedPatientInfo( - val displayName: String, - val birthDate: String?, - val ageInfo: String?, - val gender: String?, - val primaryIdentifier: IpsIdentifier?, - val contactInfo: List, - val address: IpsAddress? -) - -/** - * Document summary information - */ -data class DocumentSummary( - val totalClinicalItems: Int, - val lastUpdated: String?, - val documentTitle: String, - val author: String? -) - -/** - * Clinical alerts for important patient information - */ -data class ClinicalAlert( - val level: AlertLevel, - val title: String, - val message: String, - val details: List = emptyList() -) - -/** - * Alert severity levels - */ -enum class AlertLevel { - HIGH, // Critical information (severe allergies, etc.) - MEDIUM, // Important information (severe conditions, etc.) - LOW // Informational (incomplete vaccinations, etc.) -} - -/** - * Document metadata - */ -data class IpsMetadata( - val documentType: String, - val fhirVersion: String, - val resourceCounts: Map, - val totalResources: Int -) \ No newline at end of file diff --git a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewer.kt b/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewer.kt deleted file mode 100644 index b59aa48f..00000000 --- a/ips-viewer/src/main/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewer.kt +++ /dev/null @@ -1,151 +0,0 @@ -package org.who.gdhcnvalidator.ipsviewer - -import org.hl7.fhir.r4.model.Bundle - -/** - * Main facade for IPS viewing functionality - * Provides a simple interface for parsing and displaying FHIR IPS documents - */ -class IpsViewer { - - private val parser = IpsParser() - private val processor = IpsProcessor() - private val formatter = IpsFormatter() - - /** - * Parse and process a FHIR Bundle as an IPS document - * - * @param bundle FHIR Bundle containing IPS data - * @return Processed IPS document ready for display - * @throws IllegalArgumentException if bundle is not a valid IPS document - */ - fun processIpsBundle(bundle: Bundle): ProcessedIpsDocument { - val ipsDocument = parser.parseIpsBundle(bundle) - return processor.processIpsDocument(ipsDocument) - } - - /** - * Create a comprehensive text representation of an IPS document - * - * @param bundle FHIR Bundle containing IPS data - * @return Formatted text suitable for display - */ - fun formatIpsAsText(bundle: Bundle): String { - val processedIps = processIpsBundle(bundle) - return formatter.formatIpsDocument(processedIps) - } - - /** - * Create a brief summary of an IPS document - * - * @param bundle FHIR Bundle containing IPS data - * @return Brief formatted text summary - */ - fun formatIpsBrief(bundle: Bundle): String { - val processedIps = processIpsBundle(bundle) - return formatter.formatBriefSummary(processedIps) - } - - /** - * Extract just the patient information from an IPS bundle - * - * @param bundle FHIR Bundle containing IPS data - * @return Processed patient information - */ - fun extractPatientInfo(bundle: Bundle): ProcessedPatientInfo { - val processedIps = processIpsBundle(bundle) - return processedIps.patient - } - - /** - * Check if a FHIR Bundle contains the minimum required resources for an IPS - * - * @param bundle FHIR Bundle to validate - * @return true if bundle appears to be a valid IPS document - */ - fun isValidIpsBundle(bundle: Bundle): Boolean { - return try { - val entries = bundle.entry ?: return false - - // Must have a Patient resource - val hasPatient = entries.any { it.resource is org.hl7.fhir.r4.model.Patient } - - // Should have a Composition resource for a complete IPS - val hasComposition = entries.any { it.resource is org.hl7.fhir.r4.model.Composition } - - // Basic validation - must have patient, composition is recommended but not required - hasPatient - } catch (e: Exception) { - false - } - } - - /** - * Get clinical alerts from an IPS document - * - * @param bundle FHIR Bundle containing IPS data - * @return List of clinical alerts - */ - fun getClinicalAlerts(bundle: Bundle): List { - val processedIps = processIpsBundle(bundle) - return processedIps.alerts - } - - /** - * Get document metadata from an IPS bundle - * - * @param bundle FHIR Bundle containing IPS data - * @return IPS metadata - */ - fun getIpsMetadata(bundle: Bundle): IpsMetadata { - val processedIps = processIpsBundle(bundle) - return processedIps.metadata - } -} - -/** - * Result of IPS processing operations - */ -sealed class IpsResult { - data class Success(val data: T) : IpsResult() - data class Error(val message: String, val cause: Throwable? = null) : IpsResult() -} - -/** - * Safe version of IpsViewer that returns results instead of throwing exceptions - */ -class SafeIpsViewer { - - private val ipsViewer = IpsViewer() - - /** - * Safely process an IPS bundle, returning a result instead of throwing exceptions - */ - fun processIpsBundle(bundle: Bundle): IpsResult { - return try { - val result = ipsViewer.processIpsBundle(bundle) - IpsResult.Success(result) - } catch (e: Exception) { - IpsResult.Error("Failed to process IPS bundle: ${e.message}", e) - } - } - - /** - * Safely format an IPS bundle as text - */ - fun formatIpsAsText(bundle: Bundle): IpsResult { - return try { - val result = ipsViewer.formatIpsAsText(bundle) - IpsResult.Success(result) - } catch (e: Exception) { - IpsResult.Error("Failed to format IPS bundle: ${e.message}", e) - } - } - - /** - * Safely check if a bundle is a valid IPS - */ - fun isValidIpsBundle(bundle: Bundle): Boolean { - return ipsViewer.isValidIpsBundle(bundle) - } -} \ No newline at end of file diff --git a/ips-viewer/src/test/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewerTest.kt b/ips-viewer/src/test/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewerTest.kt deleted file mode 100644 index 53f614f0..00000000 --- a/ips-viewer/src/test/kotlin/org/who/gdhcnvalidator/ipsviewer/IpsViewerTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -package org.who.gdhcnvalidator.ipsviewer - -import org.hl7.fhir.r4.model.* -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.* -import java.time.LocalDate - -/** - * Tests for the IPS Viewer library - */ -class IpsViewerTest { - - @Test - fun `test IPS viewer creation and basic functionality`() { - val ipsViewer = IpsViewer() - assertNotNull(ipsViewer) - } - - @Test - fun `test basic patient parsing`() { - val bundle = createTestIpsBundle() - val ipsViewer = IpsViewer() - - assertTrue(ipsViewer.isValidIpsBundle(bundle)) - - val processedIps = ipsViewer.processIpsBundle(bundle) - - assertEquals("John Doe", processedIps.patient.displayName) - assertEquals("male", processedIps.patient.gender) - assertNotNull(processedIps.patient.birthDate) - - val formattedText = ipsViewer.formatIpsAsText(bundle) - assertTrue(formattedText.contains("John Doe")) - assertTrue(formattedText.contains("Patient Information")) - } - - @Test - fun `test clinical alerts detection`() { - val bundle = createTestIpsBundleWithAllergies() - val ipsViewer = IpsViewer() - - val alerts = ipsViewer.getClinicalAlerts(bundle) - - assertTrue(alerts.isNotEmpty()) - assertTrue(alerts.any { it.level == AlertLevel.HIGH }) - assertTrue(alerts.any { it.title.contains("Critical Allergies") }) - } - - @Test - fun `test metadata extraction`() { - val bundle = createTestIpsBundle() - val ipsViewer = IpsViewer() - - val metadata = ipsViewer.getIpsMetadata(bundle) - - assertEquals("International Patient Summary", metadata.documentType) - assertEquals("R4", metadata.fhirVersion) - assertTrue(metadata.totalResources > 0) - } - - @Test - fun `test safe IPS viewer`() { - val safeViewer = SafeIpsViewer() - val bundle = createTestIpsBundle() - - val result = safeViewer.processIpsBundle(bundle) - assertTrue(result is IpsResult.Success) - - when (result) { - is IpsResult.Success -> { - assertEquals("John Doe", result.data.patient.displayName) - } - is IpsResult.Error -> fail("Expected success, got error: ${result.message}") - } - } - - @Test - fun `test invalid bundle handling`() { - val invalidBundle = Bundle() // Empty bundle - val safeViewer = SafeIpsViewer() - - assertFalse(safeViewer.isValidIpsBundle(invalidBundle)) - - val result = safeViewer.processIpsBundle(invalidBundle) - assertTrue(result is IpsResult.Error) - } - - private fun createTestIpsBundle(): Bundle { - val bundle = Bundle() - bundle.type = Bundle.BundleType.DOCUMENT - - // Add patient - val patient = Patient() - patient.id = "patient-1" - patient.addName().addGiven("John").family = "Doe" - patient.gender = Enumerations.AdministrativeGender.MALE - patient.birthDate = java.util.Date(90, 0, 1) // Jan 1, 1990 - - val patientEntry = Bundle.BundleEntryComponent() - patientEntry.resource = patient - bundle.addEntry(patientEntry) - - // Add composition - val composition = Composition() - composition.id = "composition-1" - composition.status = Composition.CompositionStatus.FINAL - composition.type = CodeableConcept() - composition.type.addCoding().code = "60591-5" - composition.title = "International Patient Summary" - composition.subject = Reference("Patient/patient-1") - - val compositionEntry = Bundle.BundleEntryComponent() - compositionEntry.resource = composition - bundle.addEntry(compositionEntry) - - return bundle - } - - private fun createTestIpsBundleWithAllergies(): Bundle { - val bundle = createTestIpsBundle() - - // Add critical allergy - val allergy = AllergyIntolerance() - allergy.id = "allergy-1" - allergy.clinicalStatus = CodeableConcept() - allergy.clinicalStatus.addCoding().code = "active" - allergy.criticality = AllergyIntolerance.AllergyIntoleranceCriticality.HIGH - allergy.code = CodeableConcept() - allergy.code.text = "Penicillin" - allergy.patient = Reference("Patient/patient-1") - - val allergyEntry = Bundle.BundleEntryComponent() - allergyEntry.resource = allergy - bundle.addEntry(allergyEntry) - - return bundle - } -} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 8ed11800..eeb23bbc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -16,4 +16,3 @@ include ':trust-pathcheck' include ':trust-didweb' include ':test-resources' include ':web' -include ':ips-viewer'