001/**
002 *
003 * Copyright the original author or authors
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.jivesoftware.smack.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
032public class XmppDateTime {
033
034    private static final DateFormatType dateFormatter = DateFormatType.XEP_0082_DATE_PROFILE;
035    private static final Pattern datePattern = Pattern.compile("^\\d+-\\d+-\\d+$");
036
037    private static final DateFormatType timeFormatter = DateFormatType.XEP_0082_TIME_MILLIS_ZONE_PROFILE;
038    private static final Pattern timePattern = Pattern.compile("^(\\d+:){2}\\d+.\\d+(Z|([+-](\\d+:\\d+)))$");
039    private static final DateFormatType timeNoZoneFormatter = DateFormatType.XEP_0082_TIME_MILLIS_PROFILE;
040    private static final Pattern timeNoZonePattern = Pattern.compile("^(\\d+:){2}\\d+.\\d+$");
041
042    private static final DateFormatType timeNoMillisFormatter = DateFormatType.XEP_0082_TIME_ZONE_PROFILE;
043    private static final Pattern timeNoMillisPattern = Pattern.compile("^(\\d+:){2}\\d+(Z|([+-](\\d+:\\d+)))$");
044    private static final DateFormatType timeNoMillisNoZoneFormatter = DateFormatType.XEP_0082_TIME_PROFILE;
045    private static final Pattern timeNoMillisNoZonePattern = Pattern.compile("^(\\d+:){2}\\d+$");
046
047    private static final DateFormatType dateTimeFormatter = DateFormatType.XEP_0082_DATETIME_MILLIS_PROFILE;
048    private static final Pattern dateTimePattern = Pattern.compile("^\\d+(-\\d+){2}+T(\\d+:){2}\\d+.\\d+(Z|([+-](\\d+:\\d+)))?$");
049    private static final DateFormatType dateTimeNoMillisFormatter = DateFormatType.XEP_0082_DATETIME_PROFILE;
050    private static final Pattern dateTimeNoMillisPattern = Pattern.compile("^\\d+(-\\d+){2}+T(\\d+:){2}\\d+(Z|([+-](\\d+:\\d+)))?$");
051
052    private static final DateFormat xep0091Formatter = new SimpleDateFormat("yyyyMMdd'T'HH:mm:ss");
053    private static final DateFormat xep0091Date6DigitFormatter = new SimpleDateFormat(
054                    "yyyyMd'T'HH:mm:ss");
055    private static final DateFormat xep0091Date7Digit1MonthFormatter = new SimpleDateFormat(
056                    "yyyyMdd'T'HH:mm:ss");
057    private static final DateFormat xep0091Date7Digit2MonthFormatter = new SimpleDateFormat(
058                    "yyyyMMd'T'HH:mm:ss");
059    private static final Pattern xep0091Pattern = Pattern.compile("^\\d+T\\d+:\\d+:\\d+$");
060
061    public static enum DateFormatType {
062        // @formatter:off
063        XEP_0082_DATE_PROFILE("yyyy-MM-dd"),
064        XEP_0082_DATETIME_PROFILE("yyyy-MM-dd'T'HH:mm:ssZ"),
065        XEP_0082_DATETIME_MILLIS_PROFILE("yyyy-MM-dd'T'HH:mm:ss.SSSZ"),
066        XEP_0082_TIME_PROFILE("hh:mm:ss"),
067        XEP_0082_TIME_ZONE_PROFILE("hh:mm:ssZ"),
068        XEP_0082_TIME_MILLIS_PROFILE("hh:mm:ss.SSS"),
069        XEP_0082_TIME_MILLIS_ZONE_PROFILE("hh:mm:ss.SSSZ"),
070        XEP_0091_DATETIME("yyyyMMdd'T'HH:mm:ss");
071        // @formatter:on
072
073        private final String FORMAT_STRING;
074        private final DateFormat FORMATTER;
075        private final boolean CONVERT_TIMEZONE;
076
077        private DateFormatType(String dateFormat) {
078            FORMAT_STRING = dateFormat;
079            FORMATTER = new SimpleDateFormat(FORMAT_STRING);
080            FORMATTER.setTimeZone(TimeZone.getTimeZone("UTC"));
081            CONVERT_TIMEZONE = dateFormat.charAt(dateFormat.length() - 1) == 'Z';
082       }
083
084        public String format(Date date) {
085            String res;
086            synchronized(FORMATTER) {
087                res = FORMATTER.format(date);
088            }
089            if (CONVERT_TIMEZONE) {
090                res = convertRfc822TimezoneToXep82(res);
091            }
092            return res;
093        }
094
095        public Date parse(String dateString) throws ParseException {
096            if (CONVERT_TIMEZONE) {
097                dateString = convertXep82TimezoneToRfc822(dateString);
098            }
099            synchronized(FORMATTER) {
100                return FORMATTER.parse(dateString);
101            }
102        }
103    }
104
105    private static final List<PatternCouplings> couplings = new ArrayList<PatternCouplings>();
106
107    static {
108        TimeZone utc = TimeZone.getTimeZone("UTC");
109
110        xep0091Formatter.setTimeZone(utc);
111        xep0091Date6DigitFormatter.setTimeZone(utc);
112        xep0091Date7Digit1MonthFormatter.setTimeZone(utc);
113        xep0091Date7Digit1MonthFormatter.setLenient(false);
114        xep0091Date7Digit2MonthFormatter.setTimeZone(utc);
115        xep0091Date7Digit2MonthFormatter.setLenient(false);
116
117        couplings.add(new PatternCouplings(datePattern, dateFormatter));
118        couplings.add(new PatternCouplings(dateTimePattern, dateTimeFormatter));
119        couplings.add(new PatternCouplings(dateTimeNoMillisPattern, dateTimeNoMillisFormatter));
120        couplings.add(new PatternCouplings(timePattern, timeFormatter));
121        couplings.add(new PatternCouplings(timeNoZonePattern, timeNoZoneFormatter));
122        couplings.add(new PatternCouplings(timeNoMillisPattern, timeNoMillisFormatter));
123        couplings.add(new PatternCouplings(timeNoMillisNoZonePattern, timeNoMillisNoZoneFormatter));
124    }
125
126    /**
127     * Parses the given date string in the <a
128     * href="http://xmpp.org/extensions/xep-0082.html">XEP-0082 - XMPP Date and Time Profiles</a>.
129     * 
130     * @param dateString the date string to parse
131     * @return the parsed Date
132     * @throws ParseException if the specified string cannot be parsed
133     * @deprecated Use {@link #parseDate(String)} instead.
134     */
135    public static Date parseXEP0082Date(String dateString) throws ParseException {
136        return parseDate(dateString);
137    }
138
139    /**
140     * Parses the given date string in either of the three profiles of <a
141     * href="http://xmpp.org/extensions/xep-0082.html">XEP-0082 - XMPP Date and Time Profiles</a> or
142     * <a href="http://xmpp.org/extensions/xep-0091.html">XEP-0091 - Legacy Delayed Delivery</a>
143     * format.
144     * <p>
145     * This method uses internal date formatters and is thus threadsafe.
146     * 
147     * @param dateString the date string to parse
148     * @return the parsed Date
149     * @throws ParseException if the specified string cannot be parsed
150     */
151    public static Date parseDate(String dateString) throws ParseException {
152        Matcher matcher = xep0091Pattern.matcher(dateString);
153
154        /*
155         * if date is in XEP-0091 format handle ambiguous dates missing the leading zero in month
156         * and day
157         */
158        if (matcher.matches()) {
159            int length = dateString.split("T")[0].length();
160
161            if (length < 8) {
162                Date date = handleDateWithMissingLeadingZeros(dateString, length);
163
164                if (date != null)
165                    return date;
166            }
167            else {
168                synchronized (xep0091Formatter) {
169                    return xep0091Formatter.parse(dateString);
170                }
171            }
172        }
173        else {
174            for (PatternCouplings coupling : couplings) {
175                matcher = coupling.pattern.matcher(dateString);
176
177                if (matcher.matches()) {
178                    return coupling.formatter.parse(dateString);
179                }
180            }
181        }
182
183        /*
184         * We assume it is the XEP-0082 DateTime profile with no milliseconds at this point. If it
185         * isn't, is is just not parseable, then we attempt to parse it regardless and let it throw
186         * the ParseException.
187         */
188        synchronized (dateTimeNoMillisFormatter) {
189            return dateTimeNoMillisFormatter.parse(dateString);
190        }
191    }
192
193    /**
194     * Formats a Date into a XEP-0082 - XMPP Date and Time Profiles string.
195     * 
196     * @param date the time value to be formatted into a time string
197     * @return the formatted time string in XEP-0082 format
198     */
199    public static String formatXEP0082Date(Date date) {
200        synchronized (dateTimeFormatter) {
201            return dateTimeFormatter.format(date);
202        }
203    }
204
205    /**
206     * Converts a XEP-0082 date String's time zone definition into a RFC822 time zone definition.
207     * The major difference is that XEP-0082 uses a smicolon between hours and minutes and RFC822
208     * does not.
209     * 
210     * @param dateString
211     * @return the String with converted timezone
212     */
213    public static String convertXep82TimezoneToRfc822(String dateString) {
214        if (dateString.charAt(dateString.length() - 1) == 'Z') {
215            return dateString.replace("Z", "+0000");
216        }
217        else {
218            // If the time zone wasn't specified with 'Z', then it's in
219            // ISO8601 format (i.e. '(+|-)HH:mm')
220            // RFC822 needs a similar format just without the colon (i.e.
221            // '(+|-)HHmm)'), so remove it
222            return dateString.replaceAll("([\\+\\-]\\d\\d):(\\d\\d)", "$1$2");
223        }
224    }
225
226    public static String convertRfc822TimezoneToXep82(String dateString) {
227        int length = dateString.length();
228        String res = dateString.substring(0, length -2);
229        res += ':';
230        res += dateString.substring(length - 2, length);
231        return res;
232    }
233
234    /**
235     * Converts a time zone to the String format as specified in XEP-0082
236     *
237     * @param timeZone
238     * @return the String representation of the TimeZone
239     */
240    public static String asString(TimeZone timeZone) {
241        int rawOffset = timeZone.getRawOffset();
242        int hours = rawOffset / (1000*60*60);
243        int minutes = Math.abs((rawOffset / (1000*60)) - (hours * 60));
244        return String.format("%+d:%02d", hours, minutes);
245    }
246
247    /**
248     * Parses the given date string in different ways and returns the date that lies in the past
249     * and/or is nearest to the current date-time.
250     * 
251     * @param stampString date in string representation
252     * @param dateLength
253     * @param noFuture
254     * @return the parsed date
255     * @throws ParseException The date string was of an unknown format
256     */
257    private static Date handleDateWithMissingLeadingZeros(String stampString, int dateLength)
258                    throws ParseException {
259        if (dateLength == 6) {
260            synchronized (xep0091Date6DigitFormatter) {
261                return xep0091Date6DigitFormatter.parse(stampString);
262            }
263        }
264        Calendar now = Calendar.getInstance();
265
266        Calendar oneDigitMonth = parseXEP91Date(stampString, xep0091Date7Digit1MonthFormatter);
267        Calendar twoDigitMonth = parseXEP91Date(stampString, xep0091Date7Digit2MonthFormatter);
268
269        List<Calendar> dates = filterDatesBefore(now, oneDigitMonth, twoDigitMonth);
270
271        if (!dates.isEmpty()) {
272            return determineNearestDate(now, dates).getTime();
273        }
274        return null;
275    }
276
277    private static Calendar parseXEP91Date(String stampString, DateFormat dateFormat) {
278        try {
279            synchronized (dateFormat) {
280                dateFormat.parse(stampString);
281                return dateFormat.getCalendar();
282            }
283        }
284        catch (ParseException e) {
285            return null;
286        }
287    }
288
289    private static List<Calendar> filterDatesBefore(Calendar now, Calendar... dates) {
290        List<Calendar> result = new ArrayList<Calendar>();
291
292        for (Calendar calendar : dates) {
293            if (calendar != null && calendar.before(now)) {
294                result.add(calendar);
295            }
296        }
297
298        return result;
299    }
300
301    private static Calendar determineNearestDate(final Calendar now, List<Calendar> dates) {
302
303        Collections.sort(dates, new Comparator<Calendar>() {
304
305            public int compare(Calendar o1, Calendar o2) {
306                Long diff1 = new Long(now.getTimeInMillis() - o1.getTimeInMillis());
307                Long diff2 = new Long(now.getTimeInMillis() - o2.getTimeInMillis());
308                return diff1.compareTo(diff2);
309            }
310
311        });
312
313        return dates.get(0);
314    }
315
316    private static class PatternCouplings {
317        final Pattern pattern;
318        final DateFormatType formatter;
319
320        public PatternCouplings(Pattern datePattern, DateFormatType dateFormat) {
321            pattern = datePattern;
322            formatter = dateFormat;
323        }
324    }
325}