Пример #1
0
        /// <summary>
        /// Gets trigger times relative to a given time after some other time.
        /// </summary>
        /// <returns>Trigger times.</returns>
        /// <param name="reference">The reference time, from which the next time should be computed.</param>
        /// <param name="after">The time after which the trigger time should occur.</param>
        /// <param name="maxAge">Maximum age of the trigger.</param>
        public List <ScriptTriggerTime> GetTriggerTimes(DateTime reference, DateTime after, TimeSpan?maxAge = null)
        {
            lock (_windows)
            {
                // we used to use a yield-return approach for returning the trigger times; however, there's an issue:  the reference time does not
                // change, and if there are significant latencies involved in scheduling the returned trigger time then the notification time will
                // not accurately reflect the requested trigger reference. so, the better approach is to gather all triggers immediately to minimize
                // the effect of such latencies.
                List <ScriptTriggerTime> triggerTimes = new List <ScriptTriggerTime>();

                // return 7 triggers for each window
                for (int triggerNum = 0; triggerNum < 7; ++triggerNum)
                {
                    foreach (TriggerWindow window in _windows)
                    {
                        DateTime currTriggerAfter;

                        // if the window has a day-of-week specified, ignore the interval days field and go week-by-week instead
                        if (window.DayOfTheWeek.HasValue)
                        {
                            // how many days from the after DOW to the window's DOW?
                            int daysUntilWindowDOW = 0;

                            // if the after DOW (e.g., Tuesday) precedes the window's DOW (e.g., Thursday), the answer is simple (e.g., 2)
                            if (after.DayOfWeek < window.DayOfTheWeek.Value)
                            {
                                daysUntilWindowDOW = window.DayOfTheWeek.Value - after.DayOfWeek;
                            }
                            // if the after DOW (e.g., Wednesday) is after the window's DOW (e.g., Monday), we need to wrap around (e.g., 5)
                            else if (after.DayOfWeek > window.DayOfTheWeek.Value)
                            {
                                // number of days until saturday + number of days from saturday to window's DOW
                                daysUntilWindowDOW = (DayOfWeek.Saturday - after.DayOfWeek) + (int)window.DayOfTheWeek.Value + 1;
                            }

                            // each DOW-based window is separated by a week
                            currTriggerAfter = after.AddDays(triggerNum * 7 + daysUntilWindowDOW);

                            // ensure that the trigger time is not shifted to the next day by removing the time component (i.e., setting is to 12:00am). this
                            // ensures that any window time (e.g., 1am) will be feasible.
                            currTriggerAfter = currTriggerAfter.Date;
                        }
                        else
                        {
                            // the window is interval-based, so skip ahead the current number of days
                            currTriggerAfter = after.AddDays(triggerNum * _nonDowTriggerIntervalDays);
                        }

                        ScriptTriggerTime triggerTime = window.GetNextTriggerTime(reference, currTriggerAfter, WindowExpiration, maxAge);

                        triggerTimes.Add(triggerTime);
                    }
                }

                // it is important that these are ordered otherwise we might skip windows since we use the _maxScheduledDate to determine which schedule comes next.
                triggerTimes.Sort((x, y) => x.Trigger.CompareTo(y.Trigger));

                return(triggerTimes);
            }
        }
