-
Notifications
You must be signed in to change notification settings - Fork 6
/
ConsentController.swift
496 lines (409 loc) · 18.7 KB
/
ConsentController.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
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
//
// ConsentController.swift
// C3PRO
//
// Created by Pascal Pfiffner on 5/20/15.
// Copyright © 2015 Boston Children's Hospital. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SMART
import ResearchKit
/**
Callback used when signing the consent. Provides `Contract`, `Patient` and an optional `Error`.
*/
public typealias ConsentSigningCallback = ((Contract, Patient, Error?) -> Void)
/// Name of notification sent when the user completes and agrees to consent.
public let C3UserDidConsentNotification = NSNotification.Name(rawValue: "C3UserDidConsentNotification")
/// Name of notification sent when the user cancels or declines to consent.
public let C3UserDidDeclineConsentNotification = NSNotification.Name(rawValue:"C3UserDidDeclineConsentNotification")
/// User info dictionary key containing the consenting result in a `C3UserDidConsentNotification` notification.
public let C3ConsentResultKey = "consent-result"
/**
Struct to hold various options for consenting.
There are default values to all properties, so you only need to override what you want to change.
*/
public struct ConsentTaskOptions {
/// Whether or not the participant should be asked if she wants to share her data worldwide or only with the study researchers.
public var askForSharing = true
var shareTeamName = "the research team".c3_localized
/// Name of a bundled HTML file (without extension) that contains more information about data sharing.
public var shareMoreInfoDocument = "Consent_sharing"
/// Optional: name of a bundled HTML file (without extension) that contains the full consent document for review.
public var reviewConsentDocument: String? = nil
/// Shown when the user taps agree and she needs to confirm that she is in agreement.
public var reasonForConsent = "By agreeing you confirm that you read the consent and that you wish to take part in this research study.".c3_localized
/// Whether the user should be prompted to create a passcode/PIN after consenting.
public var askToCreatePasscode = true
/// Which system permissions the user should be asked to grant during consenting.
public var wantedServicePermissions: [SystemService]? = nil
public init() { }
}
/**
The consent controller helps using a FHIR `Contract` resource to capture consent.
The controller can read a bundled `Contract` resource and return view controllers that can be used for eligibility checking
(use `eligibilityStatusViewController(config:onStartConsent:)`) and/or consenting (use `consentViewController(onUserDidConsent:onUserDidDecline:)`).
*/
public class ConsentController {
/// The contract to be signed; if nil when signing, a new instance will be created.
public final var contract: Contract?
/// Options to consider for the consenting task.
public var options = ConsentTaskOptions()
var deidentifier: DeIdentifier?
var consentDelegate: ConsentTaskViewControllerDelegate?
/// The callback to call when the user consents.
var onUserDidConsent: ((ORKTaskViewController, ConsentResult) -> Void)?
/// The callback to call when the user declines (or aborts) consenting.
var onUserDidDeclineConsent: ((ORKTaskViewController) -> Void)?
/// Whether a PIN was present before; if not and consenting is cancelled, the PIN is cleared.
internal private(set) var pinPresentBefore = false
/// The logger to use, if any.
public var logger: OAuth2Logger?
/**
Designated initializer.
You can optionally supply the name of a bundled JSON file (without extension) that represents a serialized FHIR Contract resource. This
uses the main Bundle, so if you're calling the method from a different bundle (e.g. when unit testing), don't provide a filename and
assign `contract` manually by using `fhir_bundledResource(name:subdirectory:type:)`.
- parameter bundledContract: The filename (without ".json" of the Contract resource to read)
- parameter subdirectory: The subdirectory, if any, the Contract resource is located in
*/
public init(bundledContract: String? = nil, subdirectory: String? = nil) throws {
if let name = bundledContract {
contract = try Bundle.main.fhir_bundledResource(name, subdirectory: subdirectory, type: Contract.self)
}
}
// MARK: - Eligibility
/**
Instantiates a controller prompting the user to press “Start Eligibility”. Pressing that button pushes an EligibilityCheckViewController
onto the navigation stack, which carries the actual eligibility criteria.
- parameter config: An optional `StudyIntroConfiguration` instance that carries custom eligible/ineligible texts
- parameter onStartConsent: The block to execute when all eligibility criteria are met and the participant wants to start consent. Leave
nil to automatically present (and dismiss) the consent task view controller that will be returned by
`consentViewController()`.
*/
public func eligibilityStatusViewController(_ config: StudyIntroConfiguration? = nil, onStartConsent: ((EligibilityCheckViewController) -> Void)? = nil) -> EligibilityStatusViewController {
let check = EligibilityStatusViewController()
check.title = "Eligibility".c3_localized
check.titleText = "Let's see if you may take part in this study".c3_localized
check.subText = "Tap the button below to begin the eligibility process".c3_localized
check.actionButtonTitle = "Start Eligibility".c3_localized
// the actual eligibility check view controller; configure to present on check's navigation controller if no block is provided
let elig = EligibilityCheckViewController(style: .grouped)
if let onStartConsent = onStartConsent {
elig.onStartConsent = onStartConsent
}
else {
elig.onStartConsent = { viewController in
if let navi = viewController.navigationController {
do {
let consent = try self.consentViewController(
onUserDidConsent: { controller, result in
navi.dismiss(animated: true, completion: nil)
},
onUserDidDecline: { controller in
navi.popToRootViewController(animated: false)
navi.dismiss(animated: true, completion: nil)
}
)
navi.present(consent, animated: true, completion: nil)
}
catch let error {
c3_warn("failed to create consent view controller: \(error)")
}
}
else {
c3_warn("must embed eligibility status view controller in a navigation controller")
}
}
}
// apply configurations
if let config = config {
check.titleText = config.eligibleLetsCheckMessage ?? check.titleText
elig.eligibleTitle = config.eligibleTitle ?? elig.eligibleTitle
elig.eligibleMessage = config.eligibleMessage ?? elig.eligibleMessage
elig.ineligibleMessage = config.ineligibleMessage ?? elig.ineligibleMessage
}
// eligibility requirements
check.waitingForAction = true
eligibilityRequirements { requirements in
DispatchQueue.main.async {
elig.requirements = requirements
check.waitingForAction = false
}
}
check.onActionButtonTap = { controller in
if let navi = controller.navigationController {
navi.pushViewController(elig, animated: true)
}
else {
c3_warn("must embed eligibility status view controller in a navigation controller")
}
}
return check
}
/**
Resolves the contract's first subject to a Group. This Group is expected to have characteristics that represent eligibility criteria.
- parameter callback: The callback that is called when the group is resolved (or resolution fails); may be on any thread but may be
called immediately in case of embedded resources.
*/
public func eligibilityRequirements(callback: @escaping (([EligibilityRequirement]?) -> Void)) {
if let group = contract?.subject?.first {
group.resolve(Group.self) { group in
if let characteristics = group?.characteristic {
var criteria = [EligibilityRequirement]()
for characteristic in characteristics {
if let req = characteristic.c3_asEligibilityRequirement() {
criteria.append(req)
}
else {
c3_warn("this characteristic failed to return an eligibility requirement: \((try? characteristic.asJSON().description) ?? "nil")")
}
}
callback(criteria)
}
else {
c3_warn("failed to resolve the contract's subject group or there are no characteristics, hence no eligibility criteria")
callback(nil)
}
}
}
else {
logger?.debug("C3-PRO", msg: "the contract does not have a subject, hence no eligibility criteria")
callback(nil)
}
}
// MARK: - Consenting
/**
Creates the consent task from the receiver's contract.
- throws: `C3Error.ConsentContractNotPresent` when the contract is not present
- returns: A `ConsentTask` that can be presented using ResearchKit
*/
public func createConsentTask() throws -> ConsentTask {
guard let contract = contract else {
throw C3Error.consentContractNotPresent
}
return try ConsentTask(identifier: UUID().uuidString, contract: contract, options: options)
}
/**
A consent view controller, preconfigured with the consenting task, that can be presented to have the user go through consent.
You are given two blocks, one of them will be called when the user finishes or exits consenting, never both. They are deallocated after
either has been called.
- parameter onUserDidConsent: Block executed when the user completes and agrees to consent
- parameter onUserDidDecline: Block executed when the user cancels or actively declines consent
- throws: Re-throws from `createConsentTask()`
*/
public func consentViewController(onUserDidConsent onConsent: @escaping ((ORKTaskViewController, ConsentResult) -> Void),
onUserDidDecline: @escaping ((ORKTaskViewController) -> Void)) throws -> ORKTaskViewController {
if nil != onUserDidConsent {
c3_warn("a `onUserDidConsent` block is already set on \(self), are you already presenting a consent view controller? This might have unintended consequences.")
}
onUserDidConsent = onConsent
onUserDidDeclineConsent = onUserDidDecline
consentDelegate = ConsentTaskViewControllerDelegate(controller: self)
let task = try createConsentTask()
let consentVC = ORKTaskViewController(task: task, taskRun: UUID())
consentVC.delegate = consentDelegate!
pinPresentBefore = ORKPasscodeViewController.isPasscodeStoredInKeychain()
return consentVC
}
func userDidFinishConsent(_ taskViewController: ORKTaskViewController, reason: ORKTaskViewControllerFinishReason) {
if let task = taskViewController.task as? ConsentTask {
if .completed == reason {
let taskResult = taskViewController.result
// if we have a signature in the signature result, we're consented: create PDF and call the callbacks
if let signatureResult = task.signatureResult(from: taskResult), let signature = task.signature(in: signatureResult) {
let result = ConsentResult(signature: signature)
sign(consentDocument: task.consentDocument, with: signatureResult)
// sharing choice
if let sharingResult = taskResult.stepResult(forStepIdentifier: type(of: task).sharingStepName),
let sharing = sharingResult.results?.first as? ORKChoiceQuestionResult,
let choice = sharing.choiceAnswers?.first as? Int {
result.shareWidely = (0 == choice) // the first option, index 0, is "share worldwide"
}
else if options.askForSharing {
c3_warn("the sharing step has not returned the expected result, despite `options.askForSharing` being set to true")
}
userDidConsent(taskViewController, result: result)
}
else {
userDidDeclineConsent(taskViewController)
}
}
else if .discarded == reason {
userDidDeclineConsent(taskViewController)
}
else {
userDidDeclineConsent(taskViewController)
}
}
else {
c3_warn("user finished a consent that did not have a `ConsentTask` task; cannot handle, calling decline callback")
userDidDeclineConsent(taskViewController)
}
onUserDidConsent = nil
onUserDidDeclineConsent = nil
consentDelegate = nil
}
/**
Called when the user successfully completes the consent task and agrees to all the things.
*/
public func userDidConsent(_ taskViewController: ORKTaskViewController, result: ConsentResult) {
if let exec = onUserDidConsent {
exec(taskViewController, result)
}
let userInfo = [C3ConsentResultKey: result]
NotificationCenter.default.post(name: C3UserDidConsentNotification, object: self, userInfo: userInfo)
}
/**
Called when the user aborts consenting or actively declines consent.
*/
public func userDidDeclineConsent(_ taskViewController: ORKTaskViewController) {
if !pinPresentBefore {
ORKPasscodeViewController.removePasscodeFromKeychain()
}
if let exec = onUserDidDeclineConsent {
exec(taskViewController)
}
NotificationCenter.default.post(name: C3UserDidDeclineConsentNotification, object: self)
}
// MARK: - Consent Signing
/**
Instantiates a new "Contract" resource and fills the properties to represent a consent signed by a participant referencing the given
patient.
- parameter with: The Patient resource to use to sign
- parameter result: The date at which the contract was signed
- throws: `C3Error` when referencing the patient resource fails
- returns: A signed Contract resource, usually the receiver's `contract` ivar
*/
public func signContract(with patient: Patient, result: ConsentResult) throws -> Contract {
if nil == patient.id {
patient.id = FHIRString(UUID().uuidString)
}
do {
let reference = try patient.asRelativeReference()
let myContract = contract ?? Contract()
let date = result.consentDate ?? Date()
// applicable period
let period = Period()
period.start = date.fhir_asDateTime()
myContract.applies = period
// the participant/patient is the signer/consenter
let signer = ContractSigner()
signer.type = Coding()
signer.type!.display = "consenter"
signer.type!.code = "CONSENTER"
signer.type!.system = FHIRURL("http://hl7.org/fhir/ValueSet/contract-signer-type")
signer.party = reference
// extension to capture sharing intent
if let shareWidely = result.shareWidely {
let share = Extension(url: FHIRURL("http://fhir-registry.smarthealthit.org/StructureDefinition/consents-to-data-sharing")!)
share.valueBoolean = FHIRBool(shareWidely)
signer.extension_fhir = [share]
}
let signatureCode = Coding()
signatureCode.system = FHIRURL("http://hl7.org/fhir/ValueSet/signature-type")
signatureCode.code = "1.2.840.10065.1.12.1.7"
signatureCode.display = "Consent Signature"
let signature = Signature()
signature.type = [signatureCode]
signature.when = date.fhir_asInstant()
signature.whoReference = reference
signer.signature = [signature]
myContract.signer = [signer]
return myContract
}
catch let error {
throw C3Error.consentNoPatientReference(error)
}
}
/**
Reverse geocodes and de-identifies the patient, then uses the new Patient resource to sign the contract.
- parameter with: The Patient resource to use to sign
- parameter result: The result of the consenting task
- parameter callback: The callback to call when done or when signing failed
- throws: `C3Error` when referencing the patient resource fails
- returns: A signed Contract resource, usually the receiver's `contract` ivar
*/
public func deIdentifyAndSignContract(with patient: Patient, result: ConsentResult, callback: @escaping ConsentSigningCallback) {
deidentifier = DeIdentifier()
deidentifier!.hipaaCompliantPatient(patient: patient) { patient in
self.deidentifier = nil
do {
let contract = try self.signContract(with: patient, result: result)
callback(contract, patient, nil)
}
catch let error {
c3_warn("\(error)")
callback(Contract(), patient, error)
}
}
}
// MARK: - Consent PDF
/**
Asynchronously generates a consent PDF at `type(of: self).signedConsentPDFURL()`, containing the given signature.
- parameter consentDocument: The consent document to sign, usually the one used in our consent task
- parameter with: The signature to apply to the document
*/
func sign(consentDocument document: ORKConsentDocument, with signature: ORKConsentSignatureResult) {
logger?.debug("C3-PRO", msg: "Writing consent PDF")
signature.apply(to: document)
document.makePDF() { data, error in
if let data = data, let pdfURL = type(of: self).signedConsentPDFURL() {
do {
try data.write(to: pdfURL, options: .atomic)
self.logger?.debug("C3-PRO", msg: "Consent PDF written to \(pdfURL)")
}
catch let error {
c3_warn("failed to write consent PDF: \(error)")
}
}
else {
c3_warn("failed to write consent PDF: \(error?.localizedDescription ?? (nil == data ? "no data" : "no url"))")
}
}
}
/**
URL to the user-signed contract PDF.
- parameter mustExist: If `true` will return nil if no file exists at the expected file URL. If `false` will return the desired URL,
which is pretty sure to return non-nil, so use that exclamation mark!
*/
public class func signedConsentPDFURL(mustExist: Bool = false) -> URL? {
do {
let base = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let url = base.appendingPathComponent("consent-signed.pdf")
if !mustExist || FileManager().fileExists(atPath: url.path) {
return url
}
}
catch let err {
c3_warn("\(err)")
}
return nil
}
/**
The local URL to the bundled consent; looks for «consent.pdf» in the main bundle.
*/
public class func bundledConsentPDFURL() -> URL? {
return Bundle.main.url(forResource: "consent", withExtension: "pdf")
}
}
class ConsentTaskViewControllerDelegate: NSObject, ORKTaskViewControllerDelegate {
let controller: ConsentController
init(controller: ConsentController) {
self.controller = controller
}
func taskViewController(_ taskViewController: ORKTaskViewController, didFinishWith reason: ORKTaskViewControllerFinishReason, error: Error?) {
controller.userDidFinishConsent(taskViewController, reason: reason)
}
}