generated from StanfordSpezi/SpeziTemplateApplication
-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
LifeSpaceStandard.swift
309 lines (257 loc) · 10.9 KB
/
LifeSpaceStandard.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
//
// This source file is part of the LifeSpace based on the Stanford Spezi Template Application project
//
// SPDX-FileCopyrightText: 2023 Stanford University
//
// SPDX-License-Identifier: MIT
//
import CoreLocation
import FirebaseAuth
import FirebaseFirestore
import FirebaseStorage
import HealthKitOnFHIR
import OSLog
import PDFKit
import Spezi
@_spi(TestingSupport) import SpeziAccount
import SpeziFirebaseAccount
import SpeziFirebaseAccountStorage
import SpeziFirestore
import SpeziHealthKit
import SpeziOnboarding
import SpeziQuestionnaire
import SwiftUI
actor LifeSpaceStandard: Standard,
EnvironmentAccessible,
HealthKitConstraint,
OnboardingConstraint,
AccountNotifyConstraint {
enum LifeSpaceStandardError: Error {
case userNotAuthenticatedYet
case invalidStudyID
}
var studyID: String {
UserDefaults.standard.string(forKey: StorageKeys.studyID) ?? "unknownStudyID"
}
@Dependency(FirestoreAccountStorage.self) var accountStorage: FirestoreAccountStorage?
@Application(\.logger) private var logger
@Dependency(FirebaseConfiguration.self) private var configuration
init() {}
func respondToEvent(_ event: AccountNotifications.Event) async {
if case let .deletingAccount(accountId) = event {
do {
try await configuration.userDocumentReference(for: accountId).delete()
} catch {
logger.error("Could not delete user document: \(error)")
}
}
}
func add(sample: HKSample) async {
guard let userId = Auth.auth().currentUser?.uid else {
logger.error("User is not logged in.")
return
}
do {
let resource = try sample.resource
let encoder = FirebaseFirestore.Firestore.Encoder()
var dataDict = try encoder.encode(resource)
/// The `UpdatedBy` field is checked by the mHealth platform security rules
dataDict["UpdatedBy"] = userId
dataDict["studyID"] = studyID
try await healthKitDocument(id: sample.id).setData(dataDict)
} catch {
logger.error("Could not store HealthKit sample: \(error) Sample: \(sample.sampleType)")
}
}
func remove(sample: HKDeletedObject) async {
do {
try await healthKitDocument(id: sample.uuid).delete()
} catch {
logger.error("Could not remove HealthKit sample: \(error)")
}
}
func add(response: ModelsR4.QuestionnaireResponse) async {
let id = response.identifier?.value?.value?.string ?? UUID().uuidString
do {
try await configuration.userDocumentReference
.collection(Constants.surveyCollectionName)
.document(id)
.setData(from: response)
} catch {
logger.error("Could not store questionnaire response: \(error)")
}
}
func add(location: CLLocationCoordinate2D) async throws {
guard let userId = Auth.auth().currentUser?.uid else {
throw LifeSpaceStandardError.userNotAuthenticatedYet
}
guard let studyID = UserDefaults.standard.string(forKey: StorageKeys.studyID) else {
throw LifeSpaceStandardError.invalidStudyID
}
// Check that we only save points if location tracking is turned on
guard UserDefaults.standard.bool(forKey: StorageKeys.trackingPreference) else {
return
}
let dataPoint = LocationDataPoint(
currentDate: Date(),
time: Date().timeIntervalSince1970,
latitude: location.latitude,
longitude: location.longitude,
studyID: studyID,
UpdatedBy: userId
)
try await configuration.userDocumentReference
.collection(Constants.locationDataCollectionName)
.document(UUID().uuidString)
.setData(from: dataPoint)
}
func fetchLocations(on date: Date = Date()) async throws -> [CLLocationCoordinate2D] {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: date)
let endOfDay = Date(timeInterval: 24 * 60 * 60, since: startOfDay)
var locations = [CLLocationCoordinate2D]()
do {
let snapshot = try await configuration.userDocumentReference
.collection(Constants.locationDataCollectionName)
.whereField("currentDate", isGreaterThanOrEqualTo: startOfDay)
.whereField("currentDate", isLessThan: endOfDay)
.getDocuments()
for document in snapshot.documents {
if let longitude = document.data()["longitude"] as? CLLocationDegrees,
let latitude = document.data()["latitude"] as? CLLocationDegrees {
let coordinate = CLLocationCoordinate2D(
latitude: latitude,
longitude: longitude
)
locations.append(coordinate)
}
}
} catch {
self.logger.error("Error fetching location data: \(String(describing: error))")
throw error
}
return locations
}
func add(response: DailySurveyResponse) async throws {
guard let userId = Auth.auth().currentUser?.uid else {
throw LifeSpaceStandardError.userNotAuthenticatedYet
}
var response = response
response.timestamp = Date()
response.studyID = studyID
response.UpdatedBy = userId
try await configuration.userDocumentReference
.collection(Constants.surveyCollectionName)
.document(UUID().uuidString)
.setData(from: response)
// Update the user document with the latest survey date
try await configuration.userDocumentReference.setData(
[
"latestSurveyDate": response.surveyDate ?? ""
],
merge: true
)
}
func getLatestSurveyDate() async -> String {
let document = try? await configuration.userDocumentReference.getDocument()
if let data = document?.data(), let surveyDate = data["latestSurveyDate"] as? String {
// Update the latest survey date in UserDefaults
UserDefaults.standard.set(surveyDate, forKey: StorageKeys.lastSurveyDate)
return surveyDate
} else {
return ""
}
}
private func healthKitDocument(id uuid: UUID) async throws -> DocumentReference {
try await configuration.userDocumentReference
.collection(Constants.healthKitCollectionName) // Add all HealthKit sources in a /HealthKit collection.
.document(uuid.uuidString) // Set the document identifier to the UUID of the document.
}
func deletedAccount() async throws {
// delete all user associated data
do {
try await configuration.userDocumentReference.delete()
} catch {
logger.error("Could not delete user document: \(error)")
}
}
/// Stores the given consent form in the user's document directory with a unique timestamped filename.
///
/// - Parameter consent: The consent form's data to be stored as a `PDFDocument`.
func store(consent: PDFDocument) async {
guard !FeatureFlags.disableFirebase else {
guard let basePath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
logger.error("Could not create path for writing consent form to user document directory.")
return
}
let filePath = basePath.appending(path: "consentForm_\(studyID)_consent.pdf")
consent.write(to: filePath)
return
}
do {
guard let consentData = consent.dataRepresentation() else {
logger.error("Could not store consent form.")
return
}
let metadata = StorageMetadata()
metadata.contentType = "application/pdf"
_ = try await configuration.userBucketReference
.child("\(Constants.consentBucketName)/\(studyID)_consent.pdf")
.putDataAsync(consentData, metadata: metadata)
} catch {
logger.error("Could not store consent form: \(error)")
}
}
/// Stores the given consent form in the user's document directory and in the consent bucket in Firebase
///
/// - Parameter consentData: The consent form's data to be stored.
/// - Parameter name: The name of the consent document.
func store(consentData: Data, name: String) async {
/// Adds the study ID to the file name
let filename = "\(studyID)_\(name).pdf"
guard let docURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
logger.error("Could not create path for writing consent form to user document directory.")
return
}
let url = docURL.appendingPathComponent(filename)
do {
try consentData.write(to: url)
let metadata = StorageMetadata()
metadata.contentType = "application/pdf"
_ = try await configuration.userBucketReference
.child("\(Constants.consentBucketName)/\(filename)")
.putDataAsync(consentData, metadata: metadata)
} catch {
logger.error("Could not store consent form: \(error)")
}
}
func isConsentFormUploaded(name: String) async -> Bool {
do {
let maxSize: Int64 = 10 * 1024 * 1024
let data = try await configuration.userBucketReference
.child("\(Constants.consentBucketName)/\(studyID)_\(name).pdf")
.data(maxSize: maxSize)
if let docURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let filename = "\(studyID)_\(name).pdf"
let url = docURL.appendingPathComponent(filename)
try? data.write(to: url)
}
return true
} catch {
return false
}
}
/// Update the user document with the user's study ID
func setStudyID(_ studyID: String) async {
do {
try await configuration.userDocumentReference.setData(
[
"studyID": studyID
],
merge: true
)
} catch {
logger.error("Unable to set Study ID: \(error)")
}
}
}