Пример #2
0
        private void ScheduleScriptRun(ScriptTriggerTime triggerTime)
        {
            // don't bother with the script if it's coming too soon.
            if (triggerTime.ReferenceTillTrigger.TotalMinutes <= 1)
            {
                return;
            }

            ScheduledCallback callback = CreateScriptRunCallback(triggerTime);

            // there is a race condition, so far only seen in ios, in which multiple script runner notifications
            // accumulate and are executed concurrently when the user opens the app. when these script runners
            // execute they add their scripts to the pending scripts collection and concurrently attempt to
            // schedule all future scripts. because two such attempts are made concurrently, they may race to
            // schedule the same future script. each script callback id is functional in the sense that it is
            // a string denoting the script to run and the time window to run within. thus, the callback ids can
            // duplicate. the callback scheduler checks for such duplicate ids and will return unscheduled on the next
            // line when a duplicate is detected. in the case of a duplicate we can simply abort scheduling the
            // script run since it was already scheduled. this issue is much less common in android because all
            // scripts are run immediately in the background, producing little opportunity for the race condition.
            if (SensusContext.Current.CallbackScheduler.ScheduleCallback(callback) == ScheduledCallbackState.Scheduled)
            {
                lock (_scriptRunCallbacks)
                {
                    _scriptRunCallbacks.Add(callback);
                }

                SensusServiceHelper.Get().Logger.Log($"Scheduled for {triggerTime.Trigger} ({callback.Id})", LoggingLevel.Normal, GetType());

                _maxScheduledDate = _maxScheduledDate.Max(triggerTime.Trigger);
            }
        }
Пример #3
0
        /// <summary>
        /// Gets trigger times starting on a particular date and having a maximum age until expiration.
        /// </summary>
        /// <returns>Trigger times.</returns>
        /// <param name="startDate">The date on which the scheduled triggers should start. Only the year,
        /// month, and day elements will be considered.</param>
        /// <param name="maxAge">Maximum age of the triggers, during which they should be valid.</param>
        public List <ScriptTriggerTime> GetTriggerTimes(DateTime startDate, TimeSpan?maxAge = null)
        {
            lock (_windows)
            {
                // we used to use a yield-return approach for returning the trigger times; however, there's an issue:  the reference time does not
                // change, and if there are significant latencies involved in scheduling the returned trigger time then the notification time will
                // not accurately reflect the requested trigger reference. so, the better approach is to gather all triggers immediately to minimize
                // the effect of such latencies.
                List <ScriptTriggerTime> triggerTimes = new List <ScriptTriggerTime>();

                // ignore the time component of the start date. get all times on the given day.
                startDate = new DateTime(startDate.Year, startDate.Month, startDate.Day, 0, 0, 0);

                // pull enough days to ensure that all windows get at least one trigger. for DOW windows, this
                // means that we must schedule enough days to cover all days of the week (7 will suffice).  for
                // time-of-day-winows, this means that we must schedule at least the number of days specified in
                // the interval. the reason this is important is that, if the number of days that we pull does not
                // include any trigger windows, then no surveys will be scheduled and we run the risk of losing
                // touch with the user. the health test callback should ensure that survey triggers continue to
                // be scheduled, so it should not be the case that we lose the user with certainty. however, on
                // ios it is more likely that the user will ignore surveys without bringing the app to the foreground
                // and giving an opportunity for the health test to schedule additional surveys.
                int numDays = Math.Max(7, _nonDowTriggerIntervalDays + 1);  // super tricky corner case:  if the interval is greater than 7 days and the current day matches the interval check below, but the current time follows the window, then the current day won't be scheduled nor will any other. so add a day to the interval so that two days will match.

                for (int dayOffset = 0; dayOffset < numDays; ++dayOffset)
                {
                    DateTime  triggerDate    = startDate.AddDays(dayOffset);
                    DayOfWeek triggerDateDOW = triggerDate.DayOfWeek;

                    // schedule each window for the current date as necessary
                    foreach (TriggerWindow window in _windows)
                    {
                        bool scheduleWindowForCurrentDate = false;

                        if (window.DayOfTheWeek.HasValue)
                        {
                            if (window.DayOfTheWeek.Value == triggerDateDOW)
                            {
                                scheduleWindowForCurrentDate = true;
                            }
                        }
                        // we need a reference point for calculating the day-based interval. the minimum value will work.
                        else if ((triggerDate - DateTime.MinValue).Days % _nonDowTriggerIntervalDays == 0)
                        {
                            scheduleWindowForCurrentDate = true;
                        }

                        if (scheduleWindowForCurrentDate)
                        {
                            ScriptTriggerTime triggerTime = window.GetNextTriggerTime(triggerDate, WindowExpiration, maxAge);
                            triggerTimes.Add(triggerTime);
                        }
                    }
                }

                triggerTimes.Sort((x, y) => x.Trigger.CompareTo(y.Trigger));

                return(triggerTimes);
            }
        }
