1 /*
2  * Copyright (c) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.service.notification;
18 
19 import android.service.notification.ZenModeConfig.ScheduleInfo;
20 import android.util.ArraySet;
21 import android.util.Log;
22 
23 import com.android.internal.annotations.VisibleForTesting;
24 
25 import java.util.Calendar;
26 import java.util.Objects;
27 import java.util.TimeZone;
28 
29 /**
30  * @hide
31  */
32 public class ScheduleCalendar {
33     public static final String TAG = "ScheduleCalendar";
34     public static final boolean DEBUG = Log.isLoggable("ConditionProviders", Log.DEBUG);
35     private final ArraySet<Integer> mDays = new ArraySet<Integer>();
36     private final Calendar mCalendar = Calendar.getInstance();
37 
38     private ScheduleInfo mSchedule;
39 
40     @Override
toString()41     public String toString() {
42         return "ScheduleCalendar[mDays=" + mDays + ", mSchedule=" + mSchedule + "]";
43     }
44 
45     /**
46      * @return true if schedule will exit on alarm, else false
47      */
exitAtAlarm()48     public boolean exitAtAlarm() {
49         return mSchedule.exitAtAlarm;
50     }
51 
52     /**
53      * Sets schedule information
54      */
setSchedule(ScheduleInfo schedule)55     public void setSchedule(ScheduleInfo schedule) {
56         if (Objects.equals(mSchedule, schedule)) return;
57         mSchedule = schedule;
58         updateDays();
59     }
60 
61     /**
62      * Sets next alarm of the schedule
63      * @param now current time in milliseconds
64      * @param nextAlarm time of next alarm in milliseconds
65      */
maybeSetNextAlarm(long now, long nextAlarm)66     public void maybeSetNextAlarm(long now, long nextAlarm) {
67         if (mSchedule != null && mSchedule.exitAtAlarm) {
68             if (nextAlarm == 0) {
69                 // alarm canceled
70                 mSchedule.nextAlarm = 0;
71             } else if (nextAlarm > now) {
72                 // only allow alarms in the future
73                 mSchedule.nextAlarm = nextAlarm;
74             } else if (mSchedule.nextAlarm < now) {
75                 if (DEBUG) {
76                     Log.d(TAG, "All alarms are in the past " + mSchedule.nextAlarm);
77                 }
78                 mSchedule.nextAlarm = 0;
79             }
80         }
81     }
82 
83     /**
84      * Set calendar time zone to tz
85      * @param tz current time zone
86      */
setTimeZone(TimeZone tz)87     public void setTimeZone(TimeZone tz) {
88         mCalendar.setTimeZone(tz);
89     }
90 
91     /**
92      * @param now current time in milliseconds
93      * @return next time this rule changes (starts or ends)
94      */
getNextChangeTime(long now)95     public long getNextChangeTime(long now) {
96         if (mSchedule == null) return 0;
97         final long nextStart = getNextTime(now, mSchedule.startHour, mSchedule.startMinute, true);
98         final long nextEnd = getNextTime(now, mSchedule.endHour, mSchedule.endMinute, false);
99         long nextScheduleTime = Math.min(nextStart, nextEnd);
100 
101         return nextScheduleTime;
102     }
103 
getNextTime(long now, int hr, int min, boolean adjust)104     private long getNextTime(long now, int hr, int min, boolean adjust) {
105         // The adjust parameter indicates whether to potentially adjust the time to the closest
106         // actual time if the indicated time is one skipped due to daylight time.
107         final long time = adjust ? getClosestActualTime(now, hr, min) : getTime(now, hr, min);
108         if (time <= now) {
109             final long tomorrow = addDays(time, 1);
110             return adjust ? getClosestActualTime(tomorrow, hr, min) : getTime(tomorrow, hr, min);
111         }
112         return time;
113     }
114 
getTime(long millis, int hour, int min)115     private long getTime(long millis, int hour, int min) {
116         mCalendar.setTimeInMillis(millis);
117         mCalendar.set(Calendar.HOUR_OF_DAY, hour);
118         mCalendar.set(Calendar.MINUTE, min);
119         mCalendar.set(Calendar.SECOND, 0);
120         mCalendar.set(Calendar.MILLISECOND, 0);
121         return mCalendar.getTimeInMillis();
122     }
123 
124     /**
125      * @param time milliseconds since Epoch
126      * @return true if time is within the schedule, else false
127      */
isInSchedule(long time)128     public boolean isInSchedule(long time) {
129         if (mSchedule == null || mDays.size() == 0) return false;
130         final long start = getClosestActualTime(time, mSchedule.startHour, mSchedule.startMinute);
131         long end = getTime(time, mSchedule.endHour, mSchedule.endMinute);
132         if (end <= start) {
133             end = addDays(end, 1);
134         }
135         return isInSchedule(-1, time, start, end) || isInSchedule(0, time, start, end);
136     }
137 
138     /**
139      * @param alarm milliseconds since Epoch
140      * @param now milliseconds since Epoch
141      * @return true if alarm and now is within the schedule, else false
142      */
isAlarmInSchedule(long alarm, long now)143     public boolean isAlarmInSchedule(long alarm, long now) {
144         if (mSchedule == null || mDays.size() == 0) return false;
145         final long start = getClosestActualTime(alarm, mSchedule.startHour, mSchedule.startMinute);
146         long end = getTime(alarm, mSchedule.endHour, mSchedule.endMinute);
147         if (end <= start) {
148             end = addDays(end, 1);
149         }
150         return (isInSchedule(-1, alarm, start, end)
151                 && isInSchedule(-1, now, start, end))
152                 || (isInSchedule(0, alarm, start, end)
153                 && isInSchedule(0, now, start, end));
154     }
155 
156     /**
157      * @param time milliseconds since Epoch
158      * @return true if should exit at time for next alarm, else false
159      */
shouldExitForAlarm(long time)160     public boolean shouldExitForAlarm(long time) {
161         if (mSchedule == null) {
162             return false;
163         }
164         return mSchedule.exitAtAlarm
165                 && mSchedule.nextAlarm != 0
166                 && time >= mSchedule.nextAlarm
167                 && isAlarmInSchedule(mSchedule.nextAlarm, time);
168     }
169 
isInSchedule(int daysOffset, long time, long start, long end)170     private boolean isInSchedule(int daysOffset, long time, long start, long end) {
171         final int n = Calendar.SATURDAY;
172         final int day = ((getDayOfWeek(time) - 1) + (daysOffset % n) + n) % n + 1;
173         start = addDays(start, daysOffset);
174         end = addDays(end, daysOffset);
175         return mDays.contains(day) && time >= start && time < end;
176     }
177 
getDayOfWeek(long time)178     private int getDayOfWeek(long time) {
179         mCalendar.setTimeInMillis(time);
180         return mCalendar.get(Calendar.DAY_OF_WEEK);
181     }
182 
updateDays()183     private void updateDays() {
184         mDays.clear();
185         if (mSchedule != null && mSchedule.days != null) {
186             for (int i = 0; i < mSchedule.days.length; i++) {
187                 mDays.add(mSchedule.days[i]);
188             }
189         }
190     }
191 
addDays(long time, int days)192     private long addDays(long time, int days) {
193         mCalendar.setTimeInMillis(time);
194         mCalendar.add(Calendar.DATE, days);
195         return mCalendar.getTimeInMillis();
196     }
197 
198     /**
199      * This function returns the closest "actual" time to the provided hour/minute relative to the
200      * reference time. For most times this will behave exactly the same as getTime, but for any time
201      * during the hour skipped forward for daylight savings time (for instance, 02:xx when the
202      * clock is set to 03:00 after 01:59), this method will return the time when the clock changes
203      * (in this example, 03:00).
204      *
205      * Assumptions made in this implementation:
206      *   - Time is moved forward on an hour boundary (minute 0) by exactly 1hr when clocks shift
207      *   - a lenient Calendar implementation will interpret 02:xx on a day when 2-3AM is skipped
208      *     as 03:xx
209      *   - The skipped hour is never 11PM / 23:00.
210      *
211      * @hide
212      */
213     @VisibleForTesting
getClosestActualTime(long refTime, int hour, int min)214     public long getClosestActualTime(long refTime, int hour, int min) {
215         long resTime = getTime(refTime, hour, min);
216         if (!mCalendar.getTimeZone().observesDaylightTime()) {
217             // Do nothing if the timezone doesn't observe daylight time at all.
218             return resTime;
219         }
220 
221         // Approach to identifying whether the time is "skipped": get the result from starting with
222         // refTime and setting hour and minute, then re-extract the hour and minute of the resulting
223         // moment in time. If the hour is exactly one more than the passed-in hour and the minute is
224         // the same, then the provided hour is likely a skipped one. If the time doesn't fall into
225         // this category, return the unmodified time instead.
226         mCalendar.setTimeInMillis(resTime);
227         int resHr = mCalendar.get(Calendar.HOUR_OF_DAY);
228         int resMin = mCalendar.get(Calendar.MINUTE);
229         if (resHr == hour + 1 && resMin == min) {
230             return getTime(refTime, resHr, 0);
231         }
232         return resTime;
233     }
234 }
235