diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 0499e6f..0f9023d 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -42,7 +42,7 @@ jobs: contents: read codeql: name: CodeQL - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 permissions: security-events: write actions: read @@ -50,6 +50,7 @@ jobs: with: codeql: true fastlanelane: codeql + xcodeversion: "16.1" # Temporary workaround for: https://github.com/actions/runner-images/issues/11335. Remove when resolved. pylint: name: PyLint runs-on: ubuntu-latest diff --git a/.periphery.yml b/.periphery.yml index d0a5137..2de6efa 100644 --- a/.periphery.yml +++ b/.periphery.yml @@ -9,7 +9,3 @@ project: PAWS.xcodeproj schemes: - PAWS -targets: -- PAWS -- PAWSTests -- PAWSUITests diff --git a/PAWS.xcodeproj/project.pbxproj b/PAWS.xcodeproj/project.pbxproj index 2808110..b955bd8 100644 --- a/PAWS.xcodeproj/project.pbxproj +++ b/PAWS.xcodeproj/project.pbxproj @@ -62,6 +62,7 @@ 653A2551283387FE005D4D48 /* PAWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A2550283387FE005D4D48 /* PAWS.swift */; }; 653A255528338800005D4D48 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 653A255428338800005D4D48 /* Assets.xcassets */; }; 653A256228338800005D4D48 /* PAWSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A256128338800005D4D48 /* PAWSTests.swift */; }; + 65A0437E2CF3E43400B44621 /* DateOfEnrollment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A0437D2CF3E43400B44621 /* DateOfEnrollment.swift */; }; 9733CFC62A8066DE001B7ABC /* SpeziOnboarding in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC8029EDD91D004B9AB4 /* SpeziOnboarding */; }; 9739A0C62AD7B5730084BEA5 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 9739A0C52AD7B5730084BEA5 /* FirebaseStorage */; }; 97D73D6A2AD860AD00B47FA0 /* SpeziFirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 97D73D692AD860AD00B47FA0 /* SpeziFirebaseStorage */; }; @@ -141,6 +142,7 @@ 653A256128338800005D4D48 /* PAWSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PAWSTests.swift; sourceTree = ""; }; 653A256728338800005D4D48 /* PAWSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PAWSUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 653A258928339462005D4D48 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 65A0437D2CF3E43400B44621 /* DateOfEnrollment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateOfEnrollment.swift; sourceTree = ""; }; A9720E422ABB68CC00872D23 /* AccountSetupHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSetupHeader.swift; sourceTree = ""; }; A9DFE8A82ABE551400428242 /* AccountButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountButton.swift; sourceTree = ""; }; A9FE7ACF2AA39BAB0077B045 /* AccountSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSheet.swift; sourceTree = ""; }; @@ -380,6 +382,7 @@ A9FE7ACF2AA39BAB0077B045 /* AccountSheet.swift */, A9720E422ABB68CC00872D23 /* AccountSetupHeader.swift */, A9DFE8A82ABE551400428242 /* AccountButton.swift */, + 65A0437D2CF3E43400B44621 /* DateOfEnrollment.swift */, ); path = Account; sourceTree = ""; @@ -592,6 +595,7 @@ 2FCC1DC92B6A258A00C686BE /* PAWSScheduler.swift in Sources */, B2433E1D2BCF60C800D7C798 /* Contact+PersonNameComponents.swift in Sources */, 2FFD22FF2B59B158005DD268 /* StudyDescription.swift in Sources */, + 65A0437E2CF3E43400B44621 /* DateOfEnrollment.swift in Sources */, 2FCC1DC52B6A1F0600C686BE /* ECGRecording.swift in Sources */, 2F5E32BD297E05EA003432F8 /* PAWSDelegate.swift in Sources */, A9FE7AD02AA39BAB0077B045 /* AccountSheet.swift in Sources */, @@ -990,7 +994,7 @@ }; 2F49B7742980407B00BCB272 /* XCRemoteSwiftPackageReference "Spezi" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/StanfordSpezi/Spezi"; + repositoryURL = "https://github.com/StanfordSpezi/Spezi.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.8.0; @@ -1001,7 +1005,7 @@ repositoryURL = "https://github.com/StanfordBDHG/HealthKitOnFHIR.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.2.11; + minimumVersion = 0.2.13; }; }; 2FCC1DCE2B6A2CE000C686BE /* XCRemoteSwiftPackageReference "SwiftLint" */ = { @@ -1009,7 +1013,7 @@ repositoryURL = "https://github.com/realm/SwiftLint.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.57.0; + minimumVersion = 0.58.2; }; }; 2FE5DC6229EDD883004B9AB4 /* XCRemoteSwiftPackageReference "SpeziAccount" */ = { @@ -1017,7 +1021,7 @@ repositoryURL = "https://github.com/StanfordSpezi/SpeziAccount.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.1.0; + minimumVersion = 2.1.2; }; }; 2FE5DC6529EDD894004B9AB4 /* XCRemoteSwiftPackageReference "SpeziContact" */ = { @@ -1041,7 +1045,7 @@ repositoryURL = "https://github.com/StanfordSpezi/SpeziFirebase.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.0.0; + minimumVersion = 2.0.1; }; }; 2FE5DC8829EDD972004B9AB4 /* XCRemoteSwiftPackageReference "SpeziStorage" */ = { @@ -1049,7 +1053,7 @@ repositoryURL = "https://github.com/StanfordSpezi/SpeziStorage.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.1.2; + minimumVersion = 1.2.2; }; }; 2FE5DC8D29EDD980004B9AB4 /* XCRemoteSwiftPackageReference "SpeziViews" */ = { @@ -1057,7 +1061,7 @@ repositoryURL = "https://github.com/StanfordSpezi/SpeziViews.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.2.1; + minimumVersion = 1.8.0; }; }; 2FE5DC9029EDD9C3004B9AB4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { @@ -1065,7 +1069,7 @@ repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 11.5.0; + minimumVersion = 11.7.0; }; }; 2FE5DC9729EDD9D9004B9AB4 /* XCRemoteSwiftPackageReference "XCTestExtensions" */ = { @@ -1073,23 +1077,23 @@ repositoryURL = "https://github.com/StanfordBDHG/XCTestExtensions.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.1.0; + minimumVersion = 1.1.1; }; }; 2FE5DC9A29EDD9EF004B9AB4 /* XCRemoteSwiftPackageReference "XCTHealthKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordBDHG/XCTHealthKit.git"; requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.3.5; + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; }; }; 5661551B2AB8384200209B80 /* XCRemoteSwiftPackageReference "swift-package-list" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/FelixHerrmann/swift-package-list"; + repositoryURL = "https://github.com/FelixHerrmann/swift-package-list.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.1.0; + minimumVersion = 4.4.2; }; }; 65B012C52CEB030E000AA72D /* XCRemoteSwiftPackageReference "SpeziLicense" */ = { diff --git a/PAWS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PAWS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 57a1d11..8ed103f 100644 --- a/PAWS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/PAWS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "35b86c0bac11027e29970c00a506ec75d395fa5f014085f7123cbabeb12abf7b", + "originHash" : "6b3bc6a911d19827584cac47f26bc578e7c764644527a4af2a344f9caea16df7", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", "state" : { - "revision" : "678d442c6f7828def400a70ae15968aef67ef52d", - "version" : "1.8.3" + "revision" : "729e01bc9b9dab466ac85f21fb9ee2bc1c61b258", + "version" : "1.8.4" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk.git", "state" : { - "revision" : "dbdfdc44bee8b8e4eaa5ec27eb12b9338f3f2bc1", - "version" : "11.5.0" + "revision" : "0d885d28250fb1196b614bc9455079b75c531f72", + "version" : "11.7.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "4f234bcbdae841d7015258fbbf8e7743a39b8200", - "version" : "11.4.0" + "revision" : "be0881ff728eca210ccb628092af400c086abda3", + "version" : "11.7.0" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordBDHG/HealthKitOnFHIR.git", "state" : { - "revision" : "87a9257e6fa37407f3437e4a0bf21dd09a4ea7c5", - "version" : "0.2.11" + "revision" : "c898c0bace660ecae37fc682d629f7883f92e700", + "version" : "0.2.13" } }, { @@ -168,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziAccount.git", "state" : { - "revision" : "0e4dcc7d3284b439b17fae621c5c6e73d9213696", - "version" : "2.1.0" + "revision" : "de427909c99aa0575f6d12620f3a8098d28b8999", + "version" : "2.1.2" } }, { @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziFirebase.git", "state" : { - "revision" : "7c6829624884f6f1d700e0316b2580b39d3b0c5f", - "version" : "2.0.0" + "revision" : "5dd57f9de42c02d6a94f3af4d8cf3d9b81ec6661", + "version" : "2.0.1" } }, { @@ -240,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziStorage.git", "state" : { - "revision" : "0f4a54430e51f82d29da63a7ce5f61bad7dfb9cd", - "version" : "1.2.1" + "revision" : "1dc69e5fd6f02e87d00da9502176d25ef3f5795f", + "version" : "1.2.2" } }, { @@ -249,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziViews.git", "state" : { - "revision" : "f87514406bb57ae67d0040eec5454fff55104143", - "version" : "1.7.0" + "revision" : "69b085705f2af4c5dfe93278a228c12caa6c3379", + "version" : "1.8.0" } }, { @@ -285,8 +285,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/FelixHerrmann/swift-package-list", "state" : { - "revision" : "26732b1cf7e422cb330a1e24420394752a14b059", - "version" : "4.4.1" + "revision" : "5e954ec39ce2374ff28a38224fd4e6bba7c57cdc", + "version" : "4.4.2" } }, { @@ -303,8 +303,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { - "revision" : "515f79b522918f83483068d99c68daeb5116342d", - "version" : "600.0.0-prerelease-2024-08-14" + "revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25", + "version" : "600.0.0" } }, { @@ -312,8 +312,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/SwiftLint.git", "state" : { - "revision" : "168fb98ed1f3e343d703ecceaf518b6cf565207b", - "version" : "0.57.0" + "revision" : "eba420f77846e93beb98d516b225abeb2fef4ca2", + "version" : "0.58.2" } }, { @@ -339,8 +339,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordBDHG/XCTestExtensions.git", "state" : { - "revision" : "5379d70249cae926927105bfb6686770f03ee5b9", - "version" : "1.1.0" + "revision" : "d3a128997ddc3f958cbd6a534d71814f42eed1b3", + "version" : "1.1.1" } }, { @@ -348,8 +348,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordBDHG/XCTHealthKit", "state" : { - "revision" : "6e9344a2d632b801d94fe3bbd1d891817e032103", - "version" : "0.3.5" + "revision" : "5b6bebe83e2aeef49bb194795dc896f948a1c164", + "version" : "1.0.0" } }, { diff --git a/PAWS/Account/AccountSheet.swift b/PAWS/Account/AccountSheet.swift index 3480396..fae8741 100644 --- a/PAWS/Account/AccountSheet.swift +++ b/PAWS/Account/AccountSheet.swift @@ -17,6 +17,9 @@ struct AccountSheet: View { @Environment(Account.self) private var account @Environment(\.accountRequired) var accountRequired + // periphery:ignore - Uses @AppStorage + @AppStorage(StorageKeys.healthKitStartDate) var healthKitStartDate: Date? + @State var isInSetup = false @@ -32,7 +35,9 @@ struct AccountSheet: View { } } } else { - AccountSetup { _ in + AccountSetup { details in + healthKitStartDate = details.dateOfEnrollment + dismiss() // we just signed in, dismiss the account setup sheet } header: { AccountSetupHeader() diff --git a/PAWS/Account/DateOfEnrollment.swift b/PAWS/Account/DateOfEnrollment.swift new file mode 100644 index 0000000..76ce6d0 --- /dev/null +++ b/PAWS/Account/DateOfEnrollment.swift @@ -0,0 +1,68 @@ +// +// 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 Foundation +import SpeziAccount +import SpeziViews +import SwiftUI + + +// swiftlint:disable file_types_order +private struct DisplayView: DataDisplayView { + private let value: Date + + @Environment(\.locale) private var locale + + private var formatStyle: Date.FormatStyle { + .init() + .locale(locale) + .year(.defaultDigits) + .month(locale.identifier == "en_US" ? .abbreviated : .defaultDigits) + .day(.defaultDigits) + } + + var body: some View { + ListRow(AccountKeys.dateOfEnrollment.name) { + Text(value.formatted(formatStyle)) + } + } + + init(_ value: Date) { + self.value = value + } +} + + +private struct EntryView: DataEntryView { + @Binding private var value: Date + + var body: some View { + DisplayView(value) + } + + init(_ value: Binding) { + self._value = value + } +} + +extension AccountDetails { + /// The date of birth of a user. + @AccountKey( + name: LocalizedStringResource("Date of Enrollment"), + category: .other, + as: Date.self, + initial: .empty(Date()), + displayView: DisplayView.self, + entryView: EntryView.self + ) + public var dateOfEnrollment: Date? // swiftlint:disable:this attributes +} + + +@KeyEntry(\.dateOfEnrollment) +extension AccountKeys {} diff --git a/PAWS/ECGRecordings/ECGModule.swift b/PAWS/ECGRecordings/ECGModule.swift index 9c78a5a..c17b925 100644 --- a/PAWS/ECGRecordings/ECGModule.swift +++ b/PAWS/ECGRecordings/ECGModule.swift @@ -6,15 +6,16 @@ // SPDX-License-Identifier: MIT // -import FirebaseAuth import FirebaseFirestore import HealthKit import HealthKitOnFHIR import OSLog import Spezi +import SpeziAccount import SpeziFirebaseConfiguration import SpeziHealthKit import SpeziLocalStorage +import SwiftUI import UserNotifications @@ -24,27 +25,60 @@ import UserNotifications @Observable class ECGModule: Module, DefaultInitializable, EnvironmentAccessible { - // periphery:ignore - The ConfigureFirebaseApp injection is required to enforce an initialization within Spezi before this module. - @ObservationIgnored @Dependency(ConfigureFirebaseApp.self) private var firebaseConfiguration + @ObservationIgnored @Dependency(Account.self) private var account: Account? + @ObservationIgnored @Dependency(AccountNotifications.self) private var accountNotifications: AccountNotifications? @ObservationIgnored @Dependency(HealthKit.self) private var healthKit + @ObservationIgnored @AppStorage(StorageKeys.healthKitStartDate) var healthKitStartDate: Date? + private(set) var electrocardiograms: [HKElectrocardiogram] = [] - private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? private let healthStore = HKHealthStore() private let logger = Logger(subsystem: "PAWS", category: "ECGModule") + private var notificationsTask: Task? + + + private var healthKitSamplesEndDateCutoff: Date { + get async throws { + // Waiting until Spezi Account loads the account details. + let loadingStartDate = Date.now + while await account?.details?.dateOfEnrollment == nil || loadingStartDate.distance(to: .now) > 2.0 { + logger.debug("Loading DateOfEnrollment ...") + try await Task.sleep(for: .seconds(0.05)) + } + + // Ensure that the HealthKit start date is set correctly based on the date of enrollment. + guard let healthKitStartDate = await account?.details?.dateOfEnrollment else { + logger.error("Not able to load date of enrollment; falling back to the locally stored date of enrollment.") + + guard let healthKitStartDate = self.healthKitStartDate else { + logger.error("No locally stored date of enrollment. Can not load samples.") + throw FirebaseFirestore.Firestore.FirestoreError.userDetailsNotLoading + } + + return healthKitStartDate + } + + self.healthKitStartDate = healthKitStartDate + return healthKitStartDate + } + } required init() { } func configure() { - authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] _, user in - guard user != nil, let self else { - return - } - - Task { - try await self.reloadECGs() + if let accountNotifications { + notificationsTask = Task.detached { @MainActor [weak self] in + for await _ in accountNotifications.events { + guard let self else { + return + } + + Task { + try await self.reloadECGs() + } + } } } } @@ -89,7 +123,7 @@ class ECGModule: Module, DefaultInitializable, EnvironmentAccessible { /// If the user is authenticated, it sets a sample predicate for the HealthKit query based on the user's account creation date and the current date. /// - Throws: An error if the user is not authenticated. func reloadECGs() async throws { - guard let user = Auth.auth().currentUser else { + guard await account?.signedIn ?? false else { logger.error("User not authenticated") return } @@ -98,8 +132,12 @@ class ECGModule: Module, DefaultInitializable, EnvironmentAccessible { logger.error("HealthKit permissions not yet provided.") return } - - let samplePredicate = HKQuery.predicateForSamples(withStart: user.metadata.creationDate, end: .now, options: .strictStartDate) + + let samplePredicate = try await HKQuery.predicateForSamples( + withStart: healthKitSamplesEndDateCutoff, + end: .now, + options: .strictStartDate + ) let queryDescriptor = HKSampleQueryDescriptor( predicates: [HKSamplePredicate.electrocardiogram(samplePredicate)], sortDescriptors: [] @@ -207,6 +245,11 @@ class ECGModule: Module, DefaultInitializable, EnvironmentAccessible { private func upload(sample: HKSample, force: Bool = false) async throws { + // We do not upload any samples before the date of enrollment. + guard try await sample.endDate > healthKitSamplesEndDateCutoff else { + return + } + let resource: FHIRResourceProxy if let electrocardiogram = sample as? HKElectrocardiogram { await self.insert(electrocardiogram: electrocardiogram) diff --git a/PAWS/ECGRecordings/ECGRecordingsList.swift b/PAWS/ECGRecordings/ECGRecordingsList.swift index c5d47c1..e1e4701 100644 --- a/PAWS/ECGRecordings/ECGRecordingsList.swift +++ b/PAWS/ECGRecordings/ECGRecordingsList.swift @@ -15,8 +15,8 @@ struct ECGRecordingsList: View { var body: some View { - NavigationStack { - GeometryReader { geometry in + GeometryReader { geometry in + NavigationStack { ScrollView { if ecgModule.electrocardiograms.isEmpty { ContentUnavailableView { @@ -24,7 +24,7 @@ struct ECGRecordingsList: View { } description: { Text("New ECG Recordings will be displayed here.") } - .frame(minHeight: geometry.size.height) + .frame(minHeight: geometry.size.height - 100) } VStack(spacing: 16) { ForEach(ecgModule.electrocardiograms) { electrocardiogram in @@ -34,16 +34,16 @@ struct ECGRecordingsList: View { .padding(.vertical) } .scrollBounceBehavior(.always) - } - .toolbar { - if AccountButton.shouldDisplay { - AccountButton(isPresented: $presentingAccount) + .toolbar { + if AccountButton.shouldDisplay { + AccountButton(isPresented: $presentingAccount) + } } - } - .navigationTitle(String(localized: "ECG Recordings")) - .refreshable { - try? await ecgModule.reloadECGs() - } + .navigationTitle(String(localized: "ECG Recordings")) + .refreshable { + try? await ecgModule.reloadECGs() + } + } } } diff --git a/PAWS/Helper/Firestore+User.swift b/PAWS/Helper/Firestore+User.swift index 9030236..70cda0b 100644 --- a/PAWS/Helper/Firestore+User.swift +++ b/PAWS/Helper/Firestore+User.swift @@ -13,6 +13,7 @@ import FirebaseFirestore extension FirebaseFirestore.Firestore { enum FirestoreError: Error { case userNotAuthenticatedYet + case userDetailsNotLoading } diff --git a/PAWS/Onboarding/AccountOnboarding.swift b/PAWS/Onboarding/AccountOnboarding.swift index f2a8d45..c5dd496 100644 --- a/PAWS/Onboarding/AccountOnboarding.swift +++ b/PAWS/Onboarding/AccountOnboarding.swift @@ -13,10 +13,14 @@ import SwiftUI struct AccountOnboarding: View { @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath + // periphery:ignore - Uses @AppStorage + @AppStorage(StorageKeys.healthKitStartDate) var healthKitStartDate: Date? var body: some View { - AccountSetup { _ in + AccountSetup { details in + healthKitStartDate = details.dateOfEnrollment + Task { // Placing the nextStep() call inside this task will ensure that the sheet dismiss animation is // played till the end before we navigate to the next step. diff --git a/PAWS/Onboarding/Consent.swift b/PAWS/Onboarding/Consent.swift index 8a01e4a..c19312d 100644 --- a/PAWS/Onboarding/Consent.swift +++ b/PAWS/Onboarding/Consent.swift @@ -13,8 +13,6 @@ import SwiftUI /// - Note: The `OnboardingConsentView` exports the signed consent form as PDF to the Spezi `Standard`, necessitating the conformance of the `Standard` to the `OnboardingConstraint`. struct Consent: View { @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath - // periphery:ignore - The periphery warning here is a false positive, the value us stored using @AppStorage. - @AppStorage(StorageKeys.healthKitStartDate) var healthKitStartDate: Date? private var consentDocument: Data { @@ -32,7 +30,6 @@ struct Consent: View { consentDocument }, action: { - healthKitStartDate = Date.now onboardingNavigationPath.nextStep() } ) diff --git a/PAWS/PAWSDelegate.swift b/PAWS/PAWSDelegate.swift index d28cfa6..3d7110c 100644 --- a/PAWS/PAWSDelegate.swift +++ b/PAWS/PAWSDelegate.swift @@ -29,14 +29,16 @@ class PAWSDelegate: SpeziAppDelegate { storeIn: Firestore.firestore().userCollectionReference, mapping: [ "DateOfBirthKey": AccountKeys.dateOfBirth, - "GenderIdentityKey": AccountKeys.genderIdentity + "GenderIdentityKey": AccountKeys.genderIdentity, + "dateOfEnrollment": AccountKeys.dateOfEnrollment ] ), configuration: [ .requires(\.userId), .requires(\.name), .requires(\.dateOfBirth), - .collects(\.genderIdentity) + .collects(\.genderIdentity), + .supports(\.dateOfEnrollment) ] ) firestore @@ -82,7 +84,7 @@ class PAWSDelegate: SpeziAppDelegate { private var healthKit: HealthKit { @AppStorage(StorageKeys.healthKitStartDate) var healthKitStartDate: Date = .now - // Collection starts at the time the user consents and lasts for 1 month. + // Collection starts at the time the user consents and lasts for 6 months. let sharedPredicate = HKQuery.predicateForSamples( withStart: healthKitStartDate, end: Calendar.current.date(byAdding: DateComponents(month: 6), to: healthKitStartDate), @@ -103,7 +105,7 @@ class PAWSDelegate: SpeziAppDelegate { CollectSample( HKQuantityType(.heartRate), predicate: sharedPredicate, - deliverySetting: .background(saveAnchor: true) + deliverySetting: .manual(safeAnchor: false) ) CollectSample( HKQuantityType(.vo2Max), diff --git a/PAWS/PAWSStandard.swift b/PAWS/PAWSStandard.swift index a427729..a078779 100644 --- a/PAWS/PAWSStandard.swift +++ b/PAWS/PAWSStandard.swift @@ -24,6 +24,8 @@ import SwiftUI actor PAWSStandard: Standard, EnvironmentAccessible, HealthKitConstraint, ConsentConstraint, AccountNotifyConstraint { // periphery:ignore - The ConfigureFirebaseApp injection is required to enforce an initialization within Spezi before this module. @Dependency(ConfigureFirebaseApp.self) private var firebaseConfiguration + // periphery:ignore - Uses @AppStorage + @AppStorage(StorageKeys.healthKitStartDate) var healthKitStartDate: Date? @Dependency(ECGModule.self) private var ecgStorage private let logger = Logger(subsystem: "PAWS", category: "Standard") @@ -65,11 +67,14 @@ actor PAWSStandard: Standard, EnvironmentAccessible, HealthKitConstraint, Consen switch event { case .deletingAccount: do { + healthKitStartDate = nil // delete all user associated data try await Firestore.firestore().userDocumentReference.delete() } catch { logger.error("Could not delete user document: \(error)") } + case .disassociatingAccount: + healthKitStartDate = nil default: break } diff --git a/PAWS/Resources/Localizable.xcstrings b/PAWS/Resources/Localizable.xcstrings index f9f81f5..2b1b333 100644 --- a/PAWS/Resources/Localizable.xcstrings +++ b/PAWS/Resources/Localizable.xcstrings @@ -182,17 +182,6 @@ }, "Close" : { - }, - "CLOSE" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close" - } - } - } }, "CONSENT_LOADING_ERROR" : { "localizations" : { @@ -217,35 +206,12 @@ } } }, - "CONTRIBUTIONS_LIST_DESCRIPTION" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The following list contains all Swift Package dependencies of the PAWS Application." - } - } - } - }, - "CONTRIBUTIONS_LIST_FOOTER" : { - "extractionState" : "stale", + "Date of Enrollment" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Please refer to the individual repository links for packages without license labels." - } - } - } - }, - "CONTRIBUTIONS_LIST_HEADER" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Packages" + "value" : "Date of Enrollment" } } } @@ -579,17 +545,6 @@ }, "Redeem Invitation Code" : { - }, - "Repository Link" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Repository Link" - } - } - } }, "SCOTT_CERESNAK_BIO" : { "localizations" : { diff --git a/PAWSUITests/DataManagementTests.swift b/PAWSUITests/DataManagementTests.swift index 90a15ed..25394db 100644 --- a/PAWSUITests/DataManagementTests.swift +++ b/PAWSUITests/DataManagementTests.swift @@ -22,11 +22,13 @@ final class DataManagementTests: XCTestCase { await app.deleteAndLaunch(withSpringboardAppName: "PAWS") } + @MainActor func testPullToRefresh() throws { let app = XCUIApplication() try app.navigateOnboardingFlow(email: "lelandstanford\(Int.random(in: 0...42000))@stanford.edu", code: "XKDYV3DF") - try self.exitAppAndOpenHealth(.electrocardiograms) + let healthApp = XCUIApplication.healthApp() + try launchAndAddSample(healthApp: healthApp, .electrocardiogram()) app.activate() let initialECGText = app.staticTexts["ECG Recording"] @@ -47,13 +49,13 @@ final class DataManagementTests: XCTestCase { // Now return to the Health app, and add some more ECGs before capturing a screenshot (for App Store). for _ in 0..<4 where UserDefaults.standard.bool(forKey: "FASTLANE_SNAPSHOT") { - try self.exitAppAndOpenHealth(.electrocardiograms) + try launchAndAddSample(healthApp: healthApp, .electrocardiogram()) } app.activate() Task { - await PAWSUITests.snapshot("2Home") + PAWSUITests.snapshot("2Home") } } } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 18a7200..eac4b91 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -65,6 +65,20 @@ platform :ios do end end + desc "CodeQL" + lane :codeql do + ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "320" # CodeQL runs on GitHub CI. We need much higher timeout here. + build_app( + skip_archive: true, + skip_codesigning: true, + derived_data_path: ".derivedData", + xcargs: [ + "-skipPackagePluginValidation", + "-skipMacroValidation" + ] + ) + end + desc "Build app" lane :build do build_app(