Skip to content

Commit fcf4ae6

Browse files
authored
Consent (#14)
1 parent 0fc5683 commit fcf4ae6

14 files changed

Lines changed: 449 additions & 166 deletions

File tree

MyHeartCounts.xcodeproj/project.pbxproj

Lines changed: 86 additions & 56 deletions
Large diffs are not rendered by default.

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

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

MyHeartCounts.xcodeproj/xcshareddata/xcschemes/MyHeartCounts.xcscheme

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@
2929
shouldUseLaunchSchemeArgsEnv = "YES">
3030
<TestPlans>
3131
<TestPlanReference
32-
reference = "container:MyHeartCounts UI Tests.xctestplan"
33-
default = "YES">
32+
reference = "container:MyHeartCounts UI Tests.xctestplan">
3433
</TestPlanReference>
3534
<TestPlanReference
36-
reference = "container:MyHeartCounts Unit Tests.xctestplan">
35+
reference = "container:MyHeartCounts Unit Tests.xctestplan"
36+
default = "YES">
3737
</TestPlanReference>
3838
</TestPlans>
3939
<Testables>

MyHeartCounts/Account/AccountSheet.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ struct AccountSheet: View {
101101
}
102102
}
103103
}
104+
Section {
105+
if let enrollment = enrollments.first, let study = enrollment.study {
106+
NavigationLink("Study Information") {
107+
StudyInfoView(study: study)
108+
}
109+
}
110+
NavigationLink("Review Consent Forms") {
111+
SignedConsentForms()
112+
}
113+
}
104114
Section {
105115
NavigationLink {
106116
ContributionsList(projectLicense: .mit)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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: 2025 Stanford University
5+
//
6+
// SPDX-License-Identifier: MIT
7+
//
8+
9+
// swiftlint:disable file_types_order
10+
11+
@preconcurrency import FirebaseStorage
12+
import Foundation
13+
import QuickLook
14+
import SpeziAccount
15+
import SpeziConsent
16+
import SpeziFoundation
17+
import SpeziViews
18+
import SwiftUI
19+
20+
21+
struct SignedConsentForms: View {
22+
@Environment(Account.self)
23+
private var account: Account?
24+
25+
private let storage = Storage.storage()
26+
@State private var files: [StorageReference] = []
27+
@State private var fileBeingFetched: StorageReference?
28+
@State private var presentedFile: URL?
29+
30+
var body: some View {
31+
Form {
32+
if account == nil {
33+
ContentUnavailableView("Not logged in", systemSymbol: .personSlash)
34+
} else {
35+
ForEach(files, id: \.self) { file in
36+
ConsentFileRow(file: file, fileBeingFetched: $fileBeingFetched, presentedFile: $presentedFile)
37+
.disabled(fileBeingFetched != nil && fileBeingFetched != file)
38+
}
39+
}
40+
}
41+
.navigationTitle("Consent Documents")
42+
.navigationBarTitleDisplayMode(.inline)
43+
.quickLookPreview($presentedFile)
44+
.task {
45+
await update()
46+
}
47+
.refreshable {
48+
Task {
49+
await update()
50+
}
51+
}
52+
.onChange(of: presentedFile) { old, new in
53+
if let old, new == nil {
54+
try? FileManager.default.removeItem(at: old)
55+
}
56+
}
57+
}
58+
59+
private func update() async {
60+
guard let accountId = account?.details?.accountId else {
61+
return
62+
}
63+
let folder = storage.reference(withPath: "users/\(accountId)/consent/")
64+
do {
65+
files = try await folder.listAll().items
66+
files.sort(using: KeyPathComparator(\.name, order: .reverse)) // filenames are unix timestamps, so this should work
67+
} catch {
68+
logger.error("Error fetching all consent files for user: \(error)")
69+
files = []
70+
}
71+
}
72+
}
73+
74+
75+
private struct ConsentFileRow: View {
76+
let file: StorageReference
77+
@Binding var fileBeingFetched: StorageReference?
78+
@Binding var presentedFile: URL?
79+
80+
@State private var viewState: ViewState = .idle
81+
@State private var storageRefCustomMetadata: [String: String] = [:]
82+
@State private var formMetadata: ConsentDocument.Metadata?
83+
84+
var body: some View {
85+
AsyncButton(state: $viewState) {
86+
fileBeingFetched = file
87+
defer {
88+
fileBeingFetched = nil
89+
}
90+
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, conformingTo: .pdf)
91+
_ = try await file.writeAsync(toFile: url)
92+
presentedFile = url
93+
} label: {
94+
buttonLabel
95+
}
96+
.buttonStyle(.plain)
97+
.task {
98+
do {
99+
storageRefCustomMetadata = try await file.getMetadata().customMetadata ?? [:]
100+
formMetadata = try storageRefCustomMetadata["consentFormMetadata"].map {
101+
try JSONDecoder().decode(ConsentDocument.Metadata.self, from: $0)
102+
}
103+
} catch {
104+
logger.error("Error fetching consent file metadata: \(error)")
105+
}
106+
}
107+
}
108+
109+
@ViewBuilder private var buttonLabel: some View {
110+
HStack {
111+
VStack(alignment: .leading) {
112+
if let version = formMetadata?.version {
113+
Text(version.description)
114+
.font(.subheadline)
115+
.foregroundStyle(.secondary)
116+
}
117+
if let title = formMetadata?.title {
118+
Text(title)
119+
.font(.headline)
120+
}
121+
}
122+
Spacer()
123+
if let date = storageRefCustomMetadata["date"].flatMap({ try? Date($0, strategy: MyHeartCountsStandard.consentDateFormat) }) {
124+
Text(date.formatted(date: .abbreviated, time: .shortened))
125+
.foregroundStyle(.secondary)
126+
}
127+
}
128+
}
129+
}

MyHeartCounts/Modules/StudyDefinitionLoader.swift

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,62 @@ final class StudyDefinitionLoader: Module, Sendable {
2727
// NOTE: the compiler thinks the nonisolated(unsafe) isn't needed here. this is a lie. see also https://github.com/swiftlang/swift/issues/81962
2828
nonisolated(unsafe) private(set) var studyDefinition: Result<StudyDefinition, LoadError>?
2929

30+
// SAFETY: this is only mutated from the MainActor.
31+
// NOTE: the compiler thinks the nonisolated(unsafe) isn't needed here. this is a lie. see also https://github.com/swiftlang/swift/issues/81962
32+
nonisolated(unsafe) private(set) var consentDocument: Result<String, LoadError>?
33+
3034
private init() {
3135
Task {
32-
_ = try await update()
36+
_ = try? await update()
3337
}
3438
}
3539

3640

3741
@discardableResult
3842
func load(fromBucket bucketName: String) async throws(LoadError) -> StudyDefinition {
39-
let url = Self.studyLocation(inBucket: bucketName)
40-
logger.debug("Fetching study definition from bucket '\(bucketName)'")
41-
logger.debug("Fetching study definition from '\(url.absoluteString)'")
42-
let retval: Result<StudyDefinition, LoadError>
43+
try await load(filename: "mhcStudyDefinition.json", inBucket: bucketName, storeTo: \StudyDefinitionLoader.studyDefinition) { data in
44+
try JSONDecoder().decode(StudyDefinition.self, from: data, configuration: .init(allowTrivialSchemaMigrations: true))
45+
}
46+
}
47+
48+
@discardableResult
49+
func update() async throws(LoadError) -> StudyDefinition {
50+
if let selector = FeatureFlags.overrideFirebaseConfig ?? LocalPreferencesStore.standard[.lastUsedFirebaseConfig],
51+
let firebaseOptions = try? DeferredConfigLoading.firebaseOptions(for: selector),
52+
let storageBucket = firebaseOptions.storageBucket {
53+
logger.notice("Attempting to load study definition from firebase storage bucket '\(storageBucket)'")
54+
let studyDefinition = try await load(fromBucket: storageBucket)
55+
_ = try? await loadConsent(fromBucket: storageBucket)
56+
return studyDefinition
57+
} else {
58+
logger.error("No last-used firebase config")
59+
throw .noLastUsedFirebaseConfig
60+
}
61+
}
62+
63+
func loadConsent(fromBucket bucketName: String) async throws(LoadError) -> String {
64+
try await load(filename: "Consent_en-US.md", inBucket: bucketName, storeTo: \StudyDefinitionLoader.consentDocument) { data in
65+
if let string = String(data: data, encoding: .utf8) {
66+
return string
67+
} else {
68+
throw NSError(domain: "edu.stanford.MHC", code: 0, userInfo: [
69+
NSLocalizedDescriptionKey: "Consent Text isn't UTF-8"
70+
])
71+
}
72+
}
73+
}
74+
75+
76+
@discardableResult
77+
private func load<T: Sendable>(
78+
filename: String,
79+
inBucket: String,
80+
storeTo dstKeyPath: (ReferenceWritableKeyPath<StudyDefinitionLoader, Result<T, LoadError>?> & Sendable)? = nil,
81+
decode: (Data) throws -> T
82+
) async throws(LoadError) -> T {
83+
let url = Self.url(ofFile: filename, inBucket: inBucket)
84+
logger.notice("will try to load from url '\(url.absoluteString)'")
85+
let retval: Result<T, LoadError>
4386
do {
4487
let session = URLSession(configuration: .ephemeral)
4588
let (data, response) = try await session.data(from: url)
@@ -51,48 +94,30 @@ final class StudyDefinitionLoader: Module, Sendable {
5194
switch response.statusCode {
5295
case 200:
5396
do {
54-
let definition = try JSONDecoder().decode(
55-
StudyDefinition.self,
56-
from: data,
57-
configuration: .init(allowTrivialSchemaMigrations: true)
58-
)
59-
retval = .success(definition)
60-
logger.notice("Successfully loaded study definition: '\(definition.metadata.title)' @ revision \(definition.studyRevision)")
97+
let value = try decode(data)
98+
retval = .success(value)
6199
} catch {
62100
retval = .failure(.unableToDecode(error))
63-
logger.error("Failed to decode study revision: \(error) from input '\(String(data: data, encoding: .utf8) ?? "<nil>")'")
64101
}
65102
case 404:
66103
throw NSError(domain: "edu.stanford.MHC", code: 0, userInfo: [
67-
NSLocalizedDescriptionKey: "Unable to find the Study Definition"
104+
NSLocalizedDescriptionKey: "Unable to find file '\(filename)' in bucket'\(inBucket)'"
68105
])
69106
default:
70107
throw NSError(domain: "edu.stanford.MHC", code: 0, userInfo: [
71-
NSLocalizedDescriptionKey: "Unable to fetch the Study Definition"
108+
NSLocalizedDescriptionKey: "Unable to fetch file '\(filename)' in bucket'\(inBucket)'"
72109
])
73110
}
74111
} catch {
75112
retval = .failure(.unableToFetchFromServer(error))
76-
logger.error("Failed to fetch study definjtion: \(error)")
77113
}
78-
Task { @MainActor in
79-
self.studyDefinition = retval
114+
if let dstKeyPath {
115+
Task { @MainActor in
116+
self[keyPath: dstKeyPath] = retval
117+
}
80118
}
81119
return try retval.get()
82120
}
83-
84-
@discardableResult
85-
func update() async throws(LoadError) -> StudyDefinition {
86-
if let selector = FeatureFlags.overrideFirebaseConfig ?? LocalPreferencesStore.standard[.lastUsedFirebaseConfig],
87-
let firebaseOptions = try? DeferredConfigLoading.firebaseOptions(for: selector),
88-
let storageBucket = firebaseOptions.storageBucket {
89-
logger.notice("Attempting to load study definition from firebase storage bucket '\(storageBucket)'")
90-
return try await load(fromBucket: storageBucket)
91-
} else {
92-
logger.error("No last-used firebase config")
93-
throw .noLastUsedFirebaseConfig
94-
}
95-
}
96121
}
97122

98123

@@ -101,7 +126,11 @@ extension StudyDefinitionLoader {
101126
if let url = LaunchOptions.launchOptions[.overrideStudyDefinitionLocation] {
102127
url
103128
} else {
104-
"https://firebasestorage.googleapis.com/v0/b/\(bucketName)/o/public%2FmhcStudyDefinition.json?alt=media"
129+
url(ofFile: "mhcStudyDefinition.json", inBucket: bucketName)
105130
}
106131
}
132+
133+
private static func url(ofFile filename: String, inBucket bucketName: String) -> URL {
134+
"https://firebasestorage.googleapis.com/v0/b/\(bucketName)/o/public%2F\(filename)?alt=media"
135+
}
107136
}

0 commit comments

Comments
 (0)