Пример #4
0
        public Script Copy(bool newId, ScriptTriggerTime triggerTime)
        {
            Script copy = Copy(newId);

            copy.ExpirationDate   = triggerTime.Expiration;
            copy.ScheduledRunTime = triggerTime.Trigger;

            return(copy);
        }
Пример #5
0
        private ScheduledCallback CreateScriptRunCallback(ScriptTriggerTime triggerTime)
        {
            Script scriptToRun = Script.Copy(true);

            scriptToRun.ExpirationDate   = triggerTime.Expiration;
            scriptToRun.ScheduledRunTime = triggerTime.Trigger;

            ScheduledCallback callback = new ScheduledCallback((callbackId, cancellationToken, letDeviceSleepCallback) =>
            {
                return(Task.Run(() =>
                {
                    SensusServiceHelper.Get().Logger.Log($"Running script on callback ({callbackId})", LoggingLevel.Normal, GetType());

                    if (!Probe.Running || !_enabled)
                    {
                        return;
                    }

                    Run(scriptToRun);

                    lock (_scriptRunCallbacks)
                    {
                        _scriptRunCallbacks.RemoveAll(c => c.Id == callbackId);
                    }

                    // on android, the callback alarm has fired and the script has been run. on ios, the notification has been
                    // delivered (1) either to the app in the foreground or (2) to the notification tray where the user has opened
                    // it -- either way on ios the app is in the foreground and the script has been run. now is a good time to update
                    // the scheduled callbacks to run this script.
                    ScheduleScriptRuns();
                }, cancellationToken));

                // Be careful to use Script.Id rather than script.Id for the callback domain. Using the former means that callbacks are tied to the script runner and not the script copies (the latter) that we will be running. The latter would always be unique.
            }, triggerTime.ReferenceTillTrigger, GetType().FullName + "-" + ((long)(triggerTime.Trigger - DateTime.MinValue).TotalDays) + "-" + triggerTime.Window, Script.Id, Probe.Protocol);

#if __IOS__
            // all scheduled scripts with an expiration should show an expiration date to the user. on iOS this will be the only notification for
            // scheduled surveys, since we don't have a way to update the "you have X pending surveys" notification (generated by triggered
            // surveys) without executing code in the background.
            if (scriptToRun.ExpirationDate.HasValue)
            {
                callback.UserNotificationMessage = "Survey expires on " + scriptToRun.ExpirationDate.Value.ToShortDateString() + " at " + scriptToRun.ExpirationDate.Value.ToShortTimeString() + ".";
            }
            // on iOS, even if we don't have an expiration date we should show some additional notification, again because we don't have a way
            // to update the "you have X pending surveys" notification from the background.
            else
            {
                callback.UserNotificationMessage = "Please open to take survey.";
            }

            callback.DisplayPage = DisplayPage.PendingSurveys;
#endif

            return(callback);
        }
