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.util;
18 
19 import android.compat.annotation.UnsupportedAppUsage;
20 import android.os.Parcel;
21 import android.os.Parcelable;
22 
23 import com.android.internal.annotations.VisibleForTesting;
24 
25 import java.io.DataInputStream;
26 import java.io.DataOutputStream;
27 import java.io.IOException;
28 import java.net.ProtocolException;
29 import java.time.Clock;
30 import java.time.LocalTime;
31 import java.time.Period;
32 import java.time.ZoneId;
33 import java.time.ZonedDateTime;
34 import java.util.Iterator;
35 import java.util.Objects;
36 
37 /**
38  * Description of an event that should recur over time at a specific interval
39  * between two anchor points in time.
40  *
41  * @hide
42  */
43 public class RecurrenceRule implements Parcelable {
44     private static final String TAG = "RecurrenceRule";
45     private static final boolean LOGD = Log.isLoggable(TAG, Log.DEBUG);
46 
47     private static final int VERSION_INIT = 0;
48 
49     /** {@hide} */
50     @VisibleForTesting
51     public static Clock sClock = Clock.systemDefaultZone();
52 
53     @UnsupportedAppUsage
54     public final ZonedDateTime start;
55     public final ZonedDateTime end;
56     public final Period period;
57 
RecurrenceRule(ZonedDateTime start, ZonedDateTime end, Period period)58     public RecurrenceRule(ZonedDateTime start, ZonedDateTime end, Period period) {
59         this.start = start;
60         this.end = end;
61         this.period = period;
62     }
63 
64     @Deprecated
buildNever()65     public static RecurrenceRule buildNever() {
66         return new RecurrenceRule(null, null, null);
67     }
68 
69     @Deprecated
70     @UnsupportedAppUsage
buildRecurringMonthly(int dayOfMonth, ZoneId zone)71     public static RecurrenceRule buildRecurringMonthly(int dayOfMonth, ZoneId zone) {
72         // Assume we started last January, since it has all possible days
73         final ZonedDateTime now = ZonedDateTime.now(sClock).withZoneSameInstant(zone);
74         final ZonedDateTime start = ZonedDateTime.of(
75                 now.toLocalDate().minusYears(1).withMonth(1).withDayOfMonth(dayOfMonth),
76                 LocalTime.MIDNIGHT, zone);
77         return new RecurrenceRule(start, null, Period.ofMonths(1));
78     }
79 
RecurrenceRule(Parcel source)80     private RecurrenceRule(Parcel source) {
81         start = convertZonedDateTime(source.readString());
82         end = convertZonedDateTime(source.readString());
83         period = convertPeriod(source.readString());
84     }
85 
86     @Override
describeContents()87     public int describeContents() {
88         return 0;
89     }
90 
91     @Override
writeToParcel(Parcel dest, int flags)92     public void writeToParcel(Parcel dest, int flags) {
93         dest.writeString(convertZonedDateTime(start));
94         dest.writeString(convertZonedDateTime(end));
95         dest.writeString(convertPeriod(period));
96     }
97 
RecurrenceRule(DataInputStream in)98     public RecurrenceRule(DataInputStream in) throws IOException {
99         final int version = in.readInt();
100         switch (version) {
101             case VERSION_INIT:
102                 start = convertZonedDateTime(BackupUtils.readString(in));
103                 end = convertZonedDateTime(BackupUtils.readString(in));
104                 period = convertPeriod(BackupUtils.readString(in));
105                 break;
106             default:
107                 throw new ProtocolException("Unknown version " + version);
108         }
109     }
110 
writeToStream(DataOutputStream out)111     public void writeToStream(DataOutputStream out) throws IOException {
112         out.writeInt(VERSION_INIT);
113         BackupUtils.writeString(out, convertZonedDateTime(start));
114         BackupUtils.writeString(out, convertZonedDateTime(end));
115         BackupUtils.writeString(out, convertPeriod(period));
116     }
117 
118     @Override
toString()119     public String toString() {
120         return new StringBuilder("RecurrenceRule{")
121                 .append("start=").append(start)
122                 .append(" end=").append(end)
123                 .append(" period=").append(period)
124                 .append("}").toString();
125     }
126 
127     @Override
hashCode()128     public int hashCode() {
129         return Objects.hash(start, end, period);
130     }
131 
132     @Override
equals(Object obj)133     public boolean equals(Object obj) {
134         if (obj instanceof RecurrenceRule) {
135             final RecurrenceRule other = (RecurrenceRule) obj;
136             return Objects.equals(start, other.start)
137                     && Objects.equals(end, other.end)
138                     && Objects.equals(period, other.period);
139         }
140         return false;
141     }
142 
143     public static final @android.annotation.NonNull Parcelable.Creator<RecurrenceRule> CREATOR = new Parcelable.Creator<RecurrenceRule>() {
144         @Override
145         public RecurrenceRule createFromParcel(Parcel source) {
146             return new RecurrenceRule(source);
147         }
148 
149         @Override
150         public RecurrenceRule[] newArray(int size) {
151             return new RecurrenceRule[size];
152         }
153     };
154 
isRecurring()155     public boolean isRecurring() {
156         return period != null;
157     }
158 
159     @Deprecated
isMonthly()160     public boolean isMonthly() {
161         return start != null
162                 && period != null
163                 && period.getYears() == 0
164                 && period.getMonths() == 1
165                 && period.getDays() == 0;
166     }
167 
cycleIterator()168     public Iterator<Range<ZonedDateTime>> cycleIterator() {
169         if (period != null) {
170             return new RecurringIterator();
171         } else {
172             return new NonrecurringIterator();
173         }
174     }
175 
176     private class NonrecurringIterator implements Iterator<Range<ZonedDateTime>> {
177         boolean hasNext;
178 
NonrecurringIterator()179         public NonrecurringIterator() {
180             hasNext = (start != null) && (end != null);
181         }
182 
183         @Override
hasNext()184         public boolean hasNext() {
185             return hasNext;
186         }
187 
188         @Override
next()189         public Range<ZonedDateTime> next() {
190             hasNext = false;
191             return new Range<>(start, end);
192         }
193     }
194 
195     private class RecurringIterator implements Iterator<Range<ZonedDateTime>> {
196         int i;
197         ZonedDateTime cycleStart;
198         ZonedDateTime cycleEnd;
199 
RecurringIterator()200         public RecurringIterator() {
201             final ZonedDateTime anchor = (end != null) ? end
202                     : ZonedDateTime.now(sClock).withZoneSameInstant(start.getZone());
203             if (LOGD) Log.d(TAG, "Resolving using anchor " + anchor);
204 
205             updateCycle();
206 
207             // Walk forwards until we find first cycle after now
208             while (anchor.toEpochSecond() > cycleEnd.toEpochSecond()) {
209                 i++;
210                 updateCycle();
211             }
212 
213             // Walk backwards until we find first cycle before now
214             while (anchor.toEpochSecond() <= cycleStart.toEpochSecond()) {
215                 i--;
216                 updateCycle();
217             }
218         }
219 
updateCycle()220         private void updateCycle() {
221             cycleStart = roundBoundaryTime(start.plus(period.multipliedBy(i)));
222             cycleEnd = roundBoundaryTime(start.plus(period.multipliedBy(i + 1)));
223         }
224 
roundBoundaryTime(ZonedDateTime boundary)225         private ZonedDateTime roundBoundaryTime(ZonedDateTime boundary) {
226             if (isMonthly() && (boundary.getDayOfMonth() < start.getDayOfMonth())) {
227                 // When forced to end a monthly cycle early, we want to count
228                 // that entire day against the boundary.
229                 return ZonedDateTime.of(boundary.toLocalDate(), LocalTime.MAX, start.getZone());
230             } else {
231                 return boundary;
232             }
233         }
234 
235         @Override
hasNext()236         public boolean hasNext() {
237             return cycleStart.toEpochSecond() >= start.toEpochSecond();
238         }
239 
240         @Override
next()241         public Range<ZonedDateTime> next() {
242             if (LOGD) Log.d(TAG, "Cycle " + i + " from " + cycleStart + " to " + cycleEnd);
243             Range<ZonedDateTime> r = new Range<>(cycleStart, cycleEnd);
244             i--;
245             updateCycle();
246             return r;
247         }
248     }
249 
convertZonedDateTime(ZonedDateTime time)250     public static String convertZonedDateTime(ZonedDateTime time) {
251         return time != null ? time.toString() : null;
252     }
253 
convertZonedDateTime(String time)254     public static ZonedDateTime convertZonedDateTime(String time) {
255         return time != null ? ZonedDateTime.parse(time) : null;
256     }
257 
convertPeriod(Period period)258     public static String convertPeriod(Period period) {
259         return period != null ? period.toString() : null;
260     }
261 
convertPeriod(String period)262     public static Period convertPeriod(String period) {
263         return period != null ? Period.parse(period) : null;
264     }
265 }
266