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