Пример #6
0
        /// <summary>
        /// Creates the script run callback.
        /// </summary>
        /// <returns>The script run callback.</returns>
        /// <param name="triggerTime">Trigger time.</param>
        /// <param name="scriptId">Script identifier. If null, then a random identifier will be generated for the script that will be run.</param>
        private ScheduledCallback CreateScriptRunCallback(ScriptTriggerTime triggerTime, string scriptId = null)
        {
            Script scriptToRun = Script.Copy(true);

            scriptToRun.ExpirationDate   = triggerTime.Expiration;
            scriptToRun.ScheduledRunTime = triggerTime.Trigger;

            // if we're passed a run ID, then override the random one that was generated above in the call to Script.Copy.
            if (scriptId != null)
            {
                scriptToRun.Id = scriptId;
            }

            ScheduledCallback callback = new ScheduledCallback(async cancellationToken =>
            {
                SensusServiceHelper.Get().Logger.Log("Running script \"" + Name + "\".", LoggingLevel.Normal, GetType());

                if (Probe.State != ProbeState.Running || !_enabled)
                {
                    return;
                }

                await RunAsync(scriptToRun);

                // on android, the callback alarm has fired and the script has been run. on ios, the notification has been
                // delivered (1) to the app in the foreground, (2) to the notification tray where the user has opened
                // it, or (3) via push notification in the background. in any case, the script has been run. now is a good
                // time to update the scheduled callbacks to run this script.
                await ScheduleScriptRunsAsync();
            }, triggerTime.TimeTillTrigger, Script.Id + "." + GetType().FullName + "." + (triggerTime.Trigger - DateTime.MinValue).Days + "." + triggerTime.Window, Probe.Protocol.Id, Probe.Protocol, null, TimeSpan.FromMilliseconds(DelayToleranceBeforeMS), TimeSpan.FromMilliseconds(DelayToleranceAfterMS));  // use Script.Id rather than script.Id for the callback identifier. using the former means that callbacks are unique to the script runner and not the script copies (the latter) that we will be running. the latter would always be unique.

#if __IOS__
            // all scheduled scripts with an expiration should show an expiration date to the user. on iOS this will be the only notification for
            // scheduled surveys, since we don't have a way to update the "you have X pending surveys" notification (generated by triggered
            // surveys) without executing code in the background.
            if (scriptToRun.ExpirationDate.HasValue)
            {
                callback.UserNotificationMessage = "Survey expires on " + scriptToRun.ExpirationDate.Value.ToShortDateString() + " at " + scriptToRun.ExpirationDate.Value.ToShortTimeString() + ".";
            }
            // on iOS, even if we don't have an expiration date we should show some additional notification, again because we don't have a way
            // to update the "you have X pending surveys" notification from the background.
            else
            {
                callback.UserNotificationMessage = "Please open to take survey.";
            }

            callback.NotificationUserResponseAction = NotificationUserResponseAction.DisplayPendingSurveys;
#endif

            return(callback);
        }
Пример #7
0
        private void ScheduleScriptRun(ScriptTriggerTime triggerTime)
        {
            // don't bother with the script if it's coming too soon.
            if (triggerTime.ReferenceTillTrigger <= TimeSpan.FromMinutes(1))
            {
                return;
            }

            lock (_scheduledCallbackIds)
            {
                var callback = CreateCallback(new Script(Script, Guid.NewGuid())
                {
                    ExpirationDate = triggerTime.Expiration, ScheduledRunTime = triggerTime.Trigger
                });
                var callbackId = SensusContext.Current.CallbackScheduler.ScheduleOneTimeCallback(callback, (int)triggerTime.ReferenceTillTrigger.TotalMilliseconds);
                _scheduledCallbackIds.Add(callbackId);
                SensusServiceHelper.Get().Logger.Log($"Scheduled for {triggerTime.Trigger} ({callbackId})", LoggingLevel.Normal, GetType());
            }

            _maxScheduledDate = _maxScheduledDate.Max(triggerTime.Trigger);
        }
