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