1/*
2 * Copyright (C) 2015 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
17package libcore.icu;
18
19import java.util.Locale;
20import libcore.util.BasicLruCache;
21
22import com.ibm.icu.text.DisplayContext;
23import com.ibm.icu.util.Calendar;
24import com.ibm.icu.util.ULocale;
25
26import static libcore.icu.DateUtilsBridge.FORMAT_ABBREV_ALL;
27import static libcore.icu.DateUtilsBridge.FORMAT_ABBREV_MONTH;
28import static libcore.icu.DateUtilsBridge.FORMAT_ABBREV_RELATIVE;
29import static libcore.icu.DateUtilsBridge.FORMAT_NO_YEAR;
30import static libcore.icu.DateUtilsBridge.FORMAT_NUMERIC_DATE;
31import static libcore.icu.DateUtilsBridge.FORMAT_SHOW_DATE;
32import static libcore.icu.DateUtilsBridge.FORMAT_SHOW_TIME;
33import static libcore.icu.DateUtilsBridge.FORMAT_SHOW_YEAR;
34
35/**
36 * Exposes icu4j's RelativeDateTimeFormatter.
37 */
38public final class RelativeDateTimeFormatter {
39
40  public static final long SECOND_IN_MILLIS = 1000;
41  public static final long MINUTE_IN_MILLIS = SECOND_IN_MILLIS * 60;
42  public static final long HOUR_IN_MILLIS = MINUTE_IN_MILLIS * 60;
43  public static final long DAY_IN_MILLIS = HOUR_IN_MILLIS * 24;
44  public static final long WEEK_IN_MILLIS = DAY_IN_MILLIS * 7;
45  // YEAR_IN_MILLIS considers 364 days as a year. However, since this
46  // constant comes from public API in DateUtils, it cannot be fixed here.
47  public static final long YEAR_IN_MILLIS = WEEK_IN_MILLIS * 52;
48
49  private static final int DAY_IN_MS = 24 * 60 * 60 * 1000;
50  private static final int EPOCH_JULIAN_DAY = 2440588;
51
52  private static final FormatterCache CACHED_FORMATTERS = new FormatterCache();
53
54  static class FormatterCache
55      extends BasicLruCache<String, com.ibm.icu.text.RelativeDateTimeFormatter> {
56    FormatterCache() {
57      super(8);
58    }
59  }
60
61  private RelativeDateTimeFormatter() {
62  }
63
64  /**
65   * This is the internal API that implements the functionality of
66   * DateUtils.getRelativeTimeSpanString(long, long, long, int), which is to
67   * return a string describing 'time' as a time relative to 'now' such as
68   * '5 minutes ago', or 'in 2 days'. More examples can be found in DateUtils'
69   * doc.
70   *
71   * In the implementation below, it selects the appropriate time unit based on
72   * the elapsed time between time' and 'now', e.g. minutes, days and etc.
73   * Callers may also specify the desired minimum resolution to show in the
74   * result. For example, '45 minutes ago' will become '0 hours ago' when
75   * minResolution is HOUR_IN_MILLIS. Once getting the quantity and unit to
76   * display, it calls icu4j's RelativeDateTimeFormatter to format the actual
77   * string according to the given locale.
78   *
79   * Note that when minResolution is set to DAY_IN_MILLIS, it returns the
80   * result depending on the actual date difference. For example, it will
81   * return 'Yesterday' even if 'time' was less than 24 hours ago but falling
82   * onto a different calendar day.
83   *
84   * It takes two additional parameters of Locale and TimeZone than the
85   * DateUtils' API. Caller must specify the locale and timezone.
86   * FORMAT_ABBREV_RELATIVE or FORMAT_ABBREV_ALL can be set in 'flags' to get
87   * the abbreviated forms when available. When 'time' equals to 'now', it
88   * always // returns a string like '0 seconds/minutes/... ago' according to
89   * minResolution.
90   */
91  public static String getRelativeTimeSpanString(Locale locale, java.util.TimeZone tz, long time,
92      long now, long minResolution, int flags) {
93    // Android has been inconsistent about capitalization in the past. e.g. bug http://b/20247811.
94    // Now we capitalize everything consistently.
95    final DisplayContext displayContext = DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE;
96    return getRelativeTimeSpanString(locale, tz, time, now, minResolution, flags, displayContext);
97  }
98
99  public static String getRelativeTimeSpanString(Locale locale, java.util.TimeZone tz, long time,
100      long now, long minResolution, int flags, DisplayContext displayContext) {
101    if (locale == null) {
102      throw new NullPointerException("locale == null");
103    }
104    if (tz == null) {
105      throw new NullPointerException("tz == null");
106    }
107    ULocale icuLocale = ULocale.forLocale(locale);
108    com.ibm.icu.util.TimeZone icuTimeZone = DateUtilsBridge.icuTimeZone(tz);
109    return getRelativeTimeSpanString(icuLocale, icuTimeZone, time, now, minResolution, flags,
110        displayContext);
111  }
112
113  private static String getRelativeTimeSpanString(ULocale icuLocale,
114      com.ibm.icu.util.TimeZone icuTimeZone, long time, long now, long minResolution, int flags,
115      DisplayContext displayContext) {
116
117    long duration = Math.abs(now - time);
118    boolean past = (now >= time);
119
120    com.ibm.icu.text.RelativeDateTimeFormatter.Style style;
121    if ((flags & (FORMAT_ABBREV_RELATIVE | FORMAT_ABBREV_ALL)) != 0) {
122      style = com.ibm.icu.text.RelativeDateTimeFormatter.Style.SHORT;
123    } else {
124      style = com.ibm.icu.text.RelativeDateTimeFormatter.Style.LONG;
125    }
126
127    com.ibm.icu.text.RelativeDateTimeFormatter.Direction direction;
128    if (past) {
129      direction = com.ibm.icu.text.RelativeDateTimeFormatter.Direction.LAST;
130    } else {
131      direction = com.ibm.icu.text.RelativeDateTimeFormatter.Direction.NEXT;
132    }
133
134    // 'relative' defaults to true as we are generating relative time span
135    // string. It will be set to false when we try to display strings without
136    // a quantity, such as 'Yesterday', etc.
137    boolean relative = true;
138    int count;
139    com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit unit;
140    com.ibm.icu.text.RelativeDateTimeFormatter.AbsoluteUnit aunit = null;
141
142    if (duration < MINUTE_IN_MILLIS && minResolution < MINUTE_IN_MILLIS) {
143      count = (int)(duration / SECOND_IN_MILLIS);
144      unit = com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit.SECONDS;
145    } else if (duration < HOUR_IN_MILLIS && minResolution < HOUR_IN_MILLIS) {
146      count = (int)(duration / MINUTE_IN_MILLIS);
147      unit = com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit.MINUTES;
148    } else if (duration < DAY_IN_MILLIS && minResolution < DAY_IN_MILLIS) {
149      // Even if 'time' actually happened yesterday, we don't format it as
150      // "Yesterday" in this case. Unless the duration is longer than a day,
151      // or minResolution is specified as DAY_IN_MILLIS by user.
152      count = (int)(duration / HOUR_IN_MILLIS);
153      unit = com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit.HOURS;
154    } else if (duration < WEEK_IN_MILLIS && minResolution < WEEK_IN_MILLIS) {
155      count = Math.abs(dayDistance(icuTimeZone, time, now));
156      unit = com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit.DAYS;
157
158      if (count == 2) {
159        // Some locales have special terms for "2 days ago". Return them if
160        // available. Note that we cannot set up direction and unit here and
161        // make it fall through to use the call near the end of the function,
162        // because for locales that don't have special terms for "2 days ago",
163        // icu4j returns an empty string instead of falling back to strings
164        // like "2 days ago".
165        String str;
166        if (past) {
167          synchronized (CACHED_FORMATTERS) {
168            str = getFormatter(icuLocale, style, displayContext)
169                .format(
170                    com.ibm.icu.text.RelativeDateTimeFormatter.Direction.LAST_2,
171                    com.ibm.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY);
172          }
173        } else {
174          synchronized (CACHED_FORMATTERS) {
175            str = getFormatter(icuLocale, style, displayContext)
176                .format(
177                    com.ibm.icu.text.RelativeDateTimeFormatter.Direction.NEXT_2,
178                    com.ibm.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY);
179          }
180        }
181        if (str != null && !str.isEmpty()) {
182          return str;
183        }
184        // Fall back to show something like "2 days ago".
185      } else if (count == 1) {
186        // Show "Yesterday / Tomorrow" instead of "1 day ago / In 1 day".
187        aunit = com.ibm.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY;
188        relative = false;
189      } else if (count == 0) {
190        // Show "Today" if time and now are on the same day.
191        aunit = com.ibm.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY;
192        direction = com.ibm.icu.text.RelativeDateTimeFormatter.Direction.THIS;
193        relative = false;
194      }
195    } else if (minResolution == WEEK_IN_MILLIS) {
196      count = (int)(duration / WEEK_IN_MILLIS);
197      unit = com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit.WEEKS;
198    } else {
199      Calendar timeCalendar = DateUtilsBridge.createIcuCalendar(icuTimeZone, icuLocale, time);
200      // The duration is longer than a week and minResolution is not
201      // WEEK_IN_MILLIS. Return the absolute date instead of relative time.
202
203      // Bug 19822016:
204      // If user doesn't supply the year display flag, we need to explicitly
205      // set that to show / hide the year based on time and now. Otherwise
206      // formatDateRange() would determine that based on the current system
207      // time and may give wrong results.
208      if ((flags & (FORMAT_NO_YEAR | FORMAT_SHOW_YEAR)) == 0) {
209        Calendar nowCalendar = DateUtilsBridge.createIcuCalendar(icuTimeZone, icuLocale, now);
210
211        if (timeCalendar.get(Calendar.YEAR) != nowCalendar.get(Calendar.YEAR)) {
212          flags |= FORMAT_SHOW_YEAR;
213        } else {
214          flags |= FORMAT_NO_YEAR;
215        }
216      }
217      return DateTimeFormat.format(icuLocale, timeCalendar, flags, displayContext);
218    }
219
220    synchronized (CACHED_FORMATTERS) {
221      com.ibm.icu.text.RelativeDateTimeFormatter formatter =
222          getFormatter(icuLocale, style, displayContext);
223      if (relative) {
224        return formatter.format(count, direction, unit);
225      } else {
226        return formatter.format(direction, aunit);
227      }
228    }
229  }
230
231  /**
232   * This is the internal API that implements
233   * DateUtils.getRelativeDateTimeString(long, long, long, long, int), which is
234   * to return a string describing 'time' as a time relative to 'now', formatted
235   * like '[relative time/date], [time]'. More examples can be found in
236   * DateUtils' doc.
237   *
238   * The function is similar to getRelativeTimeSpanString, but it always
239   * appends the absolute time to the relative time string to return
240   * '[relative time/date clause], [absolute time clause]'. It also takes an
241   * extra parameter transitionResolution to determine the format of the date
242   * clause. When the elapsed time is less than the transition resolution, it
243   * displays the relative time string. Otherwise, it gives the absolute
244   * numeric date string as the date clause. With the date and time clauses, it
245   * relies on icu4j's RelativeDateTimeFormatter::combineDateAndTime() to
246   * concatenate the two.
247   *
248   * It takes two additional parameters of Locale and TimeZone than the
249   * DateUtils' API. Caller must specify the locale and timezone.
250   * FORMAT_ABBREV_RELATIVE or FORMAT_ABBREV_ALL can be set in 'flags' to get
251   * the abbreviated forms when they are available.
252   *
253   * Bug 5252772: Since the absolute time will always be part of the result,
254   * minResolution will be set to at least DAY_IN_MILLIS to correctly indicate
255   * the date difference. For example, when it's 1:30 AM, it will return
256   * 'Yesterday, 11:30 PM' for getRelativeDateTimeString(null, null,
257   * now - 2 hours, now, HOUR_IN_MILLIS, DAY_IN_MILLIS, 0), instead of '2
258   * hours ago, 11:30 PM' even with minResolution being HOUR_IN_MILLIS.
259   */
260  public static String getRelativeDateTimeString(Locale locale, java.util.TimeZone tz, long time,
261      long now, long minResolution, long transitionResolution, int flags) {
262
263    if (locale == null) {
264      throw new NullPointerException("locale == null");
265    }
266    if (tz == null) {
267      throw new NullPointerException("tz == null");
268    }
269    ULocale icuLocale = ULocale.forLocale(locale);
270    com.ibm.icu.util.TimeZone icuTimeZone = DateUtilsBridge.icuTimeZone(tz);
271
272    long duration = Math.abs(now - time);
273    // It doesn't make much sense to have results like: "1 week ago, 10:50 AM".
274    if (transitionResolution > WEEK_IN_MILLIS) {
275        transitionResolution = WEEK_IN_MILLIS;
276    }
277    com.ibm.icu.text.RelativeDateTimeFormatter.Style style;
278    if ((flags & (FORMAT_ABBREV_RELATIVE | FORMAT_ABBREV_ALL)) != 0) {
279        style = com.ibm.icu.text.RelativeDateTimeFormatter.Style.SHORT;
280    } else {
281        style = com.ibm.icu.text.RelativeDateTimeFormatter.Style.LONG;
282    }
283
284    Calendar timeCalendar = DateUtilsBridge.createIcuCalendar(icuTimeZone, icuLocale, time);
285    Calendar nowCalendar = DateUtilsBridge.createIcuCalendar(icuTimeZone, icuLocale, now);
286
287    int days = Math.abs(DateUtilsBridge.dayDistance(timeCalendar, nowCalendar));
288
289    // Now get the date clause, either in relative format or the actual date.
290    String dateClause;
291    if (duration < transitionResolution) {
292      // This is to fix bug 5252772. If there is any date difference, we should
293      // promote the minResolution to DAY_IN_MILLIS so that it can display the
294      // date instead of "x hours/minutes ago, [time]".
295      if (days > 0 && minResolution < DAY_IN_MILLIS) {
296         minResolution = DAY_IN_MILLIS;
297      }
298      dateClause = getRelativeTimeSpanString(icuLocale, icuTimeZone, time, now, minResolution,
299          flags, DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE);
300    } else {
301      // We always use fixed flags to format the date clause. User-supplied
302      // flags are ignored.
303      if (timeCalendar.get(Calendar.YEAR) != nowCalendar.get(Calendar.YEAR)) {
304        // Different years
305        flags = FORMAT_SHOW_DATE | FORMAT_SHOW_YEAR | FORMAT_NUMERIC_DATE;
306      } else {
307        // Default
308        flags = FORMAT_SHOW_DATE | FORMAT_NO_YEAR | FORMAT_ABBREV_MONTH;
309      }
310
311      dateClause = DateTimeFormat.format(icuLocale, timeCalendar, flags,
312          DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE);
313    }
314
315    String timeClause = DateTimeFormat.format(icuLocale, timeCalendar, FORMAT_SHOW_TIME,
316        DisplayContext.CAPITALIZATION_NONE);
317
318    // icu4j also has other options available to control the capitalization. We are currently using
319    // the _NONE option only.
320    DisplayContext capitalizationContext = DisplayContext.CAPITALIZATION_NONE;
321
322    // Combine the two clauses, such as '5 days ago, 10:50 AM'.
323    synchronized (CACHED_FORMATTERS) {
324      return getFormatter(icuLocale, style, capitalizationContext)
325              .combineDateAndTime(dateClause, timeClause);
326    }
327  }
328
329  /**
330   * getFormatter() caches the RelativeDateTimeFormatter instances based on
331   * the combination of localeName, sytle and capitalizationContext. It
332   * should always be used along with the action of the formatter in a
333   * synchronized block, because otherwise the formatter returned by
334   * getFormatter() may have been evicted by the time of the call to
335   * formatter->action().
336   */
337  private static com.ibm.icu.text.RelativeDateTimeFormatter getFormatter(
338      ULocale locale, com.ibm.icu.text.RelativeDateTimeFormatter.Style style,
339      DisplayContext displayContext) {
340    String key = locale + "\t" + style + "\t" + displayContext;
341    com.ibm.icu.text.RelativeDateTimeFormatter formatter = CACHED_FORMATTERS.get(key);
342    if (formatter == null) {
343      formatter = com.ibm.icu.text.RelativeDateTimeFormatter.getInstance(
344          locale, null, style, displayContext);
345      CACHED_FORMATTERS.put(key, formatter);
346    }
347    return formatter;
348  }
349
350  // Return the date difference for the two times in a given timezone.
351  private static int dayDistance(com.ibm.icu.util.TimeZone icuTimeZone, long startTime,
352      long endTime) {
353    return julianDay(icuTimeZone, endTime) - julianDay(icuTimeZone, startTime);
354  }
355
356  private static int julianDay(com.ibm.icu.util.TimeZone icuTimeZone, long time) {
357    long utcMs = time + icuTimeZone.getOffset(time);
358    return (int) (utcMs / DAY_IN_MS) + EPOCH_JULIAN_DAY;
359  }
360}
361