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