001/** 002 * 003 * Copyright © 2014-2018 Florian Schmaus 004 * 005 * Licensed under the Apache License, Version 2.0 (the "License"); 006 * you may not use this file except in compliance with the License. 007 * You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.jxmpp.util; 018 019import java.text.DateFormat; 020import java.text.ParseException; 021import java.text.SimpleDateFormat; 022import java.util.ArrayList; 023import java.util.Calendar; 024import java.util.Collections; 025import java.util.Comparator; 026import java.util.Date; 027import java.util.List; 028import java.util.TimeZone; 029import java.util.regex.Matcher; 030import java.util.regex.Pattern; 031 032/** 033 * Utility class for date and time handling in XMPP. 034 * 035 * @see <a href="http://xmpp.org/extensions/xep-0082.html">XEP-82: XMPP Date and Time Profiles</a> 036 */ 037public class XmppDateTime { 038 039 private static final DateFormatType dateFormatter = DateFormatType.XEP_0082_DATE_PROFILE; 040 private static final Pattern datePattern = Pattern.compile("^\\d+-\\d+-\\d+$"); 041 042 private static final DateFormatType timeFormatter = DateFormatType.XEP_0082_TIME_MILLIS_ZONE_PROFILE; 043 private static final Pattern timePattern = Pattern.compile("^(\\d+:){2}\\d+.\\d+(Z|([+-](\\d+:\\d+)))$"); 044 private static final DateFormatType timeNoZoneFormatter = DateFormatType.XEP_0082_TIME_MILLIS_PROFILE; 045 private static final Pattern timeNoZonePattern = Pattern.compile("^(\\d+:){2}\\d+.\\d+$"); 046 047 private static final DateFormatType timeNoMillisFormatter = DateFormatType.XEP_0082_TIME_ZONE_PROFILE; 048 private static final Pattern timeNoMillisPattern = Pattern.compile("^(\\d+:){2}\\d+(Z|([+-](\\d+:\\d+)))$"); 049 private static final DateFormatType timeNoMillisNoZoneFormatter = DateFormatType.XEP_0082_TIME_PROFILE; 050 private static final Pattern timeNoMillisNoZonePattern = Pattern.compile("^(\\d+:){2}\\d+$"); 051 052 private static final DateFormatType dateTimeFormatter = DateFormatType.XEP_0082_DATETIME_MILLIS_PROFILE; 053 private static final Pattern dateTimePattern = Pattern 054 .compile("^\\d+(-\\d+){2}+T(\\d+:){2}\\d+.\\d+(Z|([+-](\\d+:\\d+)))$"); 055 private static final DateFormatType dateTimeNoMillisFormatter = DateFormatType.XEP_0082_DATETIME_PROFILE; 056 private static final Pattern dateTimeNoMillisPattern = Pattern 057 .compile("^\\d+(-\\d+){2}+T(\\d+:){2}\\d+(Z|([+-](\\d+:\\d+)))$"); 058 059 private static final TimeZone TIME_ZONE_UTC = TimeZone.getTimeZone("UTC"); 060 061 private static final ThreadLocal<DateFormat> xep0091Formatter = new ThreadLocal<DateFormat>() { 062 @Override 063 protected DateFormat initialValue() { 064 DateFormat dateFormat = new SimpleDateFormat("yyyyMMdd'T'HH:mm:ss"); 065 dateFormat.setTimeZone(TIME_ZONE_UTC); 066 return dateFormat; 067 } 068 }; 069 private static final ThreadLocal<DateFormat> xep0091Date6DigitFormatter = new ThreadLocal<DateFormat>() { 070 @Override 071 protected DateFormat initialValue() { 072 DateFormat dateFormat = new SimpleDateFormat("yyyyMd'T'HH:mm:ss"); 073 dateFormat.setTimeZone(TIME_ZONE_UTC); 074 return dateFormat; 075 } 076 }; 077 private static final ThreadLocal<DateFormat> xep0091Date7Digit1MonthFormatter = new ThreadLocal<DateFormat>() { 078 @Override 079 protected DateFormat initialValue() { 080 DateFormat dateFormat = new SimpleDateFormat("yyyyMdd'T'HH:mm:ss"); 081 dateFormat.setTimeZone(TIME_ZONE_UTC); 082 dateFormat.setLenient(false); 083 return dateFormat; 084 } 085 }; 086 private static final ThreadLocal<DateFormat> xep0091Date7Digit2MonthFormatter = new ThreadLocal<DateFormat>() { 087 @Override 088 protected DateFormat initialValue() { 089 DateFormat dateFormat = new SimpleDateFormat("yyyyMMd'T'HH:mm:ss"); 090 dateFormat.setTimeZone(TIME_ZONE_UTC); 091 dateFormat.setLenient(false); 092 return dateFormat; 093 } 094 }; 095 private static final Pattern xep0091Pattern = Pattern.compile("^\\d+T\\d+:\\d+:\\d+$"); 096 097 @SuppressWarnings("ImmutableEnumChecker") 098 private enum DateFormatType { 099 // @formatter:off 100 XEP_0082_DATE_PROFILE("yyyy-MM-dd"), 101 XEP_0082_DATETIME_PROFILE("yyyy-MM-dd'T'HH:mm:ssZ"), 102 XEP_0082_DATETIME_MILLIS_PROFILE("yyyy-MM-dd'T'HH:mm:ss.SSSZ"), 103 XEP_0082_TIME_PROFILE("hh:mm:ss"), 104 XEP_0082_TIME_ZONE_PROFILE("hh:mm:ssZ"), 105 XEP_0082_TIME_MILLIS_PROFILE("hh:mm:ss.SSS"), 106 XEP_0082_TIME_MILLIS_ZONE_PROFILE("hh:mm:ss.SSSZ"), 107 XEP_0091_DATETIME("yyyyMMdd'T'HH:mm:ss"); 108 // @formatter:on 109 110 private final String FORMAT_STRING; 111 private final ThreadLocal<DateFormat> FORMATTER; 112 private final boolean CONVERT_TIMEZONE; 113 114 /** 115 * XEP-0082 allows the fractional second addendum to contain ANY number 116 * of digits. Implementations are therefore free to send as much digits 117 * after the dot as they want, therefore we need to truncate or fill up 118 * milliseconds. Certain platforms are only able to parse up to milliseconds, 119 * so truncate to 3 digits after the dot or fill zeros until 3 digits. 120 */ 121 private final boolean HANDLE_MILLIS; 122 123 DateFormatType(String dateFormat) { 124 FORMAT_STRING = dateFormat; 125 FORMATTER = new ThreadLocal<DateFormat>() { 126 @Override 127 protected DateFormat initialValue() { 128 DateFormat dateFormat = new SimpleDateFormat(FORMAT_STRING); 129 dateFormat.setTimeZone(TIME_ZONE_UTC); 130 return dateFormat; 131 } 132 }; 133 CONVERT_TIMEZONE = dateFormat.charAt(dateFormat.length() - 1) == 'Z'; 134 HANDLE_MILLIS = dateFormat.contains("SSS"); 135 } 136 137 private String format(Date date) { 138 String res = FORMATTER.get().format(date); 139 if (CONVERT_TIMEZONE) { 140 res = convertRfc822TimezoneToXep82(res); 141 } 142 return res; 143 } 144 145 private Date parse(String dateString) throws ParseException { 146 if (CONVERT_TIMEZONE) { 147 dateString = convertXep82TimezoneToRfc822(dateString); 148 } 149 if (HANDLE_MILLIS) { 150 dateString = handleMilliseconds(dateString); 151 } 152 return FORMATTER.get().parse(dateString); 153 } 154 } 155 156 private static final List<PatternCouplings> couplings = new ArrayList<PatternCouplings>(); 157 158 static { 159 couplings.add(new PatternCouplings(datePattern, dateFormatter)); 160 couplings.add(new PatternCouplings(dateTimePattern, dateTimeFormatter)); 161 couplings.add(new PatternCouplings(dateTimeNoMillisPattern, dateTimeNoMillisFormatter)); 162 couplings.add(new PatternCouplings(timePattern, timeFormatter)); 163 couplings.add(new PatternCouplings(timeNoZonePattern, timeNoZoneFormatter)); 164 couplings.add(new PatternCouplings(timeNoMillisPattern, timeNoMillisFormatter)); 165 couplings.add(new PatternCouplings(timeNoMillisNoZonePattern, timeNoMillisNoZoneFormatter)); 166 } 167 168 /** 169 * Parses the given date string in the <a 170 * href="http://xmpp.org/extensions/xep-0082.html">XEP-0082 - XMPP Date and 171 * Time Profiles</a>. 172 * 173 * @param dateString 174 * the date string to parse 175 * @return the parsed Date 176 * @throws ParseException 177 * if the specified string cannot be parsed 178 */ 179 public static Date parseXEP0082Date(String dateString) throws ParseException { 180 for (PatternCouplings coupling : couplings) { 181 Matcher matcher = coupling.pattern.matcher(dateString); 182 183 if (matcher.matches()) { 184 return coupling.formatter.parse(dateString); 185 } 186 } 187 /* 188 * We assume it is the XEP-0082 DateTime profile with no milliseconds at 189 * this point. If it isn't, is is just not parseable, then we attempt to 190 * parse it regardless and let it throw the ParseException. 191 */ 192 return dateTimeNoMillisFormatter.parse(dateString); 193 } 194 195 /** 196 * Parses the given date string in either of the three profiles of <a 197 * href="http://xmpp.org/extensions/xep-0082.html">XEP-0082 - XMPP Date and 198 * Time Profiles</a> or <a 199 * href="http://xmpp.org/extensions/xep-0091.html">XEP-0091 - Legacy Delayed 200 * Delivery</a> format. 201 * <p> 202 * This method uses internal date formatters and is thus threadsafe. 203 * 204 * @param dateString 205 * the date string to parse 206 * @return the parsed Date 207 * @throws ParseException 208 * if the specified string cannot be parsed 209 */ 210 public static Date parseDate(String dateString) throws ParseException { 211 Matcher matcher = xep0091Pattern.matcher(dateString); 212 213 /* 214 * if date is in XEP-0091 format handle ambiguous dates missing the 215 * leading zero in month and day 216 */ 217 if (matcher.matches()) { 218 int length = dateString.split("T")[0].length(); 219 220 if (length < 8) { 221 Date date = handleDateWithMissingLeadingZeros(dateString, length); 222 223 if (date != null) 224 return date; 225 } else { 226 return xep0091Formatter.get().parse(dateString); 227 } 228 } 229 // Assume XEP-82 date if Matcher does not match 230 return parseXEP0082Date(dateString); 231 } 232 233 /** 234 * Formats a Date into a XEP-0082 - XMPP Date and Time Profiles string. 235 * 236 * @param date 237 * the time value to be formatted into a time string 238 * @return the formatted time string in XEP-0082 format 239 */ 240 public static String formatXEP0082Date(Date date) { 241 return dateTimeFormatter.format(date); 242 } 243 244 /** 245 * Converts a XEP-0082 date String's time zone definition into a RFC822 time 246 * zone definition. The major difference is that XEP-0082 uses a smicolon 247 * between hours and minutes and RFC822 does not. 248 * 249 * @param dateString the date String. 250 * @return the String with converted timezone 251 */ 252 public static String convertXep82TimezoneToRfc822(String dateString) { 253 if (dateString.charAt(dateString.length() - 1) == 'Z') { 254 return dateString.replace("Z", "+0000"); 255 } else { 256 // If the time zone wasn't specified with 'Z', then it's in 257 // ISO8601 format (i.e. '(+|-)HH:mm') 258 // RFC822 needs a similar format just without the colon (i.e. 259 // '(+|-)HHmm)'), so remove it 260 return dateString.replaceAll("([\\+\\-]\\d\\d):(\\d\\d)", "$1$2"); 261 } 262 } 263 264 /** 265 * Convert a RFC 822 Timezone to the Timezone format used in XEP-82. 266 * 267 * @param dateString the input date String. 268 * @return the input String with the timezone converted to XEP-82. 269 */ 270 public static String convertRfc822TimezoneToXep82(String dateString) { 271 int length = dateString.length(); 272 String res = dateString.substring(0, length - 2); 273 res += ':'; 274 res += dateString.substring(length - 2, length); 275 return res; 276 } 277 278 /** 279 * Converts a time zone to the String format as specified in XEP-0082. 280 * 281 * @param timeZone the time zone to convert. 282 * @return the String representation of the TimeZone 283 */ 284 public static String asString(TimeZone timeZone) { 285 int rawOffset = timeZone.getRawOffset(); 286 int hours = rawOffset / (1000 * 60 * 60); 287 int minutes = Math.abs((rawOffset / (1000 * 60)) - (hours * 60)); 288 return String.format("%+d:%02d", hours, minutes); 289 } 290 291 /** 292 * Parses the given date string in different ways and returns the date that 293 * lies in the past and/or is nearest to the current date-time. 294 * 295 * @param stampString 296 * date in string representation 297 * @param dateLength the length of the date prefix of stampString 298 * @return the parsed date 299 * @throws ParseException 300 * The date string was of an unknown format 301 */ 302 private static Date handleDateWithMissingLeadingZeros(String stampString, int dateLength) throws ParseException { 303 if (dateLength == 6) { 304 return xep0091Date6DigitFormatter.get().parse(stampString); 305 } 306 Calendar now = Calendar.getInstance(); 307 308 Calendar oneDigitMonth = parseXEP91Date(stampString, xep0091Date7Digit1MonthFormatter.get()); 309 Calendar twoDigitMonth = parseXEP91Date(stampString, xep0091Date7Digit2MonthFormatter.get()); 310 311 List<Calendar> dates = filterDatesBefore(now, oneDigitMonth, twoDigitMonth); 312 313 if (!dates.isEmpty()) { 314 return determineNearestDate(now, dates).getTime(); 315 } 316 return null; 317 } 318 319 private static Calendar parseXEP91Date(String stampString, DateFormat dateFormat) { 320 try { 321 dateFormat.parse(stampString); 322 return dateFormat.getCalendar(); 323 } catch (ParseException e) { 324 return null; 325 } 326 } 327 328 private static List<Calendar> filterDatesBefore(Calendar now, Calendar... dates) { 329 List<Calendar> result = new ArrayList<Calendar>(); 330 331 for (Calendar calendar : dates) { 332 if (calendar != null && calendar.before(now)) { 333 result.add(calendar); 334 } 335 } 336 337 return result; 338 } 339 340 341 /** 342 * A pattern with 3 capturing groups, the second one are at least 1 digits 343 * after the 'dot'. The last one is the timezone definition, either 'Z', 344 * '+1234' or '-1234'. 345 */ 346 private static final Pattern SECOND_FRACTION = Pattern.compile(".*\\.(\\d{1,})(Z|((\\+|-)\\d{4}))"); 347 348 /** 349 * Handle the milliseconds. This means either fill up with zeros or 350 * truncate the date String so that the fractional second addendum only 351 * contains 3 digits. Returns the given string unmodified if it doesn't 352 * match {@link #SECOND_FRACTION}. 353 * 354 * @param dateString the date string 355 * @return the date String where the fractional second addendum is a most 3 356 * digits 357 */ 358 private static String handleMilliseconds(String dateString) { 359 Matcher matcher = SECOND_FRACTION.matcher(dateString); 360 if (!matcher.matches()) { 361 // The date string does not contain any milliseconds 362 return dateString; 363 } 364 365 int fractionalSecondsDigitCount = matcher.group(1).length(); 366 if (fractionalSecondsDigitCount == 3) { 367 // The date string has exactly 3 fractional second digits 368 return dateString; 369 } 370 371 // Gather information about the date string 372 int posDecimal = dateString.indexOf("."); 373 StringBuilder sb = new StringBuilder(dateString.length() - fractionalSecondsDigitCount + 3); 374 if (fractionalSecondsDigitCount > 3) { 375 // Append only 3 fractional digits after posDecimal 376 sb.append(dateString.substring(0, posDecimal + 4)); 377 } else { 378 // The date string has less then 3 fractional second digits 379 sb.append(dateString.substring(0, posDecimal + fractionalSecondsDigitCount + 1)); 380 // Fill up the "missing" fractional second digits with zeros 381 for (int i = fractionalSecondsDigitCount; i < 3; i++) { 382 sb.append('0'); 383 } 384 } 385 // Append the timezone definition 386 sb.append(dateString.substring(posDecimal + fractionalSecondsDigitCount + 1)); 387 return sb.toString(); 388 } 389 390 private static Calendar determineNearestDate(final Calendar now, List<Calendar> dates) { 391 392 Collections.sort(dates, new Comparator<Calendar>() { 393 394 @Override 395 public int compare(Calendar o1, Calendar o2) { 396 Long diff1 = now.getTimeInMillis() - o1.getTimeInMillis(); 397 Long diff2 = now.getTimeInMillis() - o2.getTimeInMillis(); 398 return diff1.compareTo(diff2); 399 } 400 401 }); 402 403 return dates.get(0); 404 } 405 406 private static class PatternCouplings { 407 final Pattern pattern; 408 final DateFormatType formatter; 409 410 PatternCouplings(Pattern datePattern, DateFormatType dateFormat) { 411 pattern = datePattern; 412 formatter = dateFormat; 413 } 414 } 415}