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 
17 package libcore.icu;
18 
19 import java.util.Locale;
20 import libcore.util.BasicLruCache;
21 
22 import android.icu.text.DisplayContext;
23 import android.icu.util.Calendar;
24 import android.icu.util.ULocale;
25 
26 import static libcore.icu.DateUtilsBridge.FORMAT_ABBREV_ALL;
27 import static libcore.icu.DateUtilsBridge.FORMAT_ABBREV_MONTH;
28 import static libcore.icu.DateUtilsBridge.FORMAT_ABBREV_RELATIVE;
29 import static libcore.icu.DateUtilsBridge.FORMAT_NO_YEAR;
30 import static libcore.icu.DateUtilsBridge.FORMAT_NUMERIC_DATE;
31 import static libcore.icu.DateUtilsBridge.FORMAT_SHOW_DATE;
32 import static libcore.icu.DateUtilsBridge.FORMAT_SHOW_TIME;
33 import static libcore.icu.DateUtilsBridge.FORMAT_SHOW_YEAR;
34 
35 /**
36  * Exposes icu4j's RelativeDateTimeFormatter.
37  */
38 public 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, android.icu.text.RelativeDateTimeFormatter> {
FormatterCache()56     FormatterCache() {
57       super(8);
58     }
59   }
60 
RelativeDateTimeFormatter()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    */
getRelativeTimeSpanString(Locale locale, java.util.TimeZone tz, long time, long now, long minResolution, int flags)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 
getRelativeTimeSpanString(Locale locale, java.util.TimeZone tz, long time, long now, long minResolution, int flags, DisplayContext displayContext)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     android.icu.util.TimeZone icuTimeZone = DateUtilsBridge.icuTimeZone(tz);
109     return getRelativeTimeSpanString(icuLocale, icuTimeZone, time, now, minResolution, flags,
110         displayContext);
111   }
112 
getRelativeTimeSpanString(ULocale icuLocale, android.icu.util.TimeZone icuTimeZone, long time, long now, long minResolution, int flags, DisplayContext displayContext)113   private static String getRelativeTimeSpanString(ULocale icuLocale,
114       android.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     android.icu.text.RelativeDateTimeFormatter.Style style;
121     if ((flags & (FORMAT_ABBREV_RELATIVE | FORMAT_ABBREV_ALL)) != 0) {
122       style = android.icu.text.RelativeDateTimeFormatter.Style.SHORT;
123     } else {
124       style = android.icu.text.RelativeDateTimeFormatter.Style.LONG;
125     }
126 
127     android.icu.text.RelativeDateTimeFormatter.Direction direction;
128     if (past) {
129       direction = android.icu.text.RelativeDateTimeFormatter.Direction.LAST;
130     } else {
131       direction = android.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     android.icu.text.RelativeDateTimeFormatter.RelativeUnit unit;
140     android.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 = android.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 = android.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 = android.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 = android.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                     android.icu.text.RelativeDateTimeFormatter.Direction.LAST_2,
171                     android.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY);
172           }
173         } else {
174           synchronized (CACHED_FORMATTERS) {
175             str = getFormatter(icuLocale, style, displayContext)
176                 .format(
177                     android.icu.text.RelativeDateTimeFormatter.Direction.NEXT_2,
178                     android.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 = android.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 = android.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY;
192         direction = android.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 = android.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       android.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    */
getRelativeDateTimeString(Locale locale, java.util.TimeZone tz, long time, long now, long minResolution, long transitionResolution, int flags)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     android.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     android.icu.text.RelativeDateTimeFormatter.Style style;
278     if ((flags & (FORMAT_ABBREV_RELATIVE | FORMAT_ABBREV_ALL)) != 0) {
279         style = android.icu.text.RelativeDateTimeFormatter.Style.SHORT;
280     } else {
281         style = android.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    */
getFormatter( ULocale locale, android.icu.text.RelativeDateTimeFormatter.Style style, DisplayContext displayContext)337   private static android.icu.text.RelativeDateTimeFormatter getFormatter(
338       ULocale locale, android.icu.text.RelativeDateTimeFormatter.Style style,
339       DisplayContext displayContext) {
340     String key = locale + "\t" + style + "\t" + displayContext;
341     android.icu.text.RelativeDateTimeFormatter formatter = CACHED_FORMATTERS.get(key);
342     if (formatter == null) {
343       formatter = android.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.
dayDistance(android.icu.util.TimeZone icuTimeZone, long startTime, long endTime)351   private static int dayDistance(android.icu.util.TimeZone icuTimeZone, long startTime,
352       long endTime) {
353     return julianDay(icuTimeZone, endTime) - julianDay(icuTimeZone, startTime);
354   }
355 
julianDay(android.icu.util.TimeZone icuTimeZone, long time)356   private static int julianDay(android.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