diff --git a/PAWS.xcodeproj/project.pbxproj b/PAWS.xcodeproj/project.pbxproj index 4060fd16..b5e75d09 100644 --- a/PAWS.xcodeproj/project.pbxproj +++ b/PAWS.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 2F65B44E2A3B8B0600A36932 /* NotificationPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F65B44D2A3B8B0600A36932 /* NotificationPermissions.swift */; }; 2FA0BFED2ACC977500E0EF83 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */; }; 2FA25D492B8402A70016E4C7 /* HKElectrocardiogram+SupplementaryData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA25D482B8402A70016E4C7 /* HKElectrocardiogram+SupplementaryData.swift */; }; + 2FA7EC1C2C7C1CFB00D674AA /* FHIRTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7EC1B2C7C1CFB00D674AA /* FHIRTypes.swift */; }; 2FB099AF2A875DF100B20952 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099AE2A875DF100B20952 /* FirebaseAuth */; }; 2FB099B12A875DF100B20952 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099B02A875DF100B20952 /* FirebaseFirestore */; }; 2FB099B32A875DF100B20952 /* FirebaseFirestoreSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099B22A875DF100B20952 /* FirebaseFirestoreSwift */; }; @@ -110,6 +111,7 @@ 2F65B44D2A3B8B0600A36932 /* NotificationPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissions.swift; sourceTree = ""; }; 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 2FA25D482B8402A70016E4C7 /* HKElectrocardiogram+SupplementaryData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKElectrocardiogram+SupplementaryData.swift"; sourceTree = ""; }; + 2FA7EC1B2C7C1CFB00D674AA /* FHIRTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FHIRTypes.swift; sourceTree = ""; }; 2FAEC07F297F583900C11C42 /* PAWS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PAWS.entitlements; sourceTree = ""; }; 2FBF63322C2CF491002F4AC1 /* Firestore+User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Firestore+User.swift"; sourceTree = ""; }; 2FC94CD4298B0A1D009C8209 /* PAWS.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = PAWS.xctestplan; sourceTree = ""; }; @@ -296,6 +298,7 @@ 2FE5DC4429EDD7F2004B9AB4 /* CodableArray+RawRepresentable.swift */, B2433E1C2BCF60C800D7C798 /* Contact+PersonNameComponents.swift */, 2FBF63322C2CF491002F4AC1 /* Firestore+User.swift */, + 2FA7EC1B2C7C1CFB00D674AA /* FHIRTypes.swift */, ); path = Helper; sourceTree = ""; @@ -418,8 +421,8 @@ buildRules = ( ); dependencies = ( + 2F15E22E2C7C3F6A006584DD /* PBXTargetDependency */, 2FDF443B2C65DB4A0075AFC1 /* PBXTargetDependency */, - 2FDF443D2C65DB4A0075AFC1 /* PBXTargetDependency */, ); name = PAWS; packageProductDependencies = ( @@ -623,6 +626,7 @@ B2F7F1E22BA549A900BE93BE /* InvitationCodeError.swift in Sources */, 2FE5DC3629EDD7CA004B9AB4 /* HealthKitPermissions.swift in Sources */, 2F65B44E2A3B8B0600A36932 /* NotificationPermissions.swift in Sources */, + 2FA7EC1C2C7C1CFB00D674AA /* FHIRTypes.swift in Sources */, 5661552E2AB854C000209B80 /* PackageHelper.swift in Sources */, 2FE5DC2629EDD38A004B9AB4 /* Contacts.swift in Sources */, 2FA25D492B8402A70016E4C7 /* HKElectrocardiogram+SupplementaryData.swift in Sources */, @@ -651,13 +655,13 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 2FDF443B2C65DB4A0075AFC1 /* PBXTargetDependency */ = { + 2F15E22E2C7C3F6A006584DD /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = 2FDF443A2C65DB4A0075AFC1 /* SwiftPackageListJSONPlugin */; + productRef = 2F15E22D2C7C3F6A006584DD /* SwiftLintPlugin */; }; - 2FDF443D2C65DB4A0075AFC1 /* PBXTargetDependency */ = { + 2FDF443B2C65DB4A0075AFC1 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = 2FDF443C2C65DB4A0075AFC1 /* SwiftLintPlugin */; + productRef = 2FDF443A2C65DB4A0075AFC1 /* SwiftPackageListJSONPlugin */; }; 653A255F28338800005D4D48 /* PBXTargetDependency */ = { isa = PBXTargetDependency; @@ -1024,7 +1028,7 @@ repositoryURL = "https://github.com/StanfordBDHG/HealthKitOnFHIR.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.2.7; + minimumVersion = 0.2.10; }; }; 2FCC1DCE2B6A2CE000C686BE /* XCRemoteSwiftPackageReference "SwiftLint" */ = { @@ -1126,6 +1130,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 2F15E22D2C7C3F6A006584DD /* SwiftLintPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = 2FCC1DCE2B6A2CE000C686BE /* XCRemoteSwiftPackageReference "SwiftLint" */; + productName = "plugin:SwiftLintPlugin"; + }; 2F3D4ABB2A4E7C290068FB2F /* SpeziScheduler */ = { isa = XCSwiftPackageProductDependency; package = 2F3D4ABA2A4E7C290068FB2F /* XCRemoteSwiftPackageReference "SpeziScheduler" */; @@ -1165,11 +1174,6 @@ package = 5661551B2AB8384200209B80 /* XCRemoteSwiftPackageReference "swift-package-list" */; productName = "plugin:SwiftPackageListJSONPlugin"; }; - 2FDF443C2C65DB4A0075AFC1 /* SwiftLintPlugin */ = { - isa = XCSwiftPackageProductDependency; - package = 2FCC1DCE2B6A2CE000C686BE /* XCRemoteSwiftPackageReference "SwiftLint" */; - productName = "plugin:SwiftLintPlugin"; - }; 2FE5DC6329EDD883004B9AB4 /* SpeziAccount */ = { isa = XCSwiftPackageProductDependency; package = 2FE5DC6229EDD883004B9AB4 /* XCRemoteSwiftPackageReference "SpeziAccount" */; diff --git a/PAWS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PAWS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3939a7a4..57370c85 100644 --- a/PAWS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/PAWS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordBDHG/HealthKitOnFHIR.git", "state" : { - "revision" : "b0cfe35a2263a517b22196b559d2dd1d1e2afcd9", - "version" : "0.2.9" + "revision" : "c24e316311ff9813cb1fe32cd8820bcca6e5e7f2", + "version" : "0.2.10" } }, { diff --git a/PAWS/ECGRecordings/ECGModule.swift b/PAWS/ECGRecordings/ECGModule.swift index 5ead47fd..0a851e24 100644 --- a/PAWS/ECGRecordings/ECGModule.swift +++ b/PAWS/ECGRecordings/ECGModule.swift @@ -9,8 +9,8 @@ import FirebaseAuth import FirebaseFirestore import HealthKit +import HealthKitOnFHIR import OSLog -import enum ModelsR4.ResourceProxy import Spezi import SpeziFirebaseConfiguration import SpeziHealthKit @@ -18,10 +18,14 @@ import SpeziLocalStorage import UserNotifications +@globalActor private actor ECGModuleActor: GlobalActor { + static let shared = ECGModuleActor() +} + @Observable class ECGModule: Module, DefaultInitializable, EnvironmentAccessible { - @ObservationIgnored @Dependency private var firebaseConfiguration: ConfigureFirebaseApp - @ObservationIgnored @Dependency private var healthKit: HealthKit + @ObservationIgnored @Dependency(ConfigureFirebaseApp.self) private var firebaseConfiguration + @ObservationIgnored @Dependency(HealthKit.self) private var healthKit private(set) var electrocardiograms: [HKElectrocardiogram] = [] private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? @@ -48,13 +52,32 @@ class ECGModule: Module, DefaultInitializable, EnvironmentAccessible { func isUploaded(_ electrocardiogram: HKElectrocardiogram, reuploadIfNeeded: Bool = false) async throws -> Bool { let documentReference = try await Firestore.firestore().healthKitCollectionReference.document(electrocardiogram.uuid.uuidString) let snapshot = try await documentReference.getDocument() - - guard !snapshot.exists else { + + /// This function is intended to re-upload ECGs that have not been completely uploaded. Could be removed in the future. + func voltageComplete(_ electrocardiogramObservation: FHIRObservation) -> Bool { + guard let ecgCode = HKElectrocardiogramMapping.default.voltageMeasurements.codings.first else { + return false + } + + // Unfortunately we have to support compiler with explicity type annotations on slower machines & the CI. + let voltageMeasurementsComponentsCount = electrocardiogramObservation.component?.filter { component in + component.code.coding?.contains(where: { coding in + coding.code?.value?.string == ecgCode.code && coding.system?.value?.url == ecgCode.system + }) ?? false + }.count ?? 0 + + return voltageMeasurementsComponentsCount >= 3 + } + + if snapshot.exists, + let electrocardiogramObservation = try? snapshot.data(as: FHIRObservation.self), + voltageComplete(electrocardiogramObservation) { return true } if reuploadIfNeeded { - try await documentReference.setData(from: try electrocardiogram.resource) + await upload(electrocardiogram: electrocardiogram) + logger.log("Uploaded Missing ECG: \(electrocardiogram.id)") return true } @@ -83,7 +106,7 @@ class ECGModule: Module, DefaultInitializable, EnvironmentAccessible { let samples = try await queryDescriptor.result(for: healthStore) self.electrocardiograms = samples - try await self.uploadUnuploadedECGs() + await self.uploadUnuploadedECGs() } @@ -132,6 +155,7 @@ class ECGModule: Module, DefaultInitializable, EnvironmentAccessible { } } + @ECGModuleActor func remove(sample id: HKSample.ID) async throws { electrocardiograms.removeAll(where: { $0.uuid == id }) try await Firestore.firestore().healthKitCollectionReference.document(id.uuidString).delete() @@ -139,6 +163,7 @@ class ECGModule: Module, DefaultInitializable, EnvironmentAccessible { // MARK: - Private Helper Functions + @ECGModuleActor private func insert(electrocardiogram: HKElectrocardiogram) { electrocardiograms.removeAll(where: { $0.uuid == electrocardiogram.id }) electrocardiograms.append(electrocardiogram) @@ -181,9 +206,9 @@ class ECGModule: Module, DefaultInitializable, EnvironmentAccessible { private func upload(sample: HKSample, force: Bool = false) async throws { - let resource: ResourceProxy + let resource: FHIRResourceProxy if let electrocardiogram = sample as? HKElectrocardiogram { - self.insert(electrocardiogram: electrocardiogram) + await self.insert(electrocardiogram: electrocardiogram) guard try await !self.isUploaded(electrocardiogram) || force else { return @@ -192,7 +217,7 @@ class ECGModule: Module, DefaultInitializable, EnvironmentAccessible { async let symptoms = try electrocardiogram.symptoms(from: healthStore) async let voltageMeasurements = try electrocardiogram.voltageMeasurements(from: healthStore) - resource = ResourceProxy( + resource = FHIRResourceProxy( with: try await electrocardiogram.observation( symptoms: symptoms, voltageMeasurements: voltageMeasurements @@ -205,14 +230,14 @@ class ECGModule: Module, DefaultInitializable, EnvironmentAccessible { try await Firestore.firestore().healthKitCollectionReference.document(sample.id.uuidString).setData(from: resource) } - private func uploadUnuploadedECGs() async throws { - try await withThrowingTaskGroup(of: Void.self) { group in - for ecg in electrocardiograms where try await !isUploaded(ecg) { + private func uploadUnuploadedECGs() async { + await withTaskGroup(of: Void.self) { group in + for ecg in electrocardiograms { group.addTask { [weak self] in do { try await self?.upload(sample: ecg) } catch { - self?.logger.log("Could not access HealthKit sample: \(error)") + self?.logger.log("Could not upload ECG: \(error)") await self?.addECGMessage(for: ecg, error: error) } } diff --git a/PAWS/ECGRecordings/ECGRecording.swift b/PAWS/ECGRecordings/ECGRecording.swift index a94aba09..48a2578e 100644 --- a/PAWS/ECGRecordings/ECGRecording.swift +++ b/PAWS/ECGRecordings/ECGRecording.swift @@ -47,16 +47,16 @@ struct ECGRecording: View { } .padding() } - .task { - guard let symptoms = try? await electrocardiogram.symptoms(from: HKHealthStore()) else { - return - } - - self.symptoms = symptoms - - if !FeatureFlags.disableFirebase { - self.isUploaded = (try? await ecgModule.isUploaded(electrocardiogram, reuploadIfNeeded: true)) ?? false + .task { + guard let symptoms = try? await electrocardiogram.symptoms(from: HKHealthStore()) else { + return + } + + self.symptoms = symptoms + + if !FeatureFlags.disableFirebase { + self.isUploaded = (try? await ecgModule.isUploaded(electrocardiogram, reuploadIfNeeded: true)) ?? false + } } - } } } diff --git a/PAWS/Helper/FHIRTypes.swift b/PAWS/Helper/FHIRTypes.swift new file mode 100644 index 00000000..10b5f26e --- /dev/null +++ b/PAWS/Helper/FHIRTypes.swift @@ -0,0 +1,15 @@ +// +// This source file is part of the PAWS application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import ModelsR4 + + +typealias FHIRObservation = ModelsR4.Observation +typealias FHIRResourceProxy = ModelsR4.ResourceProxy +typealias FHIRObservationComponent = ModelsR4.ObservationComponent +typealias FHIRCoding = ModelsR4.Coding diff --git a/PAWS/PAWSStandard.swift b/PAWS/PAWSStandard.swift index 5ee505ec..e8682719 100644 --- a/PAWS/PAWSStandard.swift +++ b/PAWS/PAWSStandard.swift @@ -10,7 +10,6 @@ import FirebaseAuth import FirebaseFirestore import FirebaseStorage import HealthKitOnFHIR -import enum ModelsR4.ResourceProxy import OSLog import PDFKit import Spezi @@ -24,9 +23,9 @@ import SwiftUI actor PAWSStandard: Standard, EnvironmentAccessible, HealthKitConstraint, OnboardingConstraint, AccountNotifyConstraint, AccountStorageConstraint { - @Dependency private var firebaseConfiguration: ConfigureFirebaseApp - @Dependency private var accountStorage: FirestoreAccountStorage? - @Dependency private var ecgStorage: ECGModule + @Dependency(ConfigureFirebaseApp.self) private var firebaseConfiguration + @Dependency(FirestoreAccountStorage.self) private var accountStorage: FirestoreAccountStorage? + @Dependency(ECGModule.self) private var ecgStorage private let logger = Logger(subsystem: "PAWS", category: "Standard") diff --git a/PAWS/Study Information/EnrollmentGroup.swift b/PAWS/Study Information/EnrollmentGroup.swift index c5ecfc7e..b139de87 100644 --- a/PAWS/Study Information/EnrollmentGroup.swift +++ b/PAWS/Study Information/EnrollmentGroup.swift @@ -17,7 +17,7 @@ import SwiftUI @Observable class EnrollmentGroup: Module, EnvironmentAccessible { - @ObservationIgnored @Dependency private var configureFirebaseApp: ConfigureFirebaseApp + @ObservationIgnored @Dependency(ConfigureFirebaseApp.self) private var configureFirebaseApp private var dateOfBirth: Date? private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? private var snapshotListener: ListenerRegistration? diff --git a/README.md b/README.md index af59efe9..d01fa361 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Copy the `ECGExporter` and `ECGReviewer` notebooks in Colab Enterprise, uncommen ## Continous Integration Setup The project supports different GitHub environments (`development`, `staging`, and `production`). -- The Firebase proejct ID needs to be saved as a GitHub variable with the name `FIREBASE_PROJECT_ID` for the different deployment environments. +- The Firebase project ID needs to be saved as a GitHub variable with the name `FIREBASE_PROJECT_ID` for the different deployment environments. - The service account key needs to be added to the GitHub secrets as `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in a base64 encoding to enable the beta deployment. - To report code coverage, a CodeCov token should be added as a `CODECOV_TOKEN` environment secret. - The Firebase Google plist needs to be stored as a base64 encoded secret named `GOOGLE_SERVICE_INFO_PLIST_BASE64`. diff --git a/ecg_data_manager/modules/visualization.py b/ecg_data_manager/modules/visualization.py index e3cfe617..ef19059b 100644 --- a/ecg_data_manager/modules/visualization.py +++ b/ecg_data_manager/modules/visualization.py @@ -335,11 +335,21 @@ def plot_single_ecg(self, row): # pylint: disable=too-many-locals ax=axs[i], ) - user_id = row[ColumnNames.USER_ID.value] - heart_rate = int(row[ColumnNames.HEART_RATE.value]) - ecg_interpretation = row[ - ColumnNames.APPLE_ELECTROCARDIOGRAM_CLASSIFICATION.value - ] + user_id = ( + row[ColumnNames.USER_ID.value] + if row[ColumnNames.USER_ID.value] is not None + else "Unknown" + ) + heart_rate = ( + int(row[ColumnNames.HEART_RATE.value]) + if row[ColumnNames.HEART_RATE.value] is not None + else "Unknown" + ) + ecg_interpretation = ( + row[ColumnNames.APPLE_ELECTROCARDIOGRAM_CLASSIFICATION.value] + if row[ColumnNames.APPLE_ELECTROCARDIOGRAM_CLASSIFICATION.value] is not None + else "Unknown" + ) group_class = row[AGE_GROUP_STRING] user_id_html = widgets.HTML( @@ -596,7 +606,6 @@ def save_diagnosis( # pylint: disable=too-many-locals, too-many-arguments "saved successfully.✓" ) display(data_saved_html) - else: print( "ECG has already been reviewed. No further review is required." @@ -929,11 +938,21 @@ def plot_single_ecg(self, row): # pylint: disable=too-many-locals ax=axs[i], ) - user_id = row[ColumnNames.USER_ID.value] - heart_rate = int(row[ColumnNames.HEART_RATE.value]) - ecg_interpretation = row[ - ColumnNames.APPLE_ELECTROCARDIOGRAM_CLASSIFICATION.value - ] + user_id = ( + row[ColumnNames.USER_ID.value] + if row[ColumnNames.USER_ID.value] is not None + else "Unknown" + ) + heart_rate = ( + int(row[ColumnNames.HEART_RATE.value]) + if row[ColumnNames.HEART_RATE.value] is not None + else "Unknown" + ) + ecg_interpretation = ( + row[ColumnNames.APPLE_ELECTROCARDIOGRAM_CLASSIFICATION.value] + if row[ColumnNames.APPLE_ELECTROCARDIOGRAM_CLASSIFICATION.value] is not None + else "Unknown" + ) group_class = row[AGE_GROUP_STRING] user_id_html = widgets.HTML( diff --git a/firebase/firebase-export-metadata.json b/firebase/firebase-export-metadata.json index 5a37fc8d..1248a5a5 100644 --- a/firebase/firebase-export-metadata.json +++ b/firebase/firebase-export-metadata.json @@ -1,16 +1,16 @@ { - "version": "13.15.0", + "version": "13.15.4", "firestore": { "version": "1.19.7", "path": "firestore_export", "metadata_file": "firestore_export/firestore_export.overall_export_metadata" }, "auth": { - "version": "13.15.0", + "version": "13.15.4", "path": "auth_export" }, "storage": { - "version": "13.15.0", + "version": "13.15.4", "path": "storage_export" } } \ No newline at end of file diff --git a/firebase/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/firebase/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata index f08c4673..cbfface2 100644 Binary files a/firebase/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata and b/firebase/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