summaryrefslogtreecommitdiffstats
path: root/luni/src/main/java/libcore/icu/RelativeDateTimeFormatter.java
blob: e2afa61b1428c297aa4a9967b18cdd09a72131f8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
/*
 * Copyright (C) 2015 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 libcore.icu;

import java.util.Locale;
import libcore.util.BasicLruCache;

import com.ibm.icu.text.DisplayContext;
import com.ibm.icu.util.Calendar;
import com.ibm.icu.util.ULocale;

import static libcore.icu.DateUtilsBridge.FORMAT_ABBREV_ALL;
import static libcore.icu.DateUtilsBridge.FORMAT_ABBREV_MONTH;
import static libcore.icu.DateUtilsBridge.FORMAT_ABBREV_RELATIVE;
import static libcore.icu.DateUtilsBridge.FORMAT_NO_YEAR;
import static libcore.icu.DateUtilsBridge.FORMAT_NUMERIC_DATE;
import static libcore.icu.DateUtilsBridge.FORMAT_SHOW_DATE;
import static libcore.icu.DateUtilsBridge.FORMAT_SHOW_TIME;
import static libcore.icu.DateUtilsBridge.FORMAT_SHOW_YEAR;

/**
 * Exposes icu4j's RelativeDateTimeFormatter.
 */
public final class RelativeDateTimeFormatter {

  public static final long SECOND_IN_MILLIS = 1000;
  public static final long MINUTE_IN_MILLIS = SECOND_IN_MILLIS * 60;
  public static final long HOUR_IN_MILLIS = MINUTE_IN_MILLIS * 60;
  public static final long DAY_IN_MILLIS = HOUR_IN_MILLIS * 24;
  public static final long WEEK_IN_MILLIS = DAY_IN_MILLIS * 7;
  // YEAR_IN_MILLIS considers 364 days as a year. However, since this
  // constant comes from public API in DateUtils, it cannot be fixed here.
  public static final long YEAR_IN_MILLIS = WEEK_IN_MILLIS * 52;

  private static final int DAY_IN_MS = 24 * 60 * 60 * 1000;
  private static final int EPOCH_JULIAN_DAY = 2440588;

  private static final FormatterCache CACHED_FORMATTERS = new FormatterCache();

  static class FormatterCache
      extends BasicLruCache<String, com.ibm.icu.text.RelativeDateTimeFormatter> {
    FormatterCache() {
      super(8);
    }
  }

  private RelativeDateTimeFormatter() {
  }

  /**
   * This is the internal API that implements the functionality of
   * DateUtils.getRelativeTimeSpanString(long, long, long, int), which is to
   * return a string describing 'time' as a time relative to 'now' such as
   * '5 minutes ago', or 'in 2 days'. More examples can be found in DateUtils'
   * doc.
   *
   * In the implementation below, it selects the appropriate time unit based on
   * the elapsed time between time' and 'now', e.g. minutes, days and etc.
   * Callers may also specify the desired minimum resolution to show in the
   * result. For example, '45 minutes ago' will become '0 hours ago' when
   * minResolution is HOUR_IN_MILLIS. Once getting the quantity and unit to
   * display, it calls icu4j's RelativeDateTimeFormatter to format the actual
   * string according to the given locale.
   *
   * Note that when minResolution is set to DAY_IN_MILLIS, it returns the
   * result depending on the actual date difference. For example, it will
   * return 'Yesterday' even if 'time' was less than 24 hours ago but falling
   * onto a different calendar day.
   *
   * It takes two additional parameters of Locale and TimeZone than the
   * DateUtils' API. Caller must specify the locale and timezone.
   * FORMAT_ABBREV_RELATIVE or FORMAT_ABBREV_ALL can be set in 'flags' to get
   * the abbreviated forms when available. When 'time' equals to 'now', it
   * always // returns a string like '0 seconds/minutes/... ago' according to
   * minResolution.
   */
  public static String getRelativeTimeSpanString(Locale locale, java.util.TimeZone tz, long time,
      long now, long minResolution, int flags) {
    // Android has been inconsistent about capitalization in the past. e.g. bug http://b/20247811.
    // Now we capitalize everything consistently.
    final DisplayContext displayContext = DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE;
    return getRelativeTimeSpanString(locale, tz, time, now, minResolution, flags, displayContext);
  }

