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