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}