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 parsable, 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 semicolon
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}