From 73fc2e1088413f314a04bf9b8592e8c96a13fbf0 Mon Sep 17 00:00:00 2001 From: Eli Jones Date: Thu, 2 May 2024 09:06:42 -0400 Subject: [PATCH] [2.5.1] possible but unlikely 2.5.1 release. We now have one instance of survey downloading, active survey list state code is explained. there are a bunch of print statements that are still active but I don't care. --- Beiwe/Managers/StudyManager.swift | 512 +++++++++++------------ Beiwe/Models/GenericSurveyQuestion.swift | 109 ++++- 2 files changed, 344 insertions(+), 277 deletions(-) diff --git a/Beiwe/Managers/StudyManager.swift b/Beiwe/Managers/StudyManager.swift index 733ec35..cf0deea 100644 --- a/Beiwe/Managers/StudyManager.swift +++ b/Beiwe/Managers/StudyManager.swift @@ -58,13 +58,14 @@ class StudyManager { guard let study = self.currentStudy else { return } + // print("emit_survey_updates_save_study_data got called") self.surveysUpdatedEvent.emit(0) // what is this? Recline.shared.save(study) } - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - ///////////////////////////////////////////////////// Setup and UnSetup /////////////////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////// Setup and UnSetup //////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////// func loadDefaultStudy() { self.currentStudy = nil @@ -159,54 +160,10 @@ class StudyManager { self.sensorsStartedEver = true } - /// sets the study as consented, sets api credentials - // ok I have not tested whether removing the promise from this impacts registration... - func setConsented() { - // fail if current study or study settings are null - guard let study = currentStudy, let studySettings = study.studySettings else { - return - } - // api setup - self.setApiCredentials() - let currentTime: Int64 = Int64(Date().timeIntervalSince1970) - // kick off survey timers - study.nextUploadCheck = currentTime + Int64(studySettings.uploadDataFileFrequencySeconds) - study.nextSurveyCheck = currentTime + Int64(studySettings.checkForNewSurveysFreqSeconds) - // set consented to true (checked over in AppDelegate) - study.participantConsented = true - // some io stuff - DataStorageManager.sharedInstance.dataStorageManagerInit(study, secKeyRef: self.keyRef) - DataStorageManager.sharedInstance.ensureDirectoriesExist() - // update study stuff? - Recline.shared.save(study) - self.checkForNewSurveys() - } - - // FIXME: This function has 4 unacceptable failure modes -- called only from setConsented (study registration) and startStudyDataServices - /// Sets up the password (api) credential for backend calls - func setApiCredentials() { - // if there is no study.... don't do this. - guard let currentStudy: Study = self.currentStudy else { - return - } - // Setup APIManager's security - // Why is this EVER allowed to be the empty string? that's silent failure FOREVER - ApiManager.sharedInstance.password = PersistentPasswordManager.sharedInstance.passwordForStudy() ?? "" - ApiManager.sharedInstance.customApiUrl = currentStudy.customApiUrl - if let patientId = currentStudy.patientId { // again WHY is this even allowed to happen on a null participant id - ApiManager.sharedInstance.patientId = patientId - if let clientPublicKey = currentStudy.studySettings?.clientPublicKey { - do { - // failure means a null key - self.keyRef = try PersistentPasswordManager.sharedInstance.storePublicKeyForStudy(clientPublicKey, patientId: patientId) - } catch { - log.error("Failed to store RSA key in keychain.") // why are we not crashing... - } - } else { - log.error("No public key found. Can't store") // why are we not crashing... - } - } - } + //////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////// Survey Submission /////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////// + // TODO: this location for this code makes no sense, why is it here? /// takes a(n active) survey and creates the survey answers file func submitSurvey(_ activeSurvey: ActiveSurvey, surveyPresenter: TrackingSurveyPresenter? = nil) { @@ -236,25 +193,11 @@ class StudyManager { } } } - self.cleanupSurvey(activeSurvey) // call cleanup } - /// miniscule portion of finishing a survey answers file creation, finalizes file sorta; also called in audio surveys - func cleanupSurvey(_ activeSurvey: ActiveSurvey) { - // removeNotificationForSurvey(activeSurvey) // I don't know why we don't call this, but we don't. - if let surveyId = activeSurvey.survey?.surveyId { - let timingsName = TrackingSurveyPresenter.timingDataType + "_" + surveyId - // This should be fine. Survey Tracking files always write their output, and even if a - // file is never retired it will be found the next time the app opens. - // print("looking for a file named \(timingsName) but don't have enough information to find the DataStorage object") - // self.surveyTimingsFile?.reset() - // DataStorageManager.sharedInstance.closeStore(timingsName) - } - } - - /// - /// Survey checking logic - /// + //////////////////////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////// Active Survey State Logic ////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////// /// updates the list of surveys in the app ui based on the study timers, /// updates the badge count, submits completed surveys, and updates the relevant survey timer. @@ -264,110 +207,89 @@ class StudyManager { guard let study = currentStudy else { return } - + print("updateActiveSurveys, forceSave: \(forceSave)") // logic that refreshes survey list - let activeSurveysModified_1 = self.clear_out_submitted_surveys() - let activeSurveysModified_2 = self.ensure_active_surveys() - let activeSurveysModified_3 = self.removeOldSurveys() + let activeSurveysModified_1 = self.clear_out_completed_surveys() + let activeSurveysModified_2 = self.ensure_always_and_trigger_surveys() + let activeSurveysModified_3 = self.remove_deleted_surveys() self.updateBadgerCount() - // save survey data + // save survey data? if activeSurveysModified_1 || activeSurveysModified_2 || activeSurveysModified_3 || forceSave { self.emit_survey_updates_save_study_data() } } - // FIXME: there these two functions: updateActiveSurveys, setActiveSurveys - // Are diverged versions of related code, see fixme next to duplicated download surveys code - - /// sets the active survey on the main app page, force-enables any specified surveys. - func setActiveSurveys(surveyIds: [String], sentTime: TimeInterval = 0) { - guard let study = self.currentStudy else { - return - } - - // force reload all always-available surveys and any passed in surveys - for survey in study.surveys { - let surveyId = survey.surveyId!, from_notification = surveyIds.contains(surveyId) - - if from_notification || survey.alwaysAvailable { - let activeSurvey = ActiveSurvey(survey: survey) - // when we receive a notification we need to record that, this is used to sort - // surveys on the main screen (I think) - if from_notification { - activeSurvey.received = sentTime - } - study.activeSurveys[surveyId] = activeSurvey - } - } - - // if the survey id doesn't exist record a log statement - for surveyId in surveyIds { - if !study.surveyExists(surveyId: surveyId) { - print("Could not get survey \(surveyId)") - AppEventManager.sharedInstance.logAppEvent(event: "survey_download", msg: "Could not get obtain survey for ActiveSurvey") - } - } - - // this is two saves in close succession - // Emits a surveyUpdated event to the listener (_what_ listener - this is why signals are an antipattern) - StudyManager.sharedInstance.surveysUpdatedEvent.emit(0) - Recline.shared.save(study) - - // set badge number - UIApplication.shared.applicationIconBadgeNumber = study.activeSurveys.count - } - - func clear_out_submitted_surveys() -> Bool { + /// Removes completed surveys from study.activeSurveys, resets completed always-available + /// surveys, implements magic (terrible) logic for trigger-on-download surveys. + func clear_out_completed_surveys() -> Bool { guard let study = currentStudy else { return false } - + print("clear_out_completed_surveys") // For all active surveys that aren't complete, but have expired, submit them. (id is a string) var surveyDataModified = false + var surveys_to_remove = [String]() + // the reason for this != nill case has been lost to time. for activeSurvey in study.activeSurveys.values where activeSurvey.survey != nil { // case: always available survey - // reset the survey, behavior if we don't is the survey stays in the "done" stage you can't retake it. - // (It loads the survey to the done page, which will resave a new version of the data in a file.) - if activeSurvey.survey!.alwaysAvailable && activeSurvey.isComplete { - surveyDataModified = true - activeSurvey.reset(activeSurvey.survey) - } else if activeSurvey.isComplete { - // case normal survey, is complete - surveyDataModified = true - // why was this still here... This was supposed to be disabled in 2.4.9 but had to comment it out in 2.4.10. - // I guess this is what was causing the race condition bug in 2.4.9, but it also _wasn't_ causing - // the extra submitted survey files submitted bug in 2.4.9. This was very confusing. - // self.submitSurvey(activeSurvey) + // reset the survey, behavior if we don't is the survey stays in the "done" stage + // you can't retake it. (It loads the survey to the done page, which will resave a + // new version of the data in a file.) + // THIS IS ALSO THE OBSCURE LOCATION WHERE WE HANDLE A CRITICAL DETAIL OF TRIGGER + // ON FIRST DOWNLOAD SURVEYS. + if activeSurvey.isComplete { + if activeSurvey.survey!.alwaysAvailable { + print("clear_out_completed_surveys - resetting always available survey \(activeSurvey.survey!.surveyId!)") + activeSurvey.reset(activeSurvey.survey) + surveyDataModified = true + } else if !activeSurvey.survey!.triggerOnFirstDownload { + // case: a non-trigger, non-always-avaiilable survey, is complete, remove entirely. + // To make trigger on first download surveys not identical in behavior to always- + // available surveys we ... never remove them from the active surveys list. + // Yup, that's how we implement them. It's Terrible. + print("clear_out_completed_surveys - found non-AlwaysAvailable, non-triggered completed survey \(activeSurvey.survey!.surveyId!)") + surveys_to_remove.append(activeSurvey.survey!.surveyId!) + surveyDataModified = true + } } } - // the old code reset a survey timer by 1 week, but that's not even correct because absolute time and relative schedules exist. + // delete them (don't mutate the list you are iterating over) + for surveyId in surveys_to_remove { + study.activeSurveys.removeValue(forKey: surveyId) + } return surveyDataModified } - /// Checks the database for surveys that should exist, removes active surveys that are not in that list. - // FIXME: This does not do anything if surveys are not removed from the database when the app checks for new surveys. NEED TO TEST. - func removeOldSurveys() -> Bool { + /// Checks the database for surveys that should exist, removes active surveys that + /// are not in that list. + // FIXME: This does not do anything if surveys are not removed from the database + // when the app checks for new surveys. NEED TO TEST. + func remove_deleted_surveys() -> Bool { guard let study = self.currentStudy else { return false } + print("remove_deleted_surveys") var surveyDataModified = false - let allSurveyIds = self.getAllSurveyIds() // this is, in-fact, sourced from RecLine + let allSurveyIds = self.getAllSurveyIds() // from the database for (surveyId, activeSurvey) in study.activeSurveys { - if activeSurvey.isComplete && !allSurveyIds.contains(surveyId) { + // if the survey no longer exists in the database, remove it + if !allSurveyIds.contains(surveyId) { + print("remove_deleted_surveys - removing survey \(surveyId)") study.activeSurveys.removeValue(forKey: surveyId) surveyDataModified = true } } return surveyDataModified } - + /// Set the badger count - a count of untaken surveys, excluding always-available surveys. func updateBadgerCount() { guard let study = self.currentStudy else { return } + var bdgrCnt = 0 for activeSurvey in study.activeSurveys.values where activeSurvey.survey != nil { // if survey is not complete and the survey is not an always available survey @@ -375,77 +297,104 @@ class StudyManager { bdgrCnt += 1 } } - // print("Setting badge count to: \(bdgrCnt)") + // print("updateBadgerCount - Setting badge count to: \(bdgrCnt)") UIApplication.shared.applicationIconBadgeNumber = bdgrCnt } - /// changes from check_surveys_old - /// radically simplified equivalent logic - /// doesn't generate list of survey ids - /// we have no effective scheduling logic here ANYWAY - /// FIXME: there is no way this is not bugged even though the logic is equivalent to the old version, because always available and triggerOnFirstDownload are treated identically - func ensure_active_surveys() -> Bool { + /// 1) These ActiveSurveys in study.activeSurveys are _persistent objects_, they are restored + /// when the app is reopened. They are ~never removed. + /// 2) AlwaysAvailable surveys _do not get disabled_ over in `clear_out_completed_surveys`. + /// 3) triggerOnFirstDownload surveys don't get unloaded, they just don't get reactivated. + /// over in over in `clear_out_completed_surveys` + func ensure_always_and_trigger_surveys() -> Bool { guard let study = self.currentStudy else { return false } + print("ensure_always_and_trigger_surveys") var surveyDataModified = false - - // for each survey, check on its availability - for survey in study.surveys where survey.surveyId != nil { - // `study.activeSurveys[id] == nil` means the study is not activated... - // If so and the survey is a triggerOnFirstDownload or alwaysAvailable survey, add it to active surveys list - if study.activeSurveys[survey.surveyId!] == nil && (survey.triggerOnFirstDownload || survey.alwaysAvailable) { - print("Adding survey \(survey.name) to active surveys survey.triggerOnFirstDownload: \(survey.triggerOnFirstDownload), survey.alwaysAvailable: \(survey.alwaysAvailable)") - study.activeSurveys[survey.surveyId!] = ActiveSurvey(survey: survey) - surveyDataModified = true + // for each survey, check on its availability, insert (and forcibly overwrite data) + for survey in study.surveys { + // If a survey does not have a surveyId... I don't even know, but kill it + if let survey_id = survey.surveyId { + // we only care about trigger and always available surveys here + if !(survey.triggerOnFirstDownload || survey.alwaysAvailable) { + print("ensure_always_and_trigger_surveys - skipping survey '\(survey.name)', it is not trigger or always available.") + continue + } + + // study.activeSurveys has non-optional keys, nil means the key is not present. + // If it is in the list then it _might but may not be_ visible (I think). + let on_the_main_screen = study.activeSurveys[survey_id] != nil + + if !on_the_main_screen { + // case: new trigger or always available survey, add it. + print("ensure_always_and_trigger_surveys - adding survey '\(survey.name)'") + study.activeSurveys[survey_id] = ActiveSurvey(survey: survey) + surveyDataModified = true + } else { + // case: Survey is already loaded + if study.activeSurveys[survey_id]!.survey == survey { + // case: old survey was _not_ updated and was already present. + // Nothin to be done here, move on. + print("ensure_always_and_trigger_surveys - survey '\(survey.name)' is unchanged, skipping.") + continue + } + + // case: old survey was updated. (for future debugging clarity retin these two cases) + // This will cause any partially completed surveys to have state cleared. If the + // surveys are identical. A final else clause should be unreachable because we test + // for that at the top of the loop. + if survey.alwaysAvailable { + print("ensure_always_and_trigger_surveys - alwaysAvailable survey changed '\(survey.name)'.") + study.activeSurveys[survey_id] = ActiveSurvey(survey: survey) + surveyDataModified = true + } else if survey.triggerOnFirstDownload { + print("ensure_always_and_trigger_surveys - trigger survey changed, '\(survey.name)'.") + study.activeSurveys[survey_id] = ActiveSurvey(survey: survey) + surveyDataModified = true + } + } } } return surveyDataModified } - // to reduce calls to save there is a single save call in persistentTimerActions - // instead of one in each of these setnext functions. - - /// The Persistant timers, these get set and are checked even across app termination. - func setNextUploadTime() { - guard let study = currentStudy, let studySettings = study.studySettings else { - return + /// loads a list of surveys into active surveys so that they will be displayed. + func activate_surveys(surveyIds: [String], sentTime: TimeInterval) -> Bool { + guard let study = self.currentStudy else { + return false } - // if let t = study.nextUploadCheck { print("previous study.nextUploadCheck:", study.nextUploadCheck!, Date(timeIntervalSince1970: Double(t))) } - study.nextUploadCheck = Int64(Date().timeIntervalSince1970) + Int64(studySettings.uploadDataFileFrequencySeconds) - // if let t = study.nextUploadCheck { print("updated study.nextUploadCheck:", study.nextUploadCheck!, Date(timeIntervalSince1970: Double(t))) } - } - - func setNextSurveyTime() { - guard let study = currentStudy, let studySettings = study.studySettings else { - return + if surveyIds.count == 0 { + return false // if there are no surveys don't do anything } - // if let t = study.nextSurveyCheck { print("previous study.nextSurveyCheck:", study.nextSurveyCheck!, Date(timeIntervalSince1970: Double(t))) } - study.nextSurveyCheck = Int64(Date().timeIntervalSince1970) + Int64(studySettings.checkForNewSurveysFreqSeconds) - // if let t = study.nextSurveyCheck { print("updated study.nextSurveyCheck:", study.nextSurveyCheck!, Date(timeIntervalSince1970: Double(t))) } - } - - func setNextDeviceSettingsTime() { - guard let study = currentStudy else { - return + + // check every survey we have stored in the database against the passed in surveyIds + // (this function runs after the surveys have been updated in most cases, and the case + // of not having a survey but receiving that notification is virtually zero.) + // (this loop is weird, whatever.) + var updated_survey_state = false + for survey in study.surveys { + if surveyIds.contains(survey.surveyId!) { + let activeSurvey = ActiveSurvey(survey: survey) + activeSurvey.received = sentTime // used to sort surveys on the main screen. + // this action clears out the prior state of the survey. + study.activeSurveys[survey.surveyId!] = activeSurvey + updated_survey_state = true + } } - // if let t = study.nextDeviceSettingsCheck { print("previous study.nextDeviceSettingsCheck:", study.nextDeviceSettingsCheck!, Date(timeIntervalSince1970: Double(t))) } - study.nextDeviceSettingsCheck = Int64(Date().timeIntervalSince1970) + DEVICE_SETTINGS_INTERVAL - // if let t = study.nextDeviceSettingsCheck { print("updated study.nextDeviceSettingsCheck:", study.nextDeviceSettingsCheck!, Date(timeIntervalSince1970: Double(t))) } + + StudyManager.sharedInstance.surveysUpdatedEvent.emit(0) + Recline.shared.save(study) + return updated_survey_state } - ////////////////////////////////////////////////////////////////////////////////////////////////////////// - ////////////////////////////////////////// Network Operations //////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////////////////////////////////// - - /// some kind of reachability thing, calls periodicNetworkTransfers - @objc func reachabilityChanged(_ notification: Notification) { - print("Reachability changed, running periodic network transfers.") - self.timerManager.pollServices() - } + //////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////// Timer Checks ////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////// /// TODO: move this over to TimerManager + /// TODO: convert to store as doubles and use Dates in logic. /// These operations all happen to be network operations, and we want them to run /// as soon as a network connection is available, which means we need to handle /// the case of the app closing. @@ -495,42 +444,47 @@ class StudyManager { } Recline.shared.save(currentStudy) - - // currently disabling the complex logic here in favor of an easier strategy. - - // get the next timer - data type is weird. - // let earliest_timer: Int64 = minThatIsntZeroForStudyTimerDetermination( - // currentStudy.nextUploadCheck ?? 0, - // currentStudy.nextSurveyCheck ?? 0, - // currentStudy.nextDeviceSettingsCheck ?? 0 - // ) - - // // case: everything was zero - I think this can happen at registration. - // // case: the earliest timer is in the past - // if earliest_timer == 0 || earliest_timer < (now_int + 3000 { - // return default_interval_from_now() - // } else { - // // case: normal case, return a Date object of the time of the earliest timer - // return Date(timeIntervalSince1970: TimeInterval(earliest_timer)) - // } } - // assumes positive integers but the typing isn't UInts because it ... isn't. - // this works in our context.... probably.... - func minThatIsntZeroForStudyTimerDetermination(_ ints: Int64...) -> Int64 { - var min = Int64.max - /// get the smallest that isn't zero - for i in ints { - if i > 0 && i < min { - min = i - } + // to reduce calls to save there is a single save call in persistentTimerActions + // instead of one in each of these setnext functions. + + /// The Persistant timers, these get set and are checked even across app termination. + func setNextUploadTime() { + guard let study = currentStudy, let studySettings = study.studySettings else { + return } - // case: everything was zero (or int64.max, which won't be a thing - if min == Int64.max { - return 0 + // if let t = study.nextUploadCheck { print("previous study.nextUploadCheck:", study.nextUploadCheck!, Date(timeIntervalSince1970: Double(t))) } + study.nextUploadCheck = Int64(Date().timeIntervalSince1970) + Int64(studySettings.uploadDataFileFrequencySeconds) + // if let t = study.nextUploadCheck { print("updated study.nextUploadCheck:", study.nextUploadCheck!, Date(timeIntervalSince1970: Double(t))) } + } + + func setNextSurveyTime() { + guard let study = currentStudy, let studySettings = study.studySettings else { + return + } + // if let t = study.nextSurveyCheck { print("previous study.nextSurveyCheck:", study.nextSurveyCheck!, Date(timeIntervalSince1970: Double(t))) } + study.nextSurveyCheck = Int64(Date().timeIntervalSince1970) + Int64(studySettings.checkForNewSurveysFreqSeconds) + // if let t = study.nextSurveyCheck { print("updated study.nextSurveyCheck:", study.nextSurveyCheck!, Date(timeIntervalSince1970: Double(t))) } + } + + func setNextDeviceSettingsTime() { + guard let study = currentStudy else { + return } - // case: something wasn't zero - return min + // if let t = study.nextDeviceSettingsCheck { print("previous study.nextDeviceSettingsCheck:", study.nextDeviceSettingsCheck!, Date(timeIntervalSince1970: Double(t))) } + study.nextDeviceSettingsCheck = Int64(Date().timeIntervalSince1970) + DEVICE_SETTINGS_INTERVAL + // if let t = study.nextDeviceSettingsCheck { print("updated study.nextDeviceSettingsCheck:", study.nextDeviceSettingsCheck!, Date(timeIntervalSince1970: Double(t))) } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////// Network Operations Kinda ////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////// + + /// some kind of reachability thing, calls periodicNetworkTransfers + @objc func reachabilityChanged(_ notification: Notification) { + // print("Reachability changed, running periodic network transfers.") + self.timerManager.pollServices() } func heartbeat_on_dispatch_queue() { @@ -551,13 +505,17 @@ class StudyManager { ) } - /// called from self.setConsented, periodicNetworkTasks, and a debug menu button (I think) - /// THE RETURN VALUE IS NOT USED BECAUSE OF COURSE NOT - func checkForNewSurveys() { + /// Runs the api call for downloading survey data, and _then_ adds any provided surveys to + /// to self.activeSurveys. (Adding the new active surveys is guaranteed to run even if the + /// network request fails.) + /// This implementation is desireable because it isn't uncommon (especially when testing) + /// to update a survey and send the survey notification via the button on the Beiwe website. + /// Also it is just safer to 99% of the time have all the relevant survey info.) + func checkForNewSurveys(surveyIds: [String] = [], sentTime: TimeInterval = 0) { guard let study = currentStudy else { return } - print("inside duplicate survey checker function 1") + print("checkForNewSurveys") ApiManager.sharedInstance.makePostRequest( GetSurveysRequest(), completion_handler: { (response: DataResponse) in @@ -573,7 +531,6 @@ class StudyManager { // we have survey data! study.surveys = newSurveys Recline.shared.save(study) - self.updateActiveSurveys() // differs from other version of code. } else { error_message = "download surveys: \(response) - but body was nil" } @@ -592,61 +549,14 @@ class StudyManager { log.error(error_message) AppEventManager.sharedInstance.logAppEvent(event: "survey_download", msg: error_message) } - } - ) - } - - // FIXME: this code got duplicated and the diverged. we should only have one checksurveys function - // but instead we now have two. Both of these have been stripped of the promise architecture - // and should retain their basic unique details. - // downloadSurveys() was called from push notifications - // checkSurveys() was called from timers - // The todo here is merge them, which is non trivial because setActiveSurveys and updateActiveSurveys now do different things. - - /// there was a bunch of duplicated code, one version of the code was in appDelegate - func downloadSurveys(surveyIds: [String], sentTime: TimeInterval = 0) { - guard let study = self.currentStudy else { - return - } - print("inside duplicate survey checker function 2") - - // our logic requires those passed-in parameters, - ApiManager.sharedInstance.makePostRequest( - GetSurveysRequest(), completion_handler: { (response: DataResponse) in - var error_message = "" - switch response.result { - case .success: - if let statusCode = response.response?.statusCode { - // valid 200 range response - if statusCode >= 200 && statusCode < 300 { - let body_response = BodyResponse(body: response.result.value) - if let body_string = body_response.body { - if let newSurveys: [Survey] = Mapper().mapArray(JSONString: body_string) { - // we have survey data! - study.surveys = newSurveys - Recline.shared.save(study) - } else { - error_message = "download surveys: \(response) - but body was nil" - } - } - } else { // all non-200 error codes - error_message = "download surveys: statuscode: \(statusCode), value/body: \(String(describing: response.result.value))" - } - } else { // no error code? - error_message = "download surveys: no status code?" - } - case .failure: // general failure? - error_message = "download surveys - error: \(String(describing: response.error))" - } - // log app event if it couldn't hit the server - if error_message != "" { - log.error(error_message) - AppEventManager.sharedInstance.logAppEvent(event: "survey_download", msg: error_message) + // if received any surveys to this call + if !surveyIds.isEmpty { + self.activate_surveys(surveyIds: surveyIds, sentTime: sentTime) } // even if we didn't get new surveys we need to call setActiveSurveys with the survey ids - self.setActiveSurveys(surveyIds: surveyIds, sentTime: sentTime) + self.updateActiveSurveys() } ) } @@ -985,7 +895,7 @@ class StudyManager { } if error_message != "" { - print(error_message) + print("Upload error message: ", error_message) AppEventManager.sharedInstance.logAppEvent( event: "upload error", msg: error_message, d1: filename) } @@ -1099,6 +1009,58 @@ class StudyManager { // print("upload for \(filename) dispatched") } + //////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////// Registration //////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////////////// + + /// sets the study as consented, sets api credentials + func setConsented() { + // fail if current study or study settings are null + guard let study = currentStudy, let studySettings = study.studySettings else { + return + } + // api setup + self.setApiCredentials() + let currentTime: Int64 = Int64(Date().timeIntervalSince1970) + // kick off survey timers + study.nextUploadCheck = currentTime + Int64(studySettings.uploadDataFileFrequencySeconds) + study.nextSurveyCheck = currentTime + Int64(studySettings.checkForNewSurveysFreqSeconds) + // set consented to true (checked over in AppDelegate) + study.participantConsented = true + // some io stuff + DataStorageManager.sharedInstance.dataStorageManagerInit(study, secKeyRef: self.keyRef) + DataStorageManager.sharedInstance.ensureDirectoriesExist() + // update study stuff? + Recline.shared.save(study) + self.checkForNewSurveys() + } + + // FIXME: This function has 4 unacceptable failure modes -- called only from setConsented (study registration) and startStudyDataServices + /// Sets up the password (api) credential for backend calls + func setApiCredentials() { + // if there is no study.... don't do this. + guard let currentStudy: Study = self.currentStudy else { + return + } + // Setup APIManager's security + // Why is this EVER allowed to be the empty string? that's silent failure FOREVER + ApiManager.sharedInstance.password = PersistentPasswordManager.sharedInstance.passwordForStudy() ?? "" + ApiManager.sharedInstance.customApiUrl = currentStudy.customApiUrl + if let patientId = currentStudy.patientId { // again WHY is this even allowed to happen on a null participant id + ApiManager.sharedInstance.patientId = patientId + if let clientPublicKey = currentStudy.studySettings?.clientPublicKey { + do { + // failure means a null key + self.keyRef = try PersistentPasswordManager.sharedInstance.storePublicKeyForStudy(clientPublicKey, patientId: patientId) + } catch { + log.error("Failed to store RSA key in keychain.") // why are we not crashing... + } + } else { + log.error("No public key found. Can't store") // why are we not crashing... + } + } + } + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////// The Leave Study Code ////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/Beiwe/Models/GenericSurveyQuestion.swift b/Beiwe/Models/GenericSurveyQuestion.swift index 9380ad6..1a843df 100644 --- a/Beiwe/Models/GenericSurveyQuestion.swift +++ b/Beiwe/Models/GenericSurveyQuestion.swift @@ -20,7 +20,8 @@ enum TextFieldType: String { } // mappable (json-backed database object) of a question -struct GenericSurveyQuestion: Mappable { +struct GenericSurveyQuestion: Mappable, Equatable { + var questionId = "" var prompt: String? var questionType: SurveyQuestionType? // unclear why this is optional... @@ -28,7 +29,7 @@ struct GenericSurveyQuestion: Mappable { var minValue: Int? var selectionValues: [OneSelection] = [] var textFieldType: TextFieldType? - var displayIf: [String: AnyObject]? + var displayIf: [String: AnyObject]? // AnyObject looks like it can be a string, int, or nsarray var required = false init?(map: Map) {} @@ -46,4 +47,108 @@ struct GenericSurveyQuestion: Mappable { self.displayIf <- map["display_if"] self.required <- map["required"] // well that's interesting, when a key is missing it Doesn't Crash AND the default value is respected } + + static func == (lhs: GenericSurveyQuestion, rhs: GenericSurveyQuestion) -> Bool { + let questionId = lhs.questionId == rhs.questionId + let prompt = lhs.prompt == rhs.prompt + let questionType = lhs.questionType == rhs.questionType + let maxValue = lhs.maxValue == rhs.maxValue + let minValue = lhs.minValue == rhs.minValue + let selectionValues = lhs.selectionValues == rhs.selectionValues + let textFieldType = lhs.textFieldType == rhs.textFieldType + let displayIf = compare_dict_of_string_to_anyobject(lhs: lhs.displayIf, rhs: rhs.displayIf) + let required = lhs.required == rhs.required + + if !questionId { print(lhs.questionId, "!=", rhs.questionId) } + if !prompt { print(lhs.prompt, "!=", rhs.prompt) } + if !questionType { print(lhs.questionType, "!=", rhs.questionType) } + if !maxValue { print(lhs.maxValue, "!=", rhs.maxValue) } + if !minValue { print(lhs.minValue, "!=", rhs.minValue) } + if !selectionValues { print(lhs.selectionValues, "!=", rhs.selectionValues) } + if !textFieldType { print(lhs.textFieldType, "!=", rhs.textFieldType) } + if !displayIf { print(lhs.displayIf, "!=", rhs.displayIf) } + if !required { print(lhs.required, "!=", rhs.required) } + + return questionId && + prompt && + questionType && + maxValue && + minValue && + selectionValues && + textFieldType && + displayIf && + required + } +} + + +/// The next two functions appear to be sufficient for handling comparison of two [String: AnyObject]? +/// objects in the context of comparing logic on whether a question should be displayed. +func compare_dict_of_string_to_anyobject(lhs: [String: AnyObject]?, rhs: [String: AnyObject]?) -> Bool { + // print("starting compare of two string:anyobject dicts") + // defer { + // print("done compare of two string:anyobject dicts") + // } + if lhs == nil && rhs == nil { + // print("both were nil") + return true + } + if lhs == nil || rhs == nil { + // print("one was nil") + return false + } + return _compare_dict_of_string_to_anyobject(lhs: lhs!, rhs: rhs!) +} + +func _compare_dict_of_string_to_anyobject(lhs: [String: AnyObject], rhs: [String: AnyObject]) -> Bool { + // immediately return false if they are of different sizes. + if lhs.count != rhs.count { + // print("different sizes") + return false + } + + // the value for both cannot be nil, these are AnyObjects, not "AnyObject?"s + for (lhsKey, lhsValue) in lhs { + // check key is present in rhs, if not they are different, return false + if let _ = rhs[lhsKey] {} else { + // print("key '\(lhsKey)' not present in rhs") + return false + } + let rhsValue = rhs[lhsKey]! + + // if the values are not the same type, fail. + if type(of: lhsValue) != type(of: rhsValue) { + // print("lhsValue was a \(type(of: lhsValue)) and rhsValue was a \(type(of: rhsValue))") + return false + } + + // so now we know they are the same type but the swift compiler doesn't know that + // we only actually need to support strings and ints, I think + + if lhsValue is String && rhsValue is String { + if lhsValue as! String != rhsValue as! String { + // print("strings were different - '\(lhsValue)' != '\(rhsValue)'") + return false + } + continue + } + + if lhsValue is Int && rhsValue is Int { + if lhsValue as! Int != rhsValue as! Int { + // print("ints were different - '\(lhsValue)' != '\(rhsValue)'") + return false + } + continue + } + + if lhsValue is NSArray && rhsValue is NSArray { + if lhsValue as! NSArray != rhsValue as! NSArray { + // print("arrays were different - '\(lhsValue)' != '\(rhsValue)'") + return false + } + continue + } + fatalError("unhandled type in compare_dict_of_string_to_anyobject: lhsValue: \(type(of: lhsValue)) rhsValue: \(type(of: rhsValue))") + } + return true }