Home | History | Annotate | Download | only in icu
      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> {
     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     android.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       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    */
    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    */
    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.
    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 
    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