  public static String getRelativeTimeSpanString(Locale locale, java.util.TimeZone tz, long time,
      long now, long minResolution, int flags, DisplayContext displayContext) {
    if (locale == null) {
      throw new NullPointerException("locale == null");
    }
    if (tz == null) {
      throw new NullPointerException("tz == null");
    }
    ULocale icuLocale = ULocale.forLocale(locale);
    com.ibm.icu.util.TimeZone icuTimeZone = DateUtilsBridge.icuTimeZone(tz);
    return getRelativeTimeSpanString(icuLocale, icuTimeZone, time, now, minResolution, flags,
        displayContext);
  }

  private static String getRelativeTimeSpanString(ULocale icuLocale,
      com.ibm.icu.util.TimeZone icuTimeZone, long time, long now, long minResolution, int flags,
      DisplayContext displayContext) {

    long duration = Math.abs(now - time);
    boolean past = (now >= time);

    com.ibm.icu.text.RelativeDateTimeFormatter.Style style;
    if ((flags & (FORMAT_ABBREV_RELATIVE | FORMAT_ABBREV_ALL)) != 0) {
      style = com.ibm.icu.text.RelativeDateTimeFormatter.Style.SHORT;
    } else {
      style = com.ibm.icu.text.RelativeDateTimeFormatter.Style.LONG;
    }

    com.ibm.icu.text.RelativeDateTimeFormatter.Direction direction;
    if (past) {
      direction = com.ibm.icu.text.RelativeDateTimeFormatter.Direction.LAST;
    } else {
      direction = com.ibm.icu.text.RelativeDateTimeFormatter.Direction.NEXT;
    }

    // 'relative' defaults to true as we are generating relative time span
    // string. It will be set to false when we try to display strings without
    // a quantity, such as 'Yesterday', etc.
    boolean relative = true;
    int count;
    com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit unit;
    com.ibm.icu.text.RelativeDateTimeFormatter.AbsoluteUnit aunit = null;

    if (duration < MINUTE_IN_MILLIS && minResolution < MINUTE_IN_MILLIS) {
      count = (int)(duration / SECOND_IN_MILLIS);
      unit = com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit.SECONDS;
    } else if (duration < HOUR_IN_MILLIS && minResolution < HOUR_IN_MILLIS) {
      count = (int)(duration / MINUTE_IN_MILLIS);
      unit = com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit.MINUTES;
    } else if (duration < DAY_IN_MILLIS && minResolution < DAY_IN_MILLIS) {
      // Even if 'time' actually happened yesterday, we don't format it as
      // "Yesterday" in this case. Unless the duration is longer than a day,
      // or minResolution is specified as DAY_IN_MILLIS by user.
      count = (int)(duration / HOUR_IN_MILLIS);
      unit = com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit.HOURS;
    } else if (duration < WEEK_IN_MILLIS && minResolution < WEEK_IN_MILLIS) {
      count = Math.abs(dayDistance(icuTimeZone, time, now));
      unit = com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit.DAYS;

      if (count == 2) {
        // Some locales have special terms for "2 days ago". Return them if
        // available. Note that we cannot set up direction and unit here and
        // make it fall through to use the call near the end of the function,
        // because for locales that don't have special terms for "2 days ago",
        // icu4j returns an empty string instead of falling back to strings
        // like "2 days ago".
        String str;
        if (past) {
          synchronized (CACHED_FORMATTERS) {
            str = getFormatter(icuLocale, style, displayContext)
                .format(
                    com.ibm.icu.text.RelativeDateTimeFormatter.Direction.LAST_2,
                    com.ibm.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY);
          }
        } else {
          synchronized (CACHED_FORMATTERS) {
            str = getFormatter(icuLocale, style, displayContext)
                .format(
                    com.ibm.icu.text.RelativeDateTimeFormatter.Direction.NEXT_2,
                    com.ibm.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY);
          }
        }
        if (str != null && !str.isEmpty()) {
          return str;
        }
        // Fall back to show something like "2 days ago".
      } else if (count == 1) {
        // Show "Yesterday / Tomorrow" instead of "1 day ago / In 1 day".
        aunit = com.ibm.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY;
        relative = false;
      } else if (count == 0) {
        // Show "Today" if time and now are on the same day.
        aunit = com.ibm.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY;
        direction = com.ibm.icu.text.RelativeDateTimeFormatter.Direction.THIS;
        relative = false;
      }
    } else if (minResolution == WEEK_IN_MILLIS) {
      count = (int)(duration / WEEK_IN_MILLIS);
      unit = com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit.WEEKS;
    } else {
      Calendar timeCalendar = DateUtilsBridge.createIcuCalendar(icuTimeZone, icuLocale, time);
      // The duration is longer than a week and minResolution is not
      // WEEK_IN_MILLIS. Return the absolute date instead of relative time.

      // Bug 19822016:
      // If user doesn't supply the year display flag, we need to explicitly
      // set that to show / hide the year based on time and now. Otherwise
      // formatDateRange() would determine that based on the current system
      // time and may give wrong results.
      if ((flags & (FORMAT_NO_YEAR | FORMAT_SHOW_YEAR)) == 0) {
        Calendar nowCalendar = DateUtilsBridge.createIcuCalendar(icuTimeZone, icuLocale, now);

        if (timeCalendar.get(Calendar.YEAR) != nowCalendar.get(Calendar.YEAR)) {
          flags |= FORMAT_SHOW_YEAR;
        } else {
          flags |= FORMAT_NO_YEAR;
        }
      }
      return DateTimeFormat.format(icuLocale, timeCalendar, flags, displayContext);
    }

    synchronized (CACHED_FORMATTERS) {
      com.ibm.icu.text.RelativeDateTimeFormatter formatter =
          getFormatter(icuLocale, style, displayContext);
      if (relative) {
        return formatter.format(count, direction, unit);
      } else {
        return formatter.format(direction, aunit);
      }
    }
  }

