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 17package libcore.icu; 18 19import java.util.Locale; 20import libcore.util.BasicLruCache; 21 22import com.ibm.icu.text.DisplayContext; 23import com.ibm.icu.util.Calendar; 24import com.ibm.icu.util.ULocale; 25 26import static libcore.icu.DateUtilsBridge.FORMAT_ABBREV_ALL; 27import static libcore.icu.DateUtilsBridge.FORMAT_ABBREV_MONTH; 28import static libcore.icu.DateUtilsBridge.FORMAT_ABBREV_RELATIVE; 29import static libcore.icu.DateUtilsBridge.FORMAT_NO_YEAR; 30import static libcore.icu.DateUtilsBridge.FORMAT_NUMERIC_DATE; 31import static libcore.icu.DateUtilsBridge.FORMAT_SHOW_DATE; 32import static libcore.icu.DateUtilsBridge.FORMAT_SHOW_TIME; 33import static libcore.icu.DateUtilsBridge.FORMAT_SHOW_YEAR; 34 35/** 36 * Exposes icu4j's RelativeDateTimeFormatter. 37 */ 38public 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, com.ibm.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 com.ibm.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 com.ibm.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 com.ibm.icu.text.RelativeDateTimeFormatter.Style style; 121 if ((flags & (FORMAT_ABBREV_RELATIVE | FORMAT_ABBREV_ALL)) != 0) { 122 style = com.ibm.icu.text.RelativeDateTimeFormatter.Style.SHORT; 123 } else { 124 style = com.ibm.icu.text.RelativeDateTimeFormatter.Style.LONG; 125 } 126 127 com.ibm.icu.text.RelativeDateTimeFormatter.Direction direction; 128 if (past) { 129 direction = com.ibm.icu.text.RelativeDateTimeFormatter.Direction.LAST; 130 } else { 131 direction = com.ibm.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 com.ibm.icu.text.RelativeDateTimeFormatter.RelativeUnit unit; 140 com.ibm.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 = com.ibm.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 = com.ibm.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 = com.ibm.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 = com.ibm.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 com.ibm.icu.text.RelativeDateTimeFormatter.Direction.LAST_2, 171 com.ibm.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY); 172 } 173 } else { 174 synchronized (CACHED_FORMATTERS) { 175 str = getFormatter(icuLocale, style, displayContext) 176 .format( 177 com.ibm.icu.text.RelativeDateTimeFormatter.Direction.NEXT_2, 178 com.ibm.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 = com.ibm.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 = com.ibm.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY; 192 direction = com.ibm.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 = com.ibm.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 com.ibm.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 com.ibm.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 com.ibm.icu.text.RelativeDateTimeFormatter.Style style; 278 if ((flags & (FORMAT_ABBREV_RELATIVE | FORMAT_ABBREV_ALL)) != 0) { 279 style = com.ibm.icu.text.RelativeDateTimeFormatter.Style.SHORT; 280 } else { 281 style = com.ibm.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 com.ibm.icu.text.RelativeDateTimeFormatter getFormatter( 338 ULocale locale, com.ibm.icu.text.RelativeDateTimeFormatter.Style style, 339 DisplayContext displayContext) { 340 String key = locale + "\t" + style + "\t" + displayContext; 341 com.ibm.icu.text.RelativeDateTimeFormatter formatter = CACHED_FORMATTERS.get(key); 342 if (formatter == null) { 343 formatter = com.ibm.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(com.ibm.icu.util.TimeZone icuTimeZone, long startTime, 352 long endTime) { 353 return julianDay(icuTimeZone, endTime) - julianDay(icuTimeZone, startTime); 354 } 355 356 private static int julianDay(com.ibm.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