1 /* 2 * Copyright (C) 2020 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 package com.android.calendarcommon2; 17 18 import java.text.SimpleDateFormat; 19 import java.util.Calendar; 20 import java.util.GregorianCalendar; 21 import java.util.Locale; 22 import java.util.TimeZone; 23 24 /** 25 * Helper class to make migration out of android.text.format.Time smoother. 26 */ 27 public class Time { 28 29 public static final String TIMEZONE_UTC = "UTC"; 30 31 private static final int EPOCH_JULIAN_DAY = 2440588; 32 private static final long HOUR_IN_MILLIS = 60 * 60 * 1000; 33 private static final long DAY_IN_MILLIS = 24 * HOUR_IN_MILLIS; 34 35 private static final String FORMAT_ALL_DAY_PATTERN = "yyyyMMdd"; 36 private static final String FORMAT_TIME_PATTERN = "yyyyMMdd'T'HHmmss"; 37 private static final String FORMAT_TIME_UTC_PATTERN = "yyyyMMdd'T'HHmmss'Z'"; 38 private static final String FORMAT_LOG_TIME_PATTERN = "EEE, MMM dd, yyyy hh:mm a"; 39 40 /* 41 * Define symbolic constants for accessing the fields in this class. Used in 42 * getActualMaximum(). 43 */ 44 public static final int SECOND = 1; 45 public static final int MINUTE = 2; 46 public static final int HOUR = 3; 47 public static final int MONTH_DAY = 4; 48 public static final int MONTH = 5; 49 public static final int YEAR = 6; 50 public static final int WEEK_DAY = 7; 51 public static final int YEAR_DAY = 8; 52 public static final int WEEK_NUM = 9; 53 54 public static final int SUNDAY = 0; 55 public static final int MONDAY = 1; 56 public static final int TUESDAY = 2; 57 public static final int WEDNESDAY = 3; 58 public static final int THURSDAY = 4; 59 public static final int FRIDAY = 5; 60 public static final int SATURDAY = 6; 61 62 private final GregorianCalendar mCalendar; 63 64 private int year; 65 private int month; 66 private int monthDay; 67 private int hour; 68 private int minute; 69 private int second; 70 71 private int yearDay; 72 private int weekDay; 73 74 private String timezone; 75 private boolean allDay; 76 77 /** 78 * Enabling this flag will apply appropriate dst transition logic when calling either 79 * {@code toMillis()} or {@code normalize()} and their respective *ApplyDst() equivalents. <br> 80 * When this flag is enabled, the following calls would be considered equivalent: 81 * <ul> 82 * <li>{@code a.t.f.Time#normalize(true)} and {@code #normalize()}</li> 83 * <li>{@code a.t.f.Time#toMillis(true)} and {@code #toMillis()}</li> 84 * <li>{@code a.t.f.Time#normalize(false)} and {@code #normalizeApplyDst()}</li> 85 * <li>{@code a.t.f.Time#toMillis(false)} and {@code #toMillisApplyDst()}</li> 86 * </ul> 87 * When the flag is disabled, both {@code toMillis()} and {@code normalize()} will ignore any 88 * dst transitions unless minutes or hours were added to the time (the default behavior of the 89 * a.t.f.Time class). <br> 90 * 91 * NOTE: currently, this flag is disabled because there are no direct manipulations of the day, 92 * hour, or minute fields. All of the accesses are correctly done via setters and they rely on 93 * a private normalize call in their respective classes to achieve their expected behavior. 94 * Additionally, using any of the {@code #set()} methods or {@code #parse()} will result in 95 * normalizing by ignoring DST, which is what the default behavior is for the a.t.f.Time class. 96 */ 97 static final boolean APPLY_DST_CHANGE_LOGIC = false; 98 private int mDstChangedByField = -1; 99 Time()100 public Time() { 101 this(TimeZone.getDefault().getID()); 102 } 103 Time(String timezone)104 public Time(String timezone) { 105 if (timezone == null) { 106 throw new NullPointerException("timezone cannot be null."); 107 } 108 this.timezone = timezone; 109 // Although the process's default locale is used here, #clear() will explicitly set the 110 // first day of the week to MONDAY to match with the expected a.t.f.Time implementation. 111 mCalendar = new GregorianCalendar(getTimeZone(), Locale.getDefault()); 112 clear(this.timezone); 113 } 114 readFieldsFromCalendar()115 private void readFieldsFromCalendar() { 116 year = mCalendar.get(Calendar.YEAR); 117 month = mCalendar.get(Calendar.MONTH); 118 monthDay = mCalendar.get(Calendar.DAY_OF_MONTH); 119 hour = mCalendar.get(Calendar.HOUR_OF_DAY); 120 minute = mCalendar.get(Calendar.MINUTE); 121 second = mCalendar.get(Calendar.SECOND); 122 } 123 writeFieldsToCalendar()124 private void writeFieldsToCalendar() { 125 clearCalendar(); 126 mCalendar.set(year, month, monthDay, hour, minute, second); 127 mCalendar.set(Calendar.MILLISECOND, 0); 128 } 129 isInDst()130 private boolean isInDst() { 131 return mCalendar.getTimeZone().inDaylightTime(mCalendar.getTime()); 132 } 133 add(int field, int amount)134 public void add(int field, int amount) { 135 final boolean wasDstBefore = isInDst(); 136 mCalendar.add(getCalendarField(field), amount); 137 if (APPLY_DST_CHANGE_LOGIC && wasDstBefore != isInDst() 138 && (field == MONTH_DAY || field == HOUR || field == MINUTE)) { 139 mDstChangedByField = field; 140 } 141 } 142 set(long millis)143 public void set(long millis) { 144 clearCalendar(); 145 mCalendar.setTimeInMillis(millis); 146 readFieldsFromCalendar(); 147 } 148 set(Time other)149 public void set(Time other) { 150 clearCalendar(); 151 mCalendar.setTimeZone(other.getTimeZone()); 152 mCalendar.setTimeInMillis(other.mCalendar.getTimeInMillis()); 153 readFieldsFromCalendar(); 154 } 155 set(int day, int month, int year)156 public void set(int day, int month, int year) { 157 clearCalendar(); 158 mCalendar.set(year, month, day); 159 readFieldsFromCalendar(); 160 } 161 set(int second, int minute, int hour, int day, int month, int year)162 public void set(int second, int minute, int hour, int day, int month, int year) { 163 clearCalendar(); 164 mCalendar.set(year, month, day, hour, minute, second); 165 readFieldsFromCalendar(); 166 } 167 setJulianDay(int julianDay)168 public long setJulianDay(int julianDay) { 169 long millis = (julianDay - EPOCH_JULIAN_DAY) * DAY_IN_MILLIS; 170 mCalendar.setTimeInMillis(millis); 171 readFieldsFromCalendar(); 172 173 // adjust day approximation, set the time to 12am, and re-normalize 174 monthDay += julianDay - getJulianDay(millis, getGmtOffset()); 175 hour = 0; 176 minute = 0; 177 second = 0; 178 writeFieldsToCalendar(); 179 return normalize(); 180 } 181 getJulianDay(long begin, long gmtOff)182 public static int getJulianDay(long begin, long gmtOff) { 183 return android.text.format.Time.getJulianDay(begin, gmtOff); 184 } 185 getWeekNumber()186 public int getWeekNumber() { 187 return mCalendar.get(Calendar.WEEK_OF_YEAR); 188 } 189 getCalendarField(int field)190 private int getCalendarField(int field) { 191 switch (field) { 192 case SECOND: return Calendar.SECOND; 193 case MINUTE: return Calendar.MINUTE; 194 case HOUR: return Calendar.HOUR_OF_DAY; 195 case MONTH_DAY: return Calendar.DAY_OF_MONTH; 196 case MONTH: return Calendar.MONTH; 197 case YEAR: return Calendar.YEAR; 198 case WEEK_DAY: return Calendar.DAY_OF_WEEK; 199 case YEAR_DAY: return Calendar.DAY_OF_YEAR; 200 case WEEK_NUM: return Calendar.WEEK_OF_YEAR; 201 default: 202 throw new RuntimeException("bad field=" + field); 203 } 204 } 205 getActualMaximum(int field)206 public int getActualMaximum(int field) { 207 return mCalendar.getActualMaximum(getCalendarField(field)); 208 } 209 switchTimezone(String timezone)210 public void switchTimezone(String timezone) { 211 long msBefore = mCalendar.getTimeInMillis(); 212 mCalendar.setTimeZone(TimeZone.getTimeZone(timezone)); 213 mCalendar.setTimeInMillis(msBefore); 214 mDstChangedByField = -1; 215 readFieldsFromCalendar(); 216 } 217 218 /** 219 * @param apply whether to apply dst logic on the ms or not; if apply is true, it is equivalent 220 * to calling the normalize or toMillis APIs in a.t.f.Time with ignoreDst=false 221 */ getDstAdjustedMillis(boolean apply, long ms)222 private long getDstAdjustedMillis(boolean apply, long ms) { 223 if (APPLY_DST_CHANGE_LOGIC) { 224 if (apply && mDstChangedByField == MONTH_DAY) { 225 return isInDst() ? (ms + HOUR_IN_MILLIS) : (ms - HOUR_IN_MILLIS); 226 } else if (!apply && (mDstChangedByField == HOUR || mDstChangedByField == MINUTE)) { 227 return isInDst() ? (ms - HOUR_IN_MILLIS) : (ms + HOUR_IN_MILLIS); 228 } 229 } 230 return ms; 231 } 232 normalizeInternal()233 private long normalizeInternal() { 234 final long ms = mCalendar.getTimeInMillis(); 235 readFieldsFromCalendar(); 236 return ms; 237 } 238 normalize()239 public long normalize() { 240 return getDstAdjustedMillis(false, normalizeInternal()); 241 } 242 normalizeApplyDst()243 long normalizeApplyDst() { 244 return getDstAdjustedMillis(true, normalizeInternal()); 245 } 246 parse(String time)247 public void parse(String time) { 248 if (time == null) { 249 throw new NullPointerException("time string is null"); 250 } 251 parseInternal(time); 252 writeFieldsToCalendar(); 253 } 254 format2445()255 public String format2445() { 256 writeFieldsToCalendar(); 257 final SimpleDateFormat sdf = new SimpleDateFormat( 258 allDay ? FORMAT_ALL_DAY_PATTERN 259 : (TIMEZONE_UTC.equals(getTimezone()) ? FORMAT_TIME_UTC_PATTERN 260 : FORMAT_TIME_PATTERN)); 261 sdf.setTimeZone(getTimeZone()); 262 return sdf.format(mCalendar.getTime()); 263 } 264 toMillis()265 public long toMillis() { 266 return getDstAdjustedMillis(false, mCalendar.getTimeInMillis()); 267 } 268 toMillisApplyDst()269 long toMillisApplyDst() { 270 return getDstAdjustedMillis(true, mCalendar.getTimeInMillis()); 271 } 272 getTimeZone()273 private TimeZone getTimeZone() { 274 return timezone != null ? TimeZone.getTimeZone(timezone) : TimeZone.getDefault(); 275 } 276 compareTo(Time other)277 public int compareTo(Time other) { 278 return mCalendar.compareTo(other.mCalendar); 279 } 280 clearCalendar()281 private void clearCalendar() { 282 mDstChangedByField = -1; 283 mCalendar.clear(); 284 mCalendar.set(Calendar.HOUR_OF_DAY, 0); // HOUR_OF_DAY doesn't get reset with #clear 285 mCalendar.setTimeZone(getTimeZone()); 286 // set fields for week number computation according to ISO 8601. 287 mCalendar.setFirstDayOfWeek(Calendar.MONDAY); 288 mCalendar.setMinimalDaysInFirstWeek(4); 289 } 290 clear(String timezoneId)291 public void clear(String timezoneId) { 292 clearCalendar(); 293 readFieldsFromCalendar(); 294 setTimezone(timezoneId); 295 } 296 getYear()297 public int getYear() { 298 return mCalendar.get(Calendar.YEAR); 299 } 300 setYear(int year)301 public void setYear(int year) { 302 this.year = year; 303 mCalendar.set(Calendar.YEAR, year); 304 } 305 getMonth()306 public int getMonth() { 307 return mCalendar.get(Calendar.MONTH); 308 } 309 setMonth(int month)310 public void setMonth(int month) { 311 this.month = month; 312 mCalendar.set(Calendar.MONTH, month); 313 } 314 getDay()315 public int getDay() { 316 return mCalendar.get(Calendar.DAY_OF_MONTH); 317 } 318 setDay(int day)319 public void setDay(int day) { 320 this.monthDay = day; 321 mCalendar.set(Calendar.DAY_OF_MONTH, day); 322 } 323 getHour()324 public int getHour() { 325 return mCalendar.get(Calendar.HOUR_OF_DAY); 326 } 327 setHour(int hour)328 public void setHour(int hour) { 329 this.hour = hour; 330 mCalendar.set(Calendar.HOUR_OF_DAY, hour); 331 } 332 getMinute()333 public int getMinute() { 334 return mCalendar.get(Calendar.MINUTE); 335 } 336 setMinute(int minute)337 public void setMinute(int minute) { 338 this.minute = minute; 339 mCalendar.set(Calendar.MINUTE, minute); 340 } 341 getSecond()342 public int getSecond() { 343 return mCalendar.get(Calendar.SECOND); 344 } 345 setSecond(int second)346 public void setSecond(int second) { 347 this.second = second; 348 mCalendar.set(Calendar.SECOND, second); 349 } 350 getTimezone()351 public String getTimezone() { 352 return mCalendar.getTimeZone().getID(); 353 } 354 setTimezone(String timezone)355 public void setTimezone(String timezone) { 356 this.timezone = timezone; 357 mCalendar.setTimeZone(getTimeZone()); 358 } 359 getYearDay()360 public int getYearDay() { 361 // yearDay in a.t.f.Time's implementation starts from 0, whereas Calendar's starts from 1. 362 return mCalendar.get(Calendar.DAY_OF_YEAR) - 1; 363 } 364 setYearDay(int yearDay)365 public void setYearDay(int yearDay) { 366 this.yearDay = yearDay; 367 // yearDay in a.t.f.Time's implementation starts from 0, whereas Calendar's starts from 1. 368 mCalendar.set(Calendar.DAY_OF_YEAR, yearDay + 1); 369 } 370 getWeekDay()371 public int getWeekDay() { 372 // weekDay in a.t.f.Time's implementation starts from 0, whereas Calendar's starts from 1. 373 return mCalendar.get(Calendar.DAY_OF_WEEK) - 1; 374 } 375 setWeekDay(int weekDay)376 public void setWeekDay(int weekDay) { 377 this.weekDay = weekDay; 378 // weekDay in a.t.f.Time's implementation starts from 0, whereas Calendar's starts from 1. 379 mCalendar.set(Calendar.DAY_OF_WEEK, weekDay + 1); 380 } 381 isAllDay()382 public boolean isAllDay() { 383 return allDay; 384 } 385 setAllDay(boolean allDay)386 public void setAllDay(boolean allDay) { 387 this.allDay = allDay; 388 } 389 getGmtOffset()390 public long getGmtOffset() { 391 return mCalendar.getTimeZone().getOffset(mCalendar.getTimeInMillis()) / 1000; 392 } 393 parseInternal(String s)394 private void parseInternal(String s) { 395 int len = s.length(); 396 if (len < 8) { 397 throw new IllegalArgumentException("String is too short: \"" + s + 398 "\" Expected at least 8 characters."); 399 } else if (len > 8 && len < 15) { 400 throw new IllegalArgumentException("String is too short: \"" + s 401 + "\" If there are more than 8 characters there must be at least 15."); 402 } 403 404 // year 405 int n = getChar(s, 0, 1000); 406 n += getChar(s, 1, 100); 407 n += getChar(s, 2, 10); 408 n += getChar(s, 3, 1); 409 year = n; 410 411 // month 412 n = getChar(s, 4, 10); 413 n += getChar(s, 5, 1); 414 n--; 415 month = n; 416 417 // day of month 418 n = getChar(s, 6, 10); 419 n += getChar(s, 7, 1); 420 monthDay = n; 421 422 if (len > 8) { 423 checkChar(s, 8, 'T'); 424 allDay = false; 425 426 // hour 427 n = getChar(s, 9, 10); 428 n += getChar(s, 10, 1); 429 hour = n; 430 431 // min 432 n = getChar(s, 11, 10); 433 n += getChar(s, 12, 1); 434 minute = n; 435 436 // sec 437 n = getChar(s, 13, 10); 438 n += getChar(s, 14, 1); 439 second = n; 440 441 if (len > 15) { 442 // Z 443 checkChar(s, 15, 'Z'); 444 timezone = TIMEZONE_UTC; 445 } 446 } else { 447 allDay = true; 448 hour = 0; 449 minute = 0; 450 second = 0; 451 } 452 453 weekDay = 0; 454 yearDay = 0; 455 } 456 checkChar(String s, int spos, char expected)457 private void checkChar(String s, int spos, char expected) { 458 final char c = s.charAt(spos); 459 if (c != expected) { 460 throw new IllegalArgumentException(String.format( 461 "Unexpected character 0x%02d at pos=%d. Expected 0x%02d (\'%c\').", 462 (int) c, spos, (int) expected, expected)); 463 } 464 } 465 getChar(String s, int spos, int mul)466 private int getChar(String s, int spos, int mul) { 467 final char c = s.charAt(spos); 468 if (Character.isDigit(c)) { 469 return Character.getNumericValue(c) * mul; 470 } else { 471 throw new IllegalArgumentException("Parse error at pos=" + spos); 472 } 473 } 474 475 // NOTE: only used for outputting time to error logs format()476 public String format() { 477 final SimpleDateFormat sdf = 478 new SimpleDateFormat(FORMAT_LOG_TIME_PATTERN, Locale.getDefault()); 479 return sdf.format(mCalendar.getTime()); 480 } 481 482 // NOTE: only used in tests parse3339(String time)483 public boolean parse3339(String time) { 484 android.text.format.Time tmp = generateInstance(); 485 boolean success = tmp.parse3339(time); 486 copyAndWriteInstance(tmp); 487 return success; 488 } 489 490 // NOTE: only used in tests format3339(boolean allDay)491 public String format3339(boolean allDay) { 492 return generateInstance().format3339(allDay); 493 } 494 generateInstance()495 private android.text.format.Time generateInstance() { 496 android.text.format.Time tmp = new android.text.format.Time(timezone); 497 tmp.set(second, minute, hour, monthDay, month, year); 498 499 tmp.yearDay = yearDay; 500 tmp.weekDay = weekDay; 501 502 tmp.timezone = timezone; 503 tmp.gmtoff = getGmtOffset(); 504 tmp.allDay = allDay; 505 tmp.set(mCalendar.getTimeInMillis()); 506 if (tmp.allDay && (tmp.hour != 0 || tmp.minute != 0 || tmp.second != 0)) { 507 // Time SDK expects hour, minute, second to be 0 if allDay is true 508 tmp.hour = 0; 509 tmp.minute = 0; 510 tmp.second = 0; 511 } 512 513 return tmp; 514 } 515 copyAndWriteInstance(android.text.format.Time time)516 private void copyAndWriteInstance(android.text.format.Time time) { 517 year = time.year; 518 month = time.month; 519 monthDay = time.monthDay; 520 hour = time.hour; 521 minute = time.minute; 522 second = time.second; 523 524 yearDay = time.yearDay; 525 weekDay = time.weekDay; 526 527 timezone = time.timezone; 528 allDay = time.allDay; 529 530 writeFieldsToCalendar(); 531 } 532 } 533