  /**
   * This is the internal API that implements
   * DateUtils.getRelativeDateTimeString(long, long, long, long, int), which is
   * to return a string describing 'time' as a time relative to 'now', formatted
   * like '[relative time/date], [time]'. More examples can be found in
   * DateUtils' doc.
   *
   * The function is similar to getRelativeTimeSpanString, but it always
   * appends the absolute time to the relative time string to return
   * '[relative time/date clause], [absolute time clause]'. It also takes an
   * extra parameter transitionResolution to determine the format of the date
   * clause. When the elapsed time is less than the transition resolution, it
   * displays the relative time string. Otherwise, it gives the absolute
   * numeric date string as the date clause. With the date and time clauses, it
   * relies on icu4j's RelativeDateTimeFormatter::combineDateAndTime() to
   * concatenate the two.
   *
   * It takes two additional parameters of Locale and TimeZone than the
   * DateUtils' API. Caller must specify the locale and timezone.
   * FORMAT_ABBREV_RELATIVE or FORMAT_ABBREV_ALL can be set in 'flags' to get
   * the abbreviated forms when they are available.
   *
   * Bug 5252772: Since the absolute time will always be part of the result,
   * minResolution will be set to at least DAY_IN_MILLIS to correctly indicate
   * the date difference. For example, when it's 1:30 AM, it will return
   * 'Yesterday, 11:30 PM' for getRelativeDateTimeString(null, null,
   * now - 2 hours, now, HOUR_IN_MILLIS, DAY_IN_MILLIS, 0), instead of '2
   * hours ago, 11:30 PM' even with minResolution being HOUR_IN_MILLIS.
   */
  public static String getRelativeDateTimeString(Locale locale, java.util.TimeZone tz, long time,
      long now, long minResolution, long transitionResolution, int flags) {

    if (locale == null) {
      throw new NullPointerException("locale == null");
    }
    if (tz == null) {
      throw new NullPointerException("tz == null");
    }
    ULocale icuLocale = ULocale.forLocale(locale);
    com.ibm.icu.util.TimeZone icuTimeZone = DateUtilsBridge.icuTimeZone(tz);

    long duration = Math.abs(now - time);
    // It doesn't make much sense to have results like: "1 week ago, 10:50 AM".
    if (transitionResolution > WEEK_IN_MILLIS) {
        transitionResolution = WEEK_IN_MILLIS;
    }
    com.ibm.icu.text.RelativeDateTimeFormatter.Style style;
    if ((flags & (FORMAT_ABBREV_RELATIVE | FORMAT_ABBREV_ALL)) != 0) {
        style = com.ibm.icu.text.RelativeDateTimeFormatter.Style.SHORT;
    } else {
        style = com.ibm.icu.text.RelativeDateTimeFormatter.Style.LONG;
    }

    Calendar timeCalendar = DateUtilsBridge.createIcuCalendar(icuTimeZone, icuLocale, time);
    Calendar nowCalendar = DateUtilsBridge.createIcuCalendar(icuTimeZone, icuLocale, now);

    int days = Math.abs(DateUtilsBridge.dayDistance(timeCalendar, nowCalendar));

    // Now get the date clause, either in relative format or the actual date.
    String dateClause;
    if (duration < transitionResolution) {
      // This is to fix bug 5252772. If there is any date difference, we should
      // promote the minResolution to DAY_IN_MILLIS so that it can display the
      // date instead of "x hours/minutes ago, [time]".
      if (days > 0 && minResolution < DAY_IN_MILLIS) {
         minResolution = DAY_IN_MILLIS;
      }
      dateClause = getRelativeTimeSpanString(icuLocale, icuTimeZone, time, now, minResolution,
          flags, DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE);
    } else {
      // We always use fixed flags to format the date clause. User-supplied
      // flags are ignored.
      if (timeCalendar.get(Calendar.YEAR) != nowCalendar.get(Calendar.YEAR)) {
        // Different years
        flags = FORMAT_SHOW_DATE | FORMAT_SHOW_YEAR | FORMAT_NUMERIC_DATE;
      } else {
        // Default
        flags = FORMAT_SHOW_DATE | FORMAT_NO_YEAR | FORMAT_ABBREV_MONTH;
      }

      dateClause = DateTimeFormat.format(icuLocale, timeCalendar, flags,
          DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE);
    }

    String timeClause = DateTimeFormat.format(icuLocale, timeCalendar, FORMAT_SHOW_TIME,
        DisplayContext.CAPITALIZATION_NONE);

    // icu4j also has other options available to control the capitalization. We are currently using
    // the _NONE option only.
    DisplayContext capitalizationContext = DisplayContext.CAPITALIZATION_NONE;

    // Combine the two clauses, such as '5 days ago, 10:50 AM'.
    synchronized (CACHED_FORMATTERS) {
      return getFormatter(icuLocale, style, capitalizationContext)
              .combineDateAndTime(dateClause, timeClause);
    }
  }