Пример #8
0
        public async Task RunAsync(Script script, Datum previousDatum = null, Datum currentDatum = null)
        {
            SensusServiceHelper.Get().Logger.Log($"Running \"{Name}\".", LoggingLevel.Normal, GetType());

            script.RunTime = DateTimeOffset.UtcNow;

            // this method can be called with previous / current datum values (e.g., when the script is first triggered). it
            // can also be called without previous / current datum values (e.g., when triggering on a schedule). if
            // we have such values, set them on the script.

            if (previousDatum != null)
            {
                script.PreviousDatum = previousDatum;
            }

            if (currentDatum != null)
            {
                script.CurrentDatum = currentDatum;
            }

            // scheduled scripts have their expiration dates set when they're scheduled. scripts triggered by other probes
            // as well as on-start scripts will not yet have their expiration dates set. so check the script we've been
            // given and set the expiration date if needed. triggered scripts don't have windows, so the only expiration
            // condition comes from the maximum age.
            if (script.ExpirationDate == null && _maxAge.HasValue)
            {
                script.ExpirationDate = script.Birthdate + _maxAge.Value;
            }

            // script could have already expired (e.g., if user took too long to open notification).
            if (script.ExpirationDate.HasValue && script.ExpirationDate.Value < DateTime.Now)
            {
                SensusServiceHelper.Get().Logger.Log("Script expired before it was run.", LoggingLevel.Normal, GetType());
                return;
            }

            // do not run a one-shot script if it has already been run
            if (OneShot && RunTimes.Count > 0)
            {
                SensusServiceHelper.Get().Logger.Log("Not running one-shot script multiple times.", LoggingLevel.Normal, GetType());
                return;
            }

            // check with the survey agent if there is one
            if (Probe.Agent != null)
            {
                Tuple <bool, DateTimeOffset?> deliverFutureTime = await Probe.Agent.DeliverSurveyNowAsync(script);

                if (deliverFutureTime.Item1)
                {
                    Probe.Protocol.LocalDataStore.WriteDatum(new ScriptStateDatum(ScriptState.AgentAccepted, script.RunTime.Value, script), CancellationToken.None);
                }
                else
                {
                    if (deliverFutureTime.Item2 == null)
                    {
                        SensusServiceHelper.Get().Logger.Log("Agent has declined survey without deferral.", LoggingLevel.Normal, GetType());

                        Probe.Protocol.LocalDataStore.WriteDatum(new ScriptStateDatum(ScriptState.AgentDeclined, script.RunTime.Value, script), CancellationToken.None);
                    }
                    else if (deliverFutureTime.Item2.Value > DateTimeOffset.UtcNow)
                    {
                        SensusServiceHelper.Get().Logger.Log("Agent has deferred survey until:  " + deliverFutureTime.Item2.Value, LoggingLevel.Normal, GetType());

                        Probe.Protocol.LocalDataStore.WriteDatum(new ScriptStateDatum(ScriptState.AgentDeferred, script.RunTime.Value, script), CancellationToken.None);

                        // check whether we need to expire the rescheduled script at some future point
                        DateTime?expiration = null;
                        DateTime trigger    = deliverFutureTime.Item2.Value.LocalDateTime;
                        if (_maxAge.HasValue)
                        {
                            expiration = trigger + _maxAge.Value;
                        }

                        // there is no window, so just add a descriptive, unique descriptor in place of the window
                        ScriptTriggerTime triggerTime = new ScriptTriggerTime(trigger, expiration, "DEFERRED-" + Guid.NewGuid());

                        // schedule the trigger. since this is a deferral, use the same script identifier that we currently have. this
                        // will maintain consistency and interpretability of the ScriptStateDatum objects that are recording the progression
                        // of scripts. this will also let survey agents better interpret what's going on with deferrals. this identifier is
                        // used as the RunId in the various tracked data types.
                        await ScheduleScriptRunAsync(triggerTime, script.Id);
                    }
                    else
                    {
                        SensusServiceHelper.Get().Logger.Log("Warning:  Agent has deferred survey to a time in the past:  " + deliverFutureTime.Item2.Value, LoggingLevel.Normal, GetType());
                    }

                    // do not proceed. the calling method (if scheduler-based) will take care of removing the current script.
                    return;
                }
            }

            lock (RunTimes)
            {
                // track participation by recording the current time. use this instead of the script's run timestamp, since
                // the latter is the time of notification on ios rather than the time that the user actually viewed the script.
                RunTimes.Add(DateTime.Now);
                RunTimes.RemoveAll(r => r < Probe.Protocol.ParticipationHorizon);
            }

            await SensusServiceHelper.Get().AddScriptAsync(script, RunMode);

            // let the script agent know and store a datum to record the event
            await(Probe.Agent?.ObserveAsync(script, ScriptState.Delivered) ?? Task.CompletedTask);
            Probe.Protocol.LocalDataStore.WriteDatum(new ScriptStateDatum(ScriptState.Delivered, script.RunTime.Value, script), CancellationToken.None);
        }