/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.calendarcommon2;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.Locale;
import java.util.TimeZone;
/**
* Helper class to make migration out of android.text.format.Time smoother.
*/
public class Time {
public static final String TIMEZONE_UTC = "UTC";
private static final int EPOCH_JULIAN_DAY = 2440588;
private static final long HOUR_IN_MILLIS = 60 * 60 * 1000;
private static final long DAY_IN_MILLIS = 24 * HOUR_IN_MILLIS;
private static final String FORMAT_ALL_DAY_PATTERN = "yyyyMMdd";
private static final String FORMAT_TIME_PATTERN = "yyyyMMdd'T'HHmmss";
private static final String FORMAT_TIME_UTC_PATTERN = "yyyyMMdd'T'HHmmss'Z'";
private static final String FORMAT_LOG_TIME_PATTERN = "EEE, MMM dd, yyyy hh:mm a";
/*
* Define symbolic constants for accessing the fields in this class. Used in
* getActualMaximum().
*/
public static final int SECOND = 1;
public static final int MINUTE = 2;
public static final int HOUR = 3;
public static final int MONTH_DAY = 4;
public static final int MONTH = 5;
public static final int YEAR = 6;
public static final int WEEK_DAY = 7;
public static final int YEAR_DAY = 8;
public static final int WEEK_NUM = 9;
public static final int SUNDAY = 0;
public static final int MONDAY = 1;
public static final int TUESDAY = 2;
public static final int WEDNESDAY = 3;
public static final int THURSDAY = 4;
public static final int FRIDAY = 5;
public static final int SATURDAY = 6;
private final GregorianCalendar mCalendar;
private int year;
private int month;
private int monthDay;
private int hour;
private int minute;
private int second;
private int yearDay;
private int weekDay;
private String timezone;
private boolean allDay;
/**
* Enabling this flag will apply appropriate dst transition logic when calling either
* {@code toMillis()} or {@code normalize()} and their respective *ApplyDst() equivalents.
* When this flag is enabled, the following calls would be considered equivalent:
*
* - {@code a.t.f.Time#normalize(true)} and {@code #normalize()}
* - {@code a.t.f.Time#toMillis(true)} and {@code #toMillis()}
* - {@code a.t.f.Time#normalize(false)} and {@code #normalizeApplyDst()}
* - {@code a.t.f.Time#toMillis(false)} and {@code #toMillisApplyDst()}
*
* When the flag is disabled, both {@code toMillis()} and {@code normalize()} will ignore any
* dst transitions unless minutes or hours were added to the time (the default behavior of the
* a.t.f.Time class).
*
* NOTE: currently, this flag is disabled because there are no direct manipulations of the day,
* hour, or minute fields. All of the accesses are correctly done via setters and they rely on
* a private normalize call in their respective classes to achieve their expected behavior.
* Additionally, using any of the {@code #set()} methods or {@code #parse()} will result in
* normalizing by ignoring DST, which is what the default behavior is for the a.t.f.Time class.
*/
static final boolean APPLY_DST_CHANGE_LOGIC = false;
private int mDstChangedByField = -1;
public Time() {
this(TimeZone.getDefault().getID());
}
public Time(String timezone) {
if (timezone == null) {
throw new NullPointerException("timezone cannot be null.");
}
this.timezone = timezone;
// Although the process's default locale is used here, #clear() will explicitly set the
// first day of the week to MONDAY to match with the expected a.t.f.Time implementation.
mCalendar = new GregorianCalendar(getTimeZone(), Locale.getDefault());
clear(this.timezone);
}
private void readFieldsFromCalendar() {
year = mCalendar.get(Calendar.YEAR);
month = mCalendar.get(Calendar.MONTH);
monthDay = mCalendar.get(Calendar.DAY_OF_MONTH);
hour = mCalendar.get(Calendar.HOUR_OF_DAY);
minute = mCalendar.get(Calendar.MINUTE);
second = mCalendar.get(Calendar.SECOND);
}
private void writeFieldsToCalendar() {
clearCalendar();
mCalendar.set(year, month, monthDay, hour, minute, second);
mCalendar.set(Calendar.MILLISECOND, 0);
}
private boolean isInDst() {
return mCalendar.getTimeZone().inDaylightTime(mCalendar.getTime());
}
public void add(int field, int amount) {
final boolean wasDstBefore = isInDst();
mCalendar.add(getCalendarField(field), amount);
if (APPLY_DST_CHANGE_LOGIC && wasDstBefore != isInDst()
&& (field == MONTH_DAY || field == HOUR || field == MINUTE)) {
mDstChangedByField = field;
}
}
public void set(long millis) {
clearCalendar();
mCalendar.setTimeInMillis(millis);
readFieldsFromCalendar();
}
public void set(Time other) {
clearCalendar();
mCalendar.setTimeZone(other.getTimeZone());
mCalendar.setTimeInMillis(other.mCalendar.getTimeInMillis());
readFieldsFromCalendar();
}
public void set(int day, int month, int year) {
clearCalendar();
mCalendar.set(year, month, day);
readFieldsFromCalendar();
}
public void set(int second, int minute, int hour, int day, int month, int year) {
clearCalendar();
mCalendar.set(year, month, day, hour, minute, second);
readFieldsFromCalendar();
}
public long setJulianDay(int julianDay) {
long millis = (julianDay - EPOCH_JULIAN_DAY) * DAY_IN_MILLIS;
mCalendar.setTimeInMillis(millis);
readFieldsFromCalendar();
// adjust day approximation, set the time to 12am, and re-normalize
monthDay += julianDay - getJulianDay(millis, getGmtOffset());
hour = 0;
minute = 0;
second = 0;
writeFieldsToCalendar();
return normalize();
}
public static int getJulianDay(long begin, long gmtOff) {
return android.text.format.Time.getJulianDay(begin, gmtOff);
}
public int getWeekNumber() {
return mCalendar.get(Calendar.WEEK_OF_YEAR);
}
private int getCalendarField(int field) {
switch (field) {
case SECOND: return Calendar.SECOND;
case MINUTE: return Calendar.MINUTE;
case HOUR: return Calendar.HOUR_OF_DAY;
case MONTH_DAY: return Calendar.DAY_OF_MONTH;
case MONTH: return Calendar.MONTH;
case YEAR: return Calendar.YEAR;
case WEEK_DAY: return Calendar.DAY_OF_WEEK;
case YEAR_DAY: return Calendar.DAY_OF_YEAR;
case WEEK_NUM: return Calendar.WEEK_OF_YEAR;
default:
throw new RuntimeException("bad field=" + field);
}
}
public int getActualMaximum(int field) {
return mCalendar.getActualMaximum(getCalendarField(field));
}
public void switchTimezone(String timezone) {
long msBefore = mCalendar.getTimeInMillis();
mCalendar.setTimeZone(TimeZone.getTimeZone(timezone));
mCalendar.setTimeInMillis(msBefore);
mDstChangedByField = -1;
readFieldsFromCalendar();
}
/**
* @param apply whether to apply dst logic on the ms or not; if apply is true, it is equivalent
* to calling the normalize or toMillis APIs in a.t.f.Time with ignoreDst=false
*/
private long getDstAdjustedMillis(boolean apply, long ms) {
if (APPLY_DST_CHANGE_LOGIC) {
if (apply && mDstChangedByField == MONTH_DAY) {
return isInDst() ? (ms + HOUR_IN_MILLIS) : (ms - HOUR_IN_MILLIS);
} else if (!apply && (mDstChangedByField == HOUR || mDstChangedByField == MINUTE)) {
return isInDst() ? (ms - HOUR_IN_MILLIS) : (ms + HOUR_IN_MILLIS);
}
}
return ms;
}
private long normalizeInternal() {
final long ms = mCalendar.getTimeInMillis();
readFieldsFromCalendar();
return ms;
}
public long normalize() {
return getDstAdjustedMillis(false, normalizeInternal());
}
long normalizeApplyDst() {
return getDstAdjustedMillis(true, normalizeInternal());
}
public void parse(String time) {
if (time == null) {
throw new NullPointerException("time string is null");
}
parseInternal(time);
writeFieldsToCalendar();
}
public String format2445() {
writeFieldsToCalendar();
final SimpleDateFormat sdf = new SimpleDateFormat(
allDay ? FORMAT_ALL_DAY_PATTERN
: (TIMEZONE_UTC.equals(getTimezone()) ? FORMAT_TIME_UTC_PATTERN
: FORMAT_TIME_PATTERN));
sdf.setTimeZone(getTimeZone());
return sdf.format(mCalendar.getTime());
}
public long toMillis() {
return getDstAdjustedMillis(false, mCalendar.getTimeInMillis());
}
long toMillisApplyDst() {
return getDstAdjustedMillis(true, mCalendar.getTimeInMillis());
}
private TimeZone getTimeZone() {
return timezone != null ? TimeZone.getTimeZone(timezone) : TimeZone.getDefault();
}
public int compareTo(Time other) {
return mCalendar.compareTo(other.mCalendar);
}
private void clearCalendar() {
mDstChangedByField = -1;
mCalendar.clear();
mCalendar.set(Calendar.HOUR_OF_DAY, 0); // HOUR_OF_DAY doesn't get reset with #clear
mCalendar.setTimeZone(getTimeZone());
// set fields for week number computation according to ISO 8601.
mCalendar.setFirstDayOfWeek(Calendar.MONDAY);
mCalendar.setMinimalDaysInFirstWeek(4);
}
public void clear(String timezoneId) {
clearCalendar();
readFieldsFromCalendar();
setTimezone(timezoneId);
}
public int getYear() {
return mCalendar.get(Calendar.YEAR);
}
public void setYear(int year) {
this.year = year;
mCalendar.set(Calendar.YEAR, year);
}
public int getMonth() {
return mCalendar.get(Calendar.MONTH);
}
public void setMonth(int month) {
this.month = month;
mCalendar.set(Calendar.MONTH, month);
}
public int getDay() {
return mCalendar.get(Calendar.DAY_OF_MONTH);
}
public void setDay(int day) {
this.monthDay = day;
mCalendar.set(Calendar.DAY_OF_MONTH, day);
}
public int getHour() {
return mCalendar.get(Calendar.HOUR_OF_DAY);
}
public void setHour(int hour) {
this.hour = hour;
mCalendar.set(Calendar.HOUR_OF_DAY, hour);
}
public int getMinute() {
return mCalendar.get(Calendar.MINUTE);
}
public void setMinute(int minute) {
this.minute = minute;
mCalendar.set(Calendar.MINUTE, minute);
}
public int getSecond() {
return mCalendar.get(Calendar.SECOND);
}
public void setSecond(int second) {
this.second = second;
mCalendar.set(Calendar.SECOND, second);
}
public String getTimezone() {
return mCalendar.getTimeZone().getID();
}
public void setTimezone(String timezone) {
this.timezone = timezone;
mCalendar.setTimeZone(getTimeZone());
}
public int getYearDay() {
// yearDay in a.t.f.Time's implementation starts from 0, whereas Calendar's starts from 1.
return mCalendar.get(Calendar.DAY_OF_YEAR) - 1;
}
public void setYearDay(int yearDay) {
this.yearDay = yearDay;
// yearDay in a.t.f.Time's implementation starts from 0, whereas Calendar's starts from 1.
mCalendar.set(Calendar.DAY_OF_YEAR, yearDay + 1);
}
public int getWeekDay() {
// weekDay in a.t.f.Time's implementation starts from 0, whereas Calendar's starts from 1.
return mCalendar.get(Calendar.DAY_OF_WEEK) - 1;
}
public void setWeekDay(int weekDay) {
this.weekDay = weekDay;
// weekDay in a.t.f.Time's implementation starts from 0, whereas Calendar's starts from 1.
mCalendar.set(Calendar.DAY_OF_WEEK, weekDay + 1);
}
public boolean isAllDay() {
return allDay;
}
public void setAllDay(boolean allDay) {
this.allDay = allDay;
}
public long getGmtOffset() {
return mCalendar.getTimeZone().getOffset(mCalendar.getTimeInMillis()) / 1000;
}
private void parseInternal(String s) {
int len = s.length();
if (len < 8) {
throw new IllegalArgumentException("String is too short: \"" + s +
"\" Expected at least 8 characters.");
} else if (len > 8 && len < 15) {
throw new IllegalArgumentException("String is too short: \"" + s
+ "\" If there are more than 8 characters there must be at least 15.");
}
// year
int n = getChar(s, 0, 1000);
n += getChar(s, 1, 100);
n += getChar(s, 2, 10);
n += getChar(s, 3, 1);
year = n;
// month
n = getChar(s, 4, 10);
n += getChar(s, 5, 1);
n--;
month = n;
// day of month
n = getChar(s, 6, 10);
n += getChar(s, 7, 1);
monthDay = n;
if (len > 8) {
checkChar(s, 8, 'T');
allDay = false;
// hour
n = getChar(s, 9, 10);
n += getChar(s, 10, 1);
hour = n;
// min
n = getChar(s, 11, 10);
n += getChar(s, 12, 1);
minute = n;
// sec
n = getChar(s, 13, 10);
n += getChar(s, 14, 1);
second = n;
if (len > 15) {
// Z
checkChar(s, 15, 'Z');
timezone = TIMEZONE_UTC;
}
} else {
allDay = true;
hour = 0;
minute = 0;
second = 0;
}
weekDay = 0;
yearDay = 0;
}
private void checkChar(String s, int spos, char expected) {
final char c = s.charAt(spos);
if (c != expected) {
throw new IllegalArgumentException(String.format(
"Unexpected character 0x%02d at pos=%d. Expected 0x%02d (\'%c\').",
(int) c, spos, (int) expected, expected));
}
}
private int getChar(String s, int spos, int mul) {
final char c = s.charAt(spos);
if (Character.isDigit(c)) {
return Character.getNumericValue(c) * mul;
} else {
throw new IllegalArgumentException("Parse error at pos=" + spos);
}
}
// NOTE: only used for outputting time to error logs
public String format() {
final SimpleDateFormat sdf =
new SimpleDateFormat(FORMAT_LOG_TIME_PATTERN, Locale.getDefault());
return sdf.format(mCalendar.getTime());
}
// NOTE: only used in tests
public boolean parse3339(String time) {
android.text.format.Time tmp = generateInstance();
boolean success = tmp.parse3339(time);
copyAndWriteInstance(tmp);
return success;
}
// NOTE: only used in tests
public String format3339(boolean allDay) {
return generateInstance().format3339(allDay);
}
private android.text.format.Time generateInstance() {
android.text.format.Time tmp = new android.text.format.Time(timezone);
tmp.set(second, minute, hour, monthDay, month, year);
tmp.yearDay = yearDay;
tmp.weekDay = weekDay;
tmp.timezone = timezone;
tmp.gmtoff = getGmtOffset();
tmp.allDay = allDay;
tmp.set(mCalendar.getTimeInMillis());
if (tmp.allDay && (tmp.hour != 0 || tmp.minute != 0 || tmp.second != 0)) {
// Time SDK expects hour, minute, second to be 0 if allDay is true
tmp.hour = 0;
tmp.minute = 0;
tmp.second = 0;
}
return tmp;
}
private void copyAndWriteInstance(android.text.format.Time time) {
year = time.year;
month = time.month;
monthDay = time.monthDay;
hour = time.hour;
minute = time.minute;
second = time.second;
yearDay = time.yearDay;
weekDay = time.weekDay;
timezone = time.timezone;
allDay = time.allDay;
writeFieldsToCalendar();
}
}