  /**
   * getFormatter() caches the RelativeDateTimeFormatter instances based on
   * the combination of localeName, sytle and capitalizationContext. It
   * should always be used along with the action of the formatter in a
   * synchronized block, because otherwise the formatter returned by
   * getFormatter() may have been evicted by the time of the call to
   * formatter->action().
   */
  private static com.ibm.icu.text.RelativeDateTimeFormatter getFormatter(
      ULocale locale, com.ibm.icu.text.RelativeDateTimeFormatter.Style style,
      DisplayContext displayContext) {
    String key = locale + "\t" + style + "\t" + displayContext;
    com.ibm.icu.text.RelativeDateTimeFormatter formatter = CACHED_FORMATTERS.get(key);
    if (formatter == null) {
      formatter = com.ibm.icu.text.RelativeDateTimeFormatter.getInstance(
          locale, null, style, displayContext);
      CACHED_FORMATTERS.put(key, formatter);
    }
    return formatter;
  }

  // Return the date difference for the two times in a given timezone.
  private static int dayDistance(com.ibm.icu.util.TimeZone icuTimeZone, long startTime,
      long endTime) {
    return julianDay(icuTimeZone, endTime) - julianDay(icuTimeZone, startTime);
  }

  private static int julianDay(com.ibm.icu.util.TimeZone icuTimeZone, long time) {
    long utcMs = time + icuTimeZone.getOffset(time);
    return (int) (utcMs / DAY_IN_MS) + EPOCH_JULIAN_DAY;
  }
}