Skip to content

Commit d3eea0e

Browse files
authored
clinical record auth handling rework (#127)
# clinical record auth handling rework ## ♻️ Current situation & Problem see #123 for details ## ⚙️ Release Notes - attempts to fix clinical record authorization handling ## 📚 Documentation n/a ## ✅ Testing i wote a test case but i'm not sure how stable this'll be; we might have to remove it again ### Code of Conduct & Contributing Guidelines By creating and submitting this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md).
1 parent 81c0799 commit d3eea0e

16 files changed

Lines changed: 275 additions & 51 deletions

File tree

MyHeartCounts.xcodeproj/project.pbxproj

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
80713BE52D93F48C00A2DB0A /* SpeziStudy in Frameworks */ = {isa = PBXBuildFile; productRef = 80713BE42D93F48C00A2DB0A /* SpeziStudy */; };
8181
807229A72ED747BC00999926 /* SpeziFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 807229A62ED747BC00999926 /* SpeziFoundation */; };
8282
807229A92ED747BC00999926 /* SpeziLocalization in Frameworks */ = {isa = PBXBuildFile; productRef = 807229A82ED747BC00999926 /* SpeziLocalization */; };
83+
807423542F28E15E00113313 /* MHCStudyDefinitionExporter in Frameworks */ = {isa = PBXBuildFile; productRef = 807423532F28E15E00113313 /* MHCStudyDefinitionExporter */; };
8384
8076696E2D772C07001B980B /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 8076696D2D772C07001B980B /* Algorithms */; };
8485
807669712D772C40001B980B /* BitCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 807669702D772C40001B980B /* BitCollections */; };
8586
807669732D772C40001B980B /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 807669722D772C40001B980B /* Collections */; };
@@ -261,6 +262,7 @@
261262
8052B86E2EB9FFDF005E2D8C /* SpeziStudyDefinition in Frameworks */,
262263
8052B86D2EB9FFDF005E2D8C /* SpeziStudy in Frameworks */,
263264
8052B86C2EB9FFDF005E2D8C /* SpeziStudy in Frameworks */,
265+
807423542F28E15E00113313 /* MHCStudyDefinitionExporter in Frameworks */,
264266
8052B86B2EB9FFDF005E2D8C /* SpeziStudy in Frameworks */,
265267
8052B86A2EB9FFDF005E2D8C /* SpeziOnboarding in Frameworks */,
266268
800474692EF066E100585753 /* SpeziFirebaseAccount in Frameworks */,
@@ -532,6 +534,7 @@
532534
80ADAC5F2F0FEC9C0061DE0C /* SpeziSensorKit */,
533535
80E144762F1E4AF500FE2A8A /* SpeziStudy */,
534536
80E144782F1E4AF500FE2A8A /* SpeziStudyDefinition */,
537+
807423532F28E15E00113313 /* MHCStudyDefinitionExporter */,
535538
);
536539
productName = MyHeartCounts;
537540
productReference = 653A254D283387FE005D4D48 /* MyHeartCounts.app */;
@@ -972,6 +975,7 @@
972975
INFOPLIST_KEY_NSSensorKitPrivacyPolicyURL = "https://myheartcounts.su.domains/privacy-policy/";
973976
INFOPLIST_KEY_NSSensorKitUsageDescription = "Enabling SensorKit provides more accurate and in depth data for things like ECG to help advance cardiovascular research.";
974977
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "This message should never appear. Please adjust this when you start using speecg information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect.";
978+
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
975979
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
976980
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
977981
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
@@ -1030,6 +1034,7 @@
10301034
INFOPLIST_KEY_NSSensorKitPrivacyPolicyURL = "https://myheartcounts.su.domains/privacy-policy/";
10311035
INFOPLIST_KEY_NSSensorKitUsageDescription = "Enabling SensorKit provides more accurate and in depth data for things like ECG to help advance cardiovascular research.";
10321036
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "This message should never appear. Please adjust this when you start using speecg information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect.";
1037+
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
10331038
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
10341039
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
10351040
INFOPLIST_KEY_UIMyHeartCountslicationSceneManifest_Generation = YES;
@@ -1368,7 +1373,7 @@
13681373
repositoryURL = "https://github.com/StanfordBDHG/MyHeartCounts-StudyDefinitions.git";
13691374
requirement = {
13701375
kind = upToNextMajorVersion;
1371-
minimumVersion = 0.1.13;
1376+
minimumVersion = 0.1.14;
13721377
};
13731378
};
13741379
804C11E72ECA382D004783C3 /* XCRemoteSwiftPackageReference "SpeziScheduler" */ = {
@@ -1512,7 +1517,7 @@
15121517
repositoryURL = "https://github.com/StanfordSpezi/SpeziStudy.git";
15131518
requirement = {
15141519
kind = upToNextMajorVersion;
1515-
minimumVersion = 0.1.18;
1520+
minimumVersion = 0.1.19;
15161521
};
15171522
};
15181523
80E6B9322EFAC1960037D4BB /* XCRemoteSwiftPackageReference "SpeziHealthKit" */ = {
@@ -1825,6 +1830,11 @@
18251830
package = 807229A52ED747BC00999926 /* XCRemoteSwiftPackageReference "SpeziFoundation" */;
18261831
productName = SpeziLocalization;
18271832
};
1833+
807423532F28E15E00113313 /* MHCStudyDefinitionExporter */ = {
1834+
isa = XCSwiftPackageProductDependency;
1835+
package = 804C11E22ECA380D004783C3 /* XCRemoteSwiftPackageReference "MyHeartCounts-StudyDefinitions" */;
1836+
productName = MHCStudyDefinitionExporter;
1837+
};
18281838
8076696D2D772C07001B980B /* Algorithms */ = {
18291839
isa = XCSwiftPackageProductDependency;
18301840
package = 8076696C2D772C07001B980B /* XCRemoteSwiftPackageReference "swift-algorithms" */;

MyHeartCounts.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

MyHeartCounts/Account/AccountSheet.swift

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// SPDX-License-Identifier: MIT
77
//
88

9+
import FirebaseCore
910
import FirebaseFunctions
1011
import SFSafeSymbols
1112
import SpeziAccount
@@ -129,10 +130,19 @@ struct AccountSheet: View {
129130
Section {
130131
LabeledContent {
131132
let bundle = Bundle.main
132-
Text("\(bundle.appVersion) (\(bundle.appBuildNumber ?? -1))")
133+
Text(verbatim: "\(bundle.appVersion) (\(bundle.appBuildNumber ?? -1))")
133134
} label: {
134-
Label("My Heart Counts", systemSymbol: .infoCircle)
135-
.foregroundStyle(colorScheme.textLabelForegroundStyle)
135+
Label(symbol: .infoCircle) {
136+
VStack(alignment: .leading) {
137+
Text("My Heart Counts")
138+
if let firebaseProjectId = FirebaseApp.app()?.options.projectID {
139+
Text(firebaseProjectId)
140+
.font(.footnote)
141+
.foregroundStyle(.secondary)
142+
}
143+
}
144+
}
145+
.foregroundStyle(colorScheme.textLabelForegroundStyle)
136146
}
137147
NavigationLink {
138148
ContributionsList(projectLicense: .mit)

MyHeartCounts/Health Import/HistoricalHealthSamplesExportManager.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ final class HistoricalHealthSamplesExportManager: Module, EnvironmentAccessible,
115115
do {
116116
return try await bulkExporter.session(
117117
withId: .mhcHistoricalDataExport,
118-
for: study.allCollectedHealthData,
118+
// The bulk exporter does not ask for permission to read the samples, so it's safe to always pass in everything.
119+
for: study.allCollectedHealthData(includingOptionalSampleTypes: true),
119120
startDate: startDate,
120121
using: HealthKitSamplesFHIRUploader(standard: standard)
121122
)

MyHeartCounts/Heart Health Dashboard/HeartHealthDashboard.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ private struct HealthDashboardQuestionnaireView: View {
267267
QuestionnaireView(questionnaire: questionnaire) { result in
268268
switch result {
269269
case .completed(let response):
270-
await standard.add(response)
270+
await standard.add(response, for: questionnaire)
271271
case .cancelled, .failed:
272272
break
273273
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//
2+
// This source file is part of the My Heart Counts iOS application based on the Stanford Spezi Template Application project
3+
//
4+
// SPDX-FileCopyrightText: 2026 Stanford University
5+
//
6+
// SPDX-License-Identifier: MIT
7+
//
8+
9+
import Foundation
10+
import Spezi
11+
import SpeziFoundation
12+
import SpeziHealthKit
13+
import SpeziStudy
14+
15+
16+
@Observable
17+
@MainActor
18+
final class ClinicalRecordPermissions: Module, EnvironmentAccessible, Sendable {
19+
enum AuthorizationState: Hashable {
20+
/// The user already was prompted this, and made some decision to allow/deny.
21+
case decided
22+
/// The user was prompted but cancelled the authorization flow.
23+
case cancelled
24+
/// The user has not been prompted for clinical record access at all.
25+
case undetermined
26+
}
27+
28+
// swiftlint:disable attributes
29+
@ObservationIgnored @Dependency(HealthKit.self) private var healthKit
30+
@ObservationIgnored @Dependency(StudyBundleLoader.self) private var studyLoader
31+
@ObservationIgnored @Dependency(StudyManager.self) private var studyManager: StudyManager?
32+
33+
@ObservationIgnored @LocalPreference(.clinicalRecordAuthWasCancelledByUser)
34+
private var wasCancelledByUser
35+
// swiftlint:enable attributes
36+
37+
private(set) var authorizationState: AuthorizationState = .undetermined
38+
39+
func configure() {
40+
Task {
41+
_ = try? await studyLoader.update()
42+
await updateAuthorizationState()
43+
}
44+
}
45+
46+
func updateAuthorizationState() async {
47+
/// HealthKit's opinion on whether we already asked for access.
48+
let healthKitValue = await healthKit.didAskForAuthorization(for: dataAccessRequirements())
49+
switch (healthKitValue, wasCancelledByUser) {
50+
case (true, _):
51+
authorizationState = .decided
52+
wasCancelledByUser = false // just to make sure this is correct.
53+
case (false, true):
54+
authorizationState = .cancelled
55+
case (false, false):
56+
authorizationState = .undetermined
57+
}
58+
}
59+
60+
/// Prompts the user for authorization to access clinical record sample types, unless the user has cancelled a previous request.
61+
///
62+
/// Also triggers any automatic health data collection for such sample types.
63+
func askForAuthorization(askAgainIfCancelledPreviously: Bool) async throws {
64+
await updateAuthorizationState()
65+
switch authorizationState {
66+
case .decided:
67+
return
68+
case .cancelled:
69+
guard askAgainIfCancelledPreviously else {
70+
return
71+
}
72+
case .undetermined:
73+
break
74+
}
75+
do {
76+
try await healthKit.askForAuthorization(for: dataAccessRequirements())
77+
await updateAuthorizationState()
78+
try await studyManager?.updateHealthDataCollection()
79+
} catch {
80+
if let error = error as? HKError, error.code == .errorUserCanceled {
81+
wasCancelledByUser = true
82+
} else {
83+
throw error
84+
}
85+
}
86+
}
87+
88+
private func dataAccessRequirements() async -> HealthKit.DataAccessRequirements {
89+
HealthKit.DataAccessRequirements(read: await requestedRecordTypes().lazy.map(\.hkSampleType))
90+
}
91+
92+
private func requestedRecordTypes() async -> Set<SampleType<HKClinicalRecord>> {
93+
let imp = { (_ study: StudyDefinition) -> Set<SampleType<HKClinicalRecord>> in
94+
var types = Set<SampleType<HKClinicalRecord>>()
95+
for component in study.healthDataCollectionComponents {
96+
types.formUnion(component.sampleTypes.compactMap { $0 as? SampleType<HKClinicalRecord> })
97+
types.formUnion(component.optionalSampleTypes.compactMap { $0 as? SampleType<HKClinicalRecord> })
98+
}
99+
return types
100+
}
101+
if let enrollments = studyManager?.studyEnrollments {
102+
return enrollments.reduce(into: []) { types, enrollment in
103+
guard let studyDefinition = enrollment.studyBundle?.studyDefinition else {
104+
return
105+
}
106+
types.formUnion(imp(studyDefinition))
107+
}
108+
} else if let studyDefinition = try? studyLoader.studyBundle?.get().studyDefinition {
109+
return imp(studyDefinition)
110+
} else {
111+
return []
112+
}
113+
}
114+
}
115+
116+
117+
extension LocalPreferenceKeys {
118+
fileprivate static let clinicalRecordAuthWasCancelledByUser = LocalPreferenceKey(
119+
"clinicalRecordAuthWasCancelledByUser",
120+
default: false
121+
)
122+
}

MyHeartCounts/Modules/SetupTestEnvironment.swift

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ final class SetupTestEnvironment: Module, EnvironmentAccessible, Sendable {
4545
@ObservationIgnored @Dependency(FirebaseAccountService.self) private var accountService: FirebaseAccountService?
4646
@ObservationIgnored @Dependency(StudyBundleLoader.self) private var studyBundleLoader
4747
@ObservationIgnored @Dependency(HealthKit.self) private var healthKit
48+
@ObservationIgnored @Dependency(ClinicalRecordPermissions.self) private var clinicalRecordPermissions
4849
@ObservationIgnored @Dependency(BulkHealthExporter.self) private var bulkHealthExporter
4950
@ObservationIgnored @Dependency(ManagedFileUpload.self) private var fileUploader
5051
@ObservationIgnored @Dependency(LocalStorage.self) private var localStorage
@@ -55,6 +56,7 @@ final class SetupTestEnvironment: Module, EnvironmentAccessible, Sendable {
5556
@MainActor private(set) var isInSetup = false
5657

5758
private(set) var state: State
59+
private(set) var desc = ""
5860

5961
init() {
6062
state = if FeatureFlags.disableFirebase || config == .disabled {
@@ -93,9 +95,11 @@ final class SetupTestEnvironment: Module, EnvironmentAccessible, Sendable {
9395
isInSetup = false
9496
}
9597
if config.resetExistingData {
98+
desc = "\(#function) will reset existing data"
9699
try await resetExistingData()
97100
}
98101
if config.loginAndEnroll {
102+
desc = "\(#function) will loginAndEnroll"
99103
try await loginAndEnroll()
100104
}
101105
}
@@ -150,26 +154,36 @@ final class SetupTestEnvironment: Module, EnvironmentAccessible, Sendable {
150154
// an error occurred logging in to the test account, and it's not because the account doesn't exist.
151155
throw error
152156
}
157+
desc = "\(#function) will update study bundle loader"
153158
let studyBundle = try await studyBundleLoader.update()
154159
logger.notice("Enrolling test environment into study bundle")
155-
let accessReqs = MyHeartCountsStandard.baselineHealthAccessReqs
156-
.merging(with: .init(read: studyBundle.studyDefinition.allCollectedHealthData.filter(isNotKindOf: SampleType<HKClinicalRecord>.self)))
160+
let accessReqs = MyHeartCountsStandard.baselineHealthAccessReqs.merging(
161+
with: .init(read: studyBundle.studyDefinition.allCollectedHealthData(includingOptionalSampleTypes: true).exceptClinicalRecordTypes())
162+
)
163+
desc = "\(#function) will ask for regular HK auth"
157164
try await healthKit.askForAuthorization(for: accessReqs)
165+
desc = "\(#function) will enroll"
166+
try await standard.enroll(in: studyBundle)
158167
if HKHealthStore().supportsHealthRecords() {
168+
desc = "\(#function) will ask for clinical access"
159169
try await _Concurrency.Task.sleep(for: .seconds(1))
160-
try await healthKit.askForAuthorization(for: .init(read: studyBundle.studyDefinition.allCollectedHealthData.clinicalRecordTypes()))
170+
try await clinicalRecordPermissions.askForAuthorization(askAgainIfCancelledPreviously: false)
161171
}
162-
try await standard.enroll(in: studyBundle)
163172
LocalPreferencesStore.standard[.onboardingFlowComplete] = true
173+
desc = "\(#function) DONE"
164174
}
165175
}
166176

167177

168178
extension SampleTypesCollection {
169-
func clinicalRecordTypes() -> Self {
179+
func onlyClinicalRecordTypes() -> Self {
170180
filter(isKindOf: SampleType<HKClinicalRecord>.self)
171181
}
172182

183+
func exceptClinicalRecordTypes() -> Self {
184+
filter(isNotKindOf: SampleType<HKClinicalRecord>.self)
185+
}
186+
173187
func filter<Sample>(isKindOf _: SampleType<Sample>.Type) -> Self {
174188
Self(self.filter { $0 is SampleType<Sample> })
175189
}

MyHeartCounts/MyHeartCountsDelegate.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ final class MyHeartCountsDelegate: SpeziAppDelegate {
3232
SetupTestEnvironment()
3333
DeferredConfigLoading.initialAppLaunchConfig
3434
HealthKit()
35+
ClinicalRecordPermissions()
3536
Scheduler()
3637
Notifications()
3738
BulkHealthExporter()

MyHeartCounts/MyHeartCountsStandard+QuestionnaireResponse.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
//
88

99
@preconcurrency import FirebaseFirestore
10-
import class ModelsR4.QuestionnaireResponse
10+
import ModelsR4
1111
import MyHeartCountsShared
1212
import OSLog
1313
import Spezi
@@ -16,13 +16,19 @@ import SpeziHealthKit
1616

1717
extension MyHeartCountsStandard {
1818
// periphery:ignore:parameters isolation
19-
func add(isolation: isolated (any Actor)? = #isolation, _ response: ModelsR4.QuestionnaireResponse) async {
19+
func add(
20+
isolation: isolated (any Actor)? = #isolation,
21+
_ response: ModelsR4.QuestionnaireResponse,
22+
for questionnaire: ModelsR4.Questionnaire
23+
) async {
24+
// shouldn't be necessary, but we had some issues with these not being properly set
25+
response.questionnaire = questionnaire.url?.value?.url.absoluteString.asFHIRCanonicalPrimitive()
2026
let logger = await self.logger
2127
let id = response.identifier?.value?.value?.string ?? UUID().uuidString
2228
do {
2329
try await firebaseConfiguration.userDocumentReference
24-
.collection("questionnaireResponses") // Add all HealthKit sources in a /QuestionnaireResponse collection.
25-
.document(id) // Set the document identifier to the id of the response.
30+
.collection("questionnaireResponses")
31+
.document(id)
2632
.setData(from: response)
2733
} catch {
2834
logger.error("Could not store questionnaire response: \(error)")

0 commit comments

Comments
 (0)