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