1<?php
2/**
3 * Horde_ActiveSync_Timezone::
4 *
5 * @license   http://www.horde.org/licenses/gpl GPLv2
6 *
7 * @copyright 2009-2020 Horde LLC (http://www.horde.org)
8 * @author    Michael J Rubinsky <mrubinsk@horde.org>
9 * @package   ActiveSync
10 */
11/**
12 * Utility functions for dealing with Microsoft ActiveSync's Timezone format.
13 *
14 * Copyright 2009-2020 Horde LLC (http://www.horde.org/)
15 *
16 * See the enclosed file LICENSE for license information. If you
17 * did not receive this file, see http://www.horde.org/licenses/gpl.
18 *
19 * Code dealing with searching for a timezone identifier from an AS timezone
20 * blob inspired by code in the Tine20 Project (http://tine20.org).
21 *
22 * @license   http://www.horde.org/licenses/gpl GPLv2
23 *
24 * @copyright 2009-2020 Horde LLC (http://www.horde.org)
25 * @author    Michael J Rubinsky <mrubinsk@horde.org>
26 * @package   ActiveSync
27 * @deprecated To be removed for Horde 6. Use Horde_Mapi_Timezone::.
28 */
29class Horde_ActiveSync_Timezone
30{
31    /**
32     * Date to use as start date when iterating through offsets looking for a
33     * transition.
34     *
35     * @var Horde_Date
36     */
37    protected $_startDate;
38
39    /**
40     * Convert a timezone from the ActiveSync base64 structure to a TZ offset
41     * hash.
42     *
43     * @param base64 encoded timezone structure defined by MS as:
44     *  <pre>
45     *      typedef struct TIME_ZONE_INFORMATION {
46     *        LONG Bias;
47     *        WCHAR StandardName[32];
48     *        SYSTEMTIME StandardDate;
49     *        LONG StandardBias;
50     *        WCHAR DaylightName[32];
51     *        SYSTEMTIME DaylightDate;
52     *        LONG DaylightBias;};
53     *  </pre>
54     *
55     *  With the SYSTEMTIME format being:
56     *  <pre>
57     * typedef struct _SYSTEMTIME {
58     *     WORD wYear;
59     *     WORD wMonth;
60     *     WORD wDayOfWeek;
61     *     WORD wDay;
62     *     WORD wHour;
63     *     WORD wMinute;
64     *     WORD wSecond;
65     *     WORD wMilliseconds;
66     *   } SYSTEMTIME, *PSYSTEMTIME;
67     *  </pre>
68     *
69     *  See: http://msdn.microsoft.com/en-us/library/ms724950%28VS.85%29.aspx
70     *  and: http://msdn.microsoft.com/en-us/library/ms725481%28VS.85%29.aspx
71     *
72     * @return array  Hash of offset information
73     */
74    public static function getOffsetsFromSyncTZ($data)
75    {
76        if (version_compare(PHP_VERSION, '5.5', '>=')) {
77            $format = 'lbias/Z64stdname/vstdyear/vstdmonth/vstdday/vstdweek/vstdhour/vstdminute/vstdsecond/vstdmillis/'
78                . 'lstdbias/Z64dstname/vdstyear/vdstmonth/vdstday/vdstweek/vdsthour/vdstminute/vdstsecond/vdstmillis/'
79                . 'ldstbias';
80        } else {
81            $format = 'lbias/a64stdname/vstdyear/vstdmonth/vstdday/vstdweek/vstdhour/vstdminute/vstdsecond/vstdmillis/'
82                . 'lstdbias/a64dstname/vdstyear/vdstmonth/vdstday/vdstweek/vdsthour/vdstminute/vdstsecond/vdstmillis/'
83                . 'ldstbias';
84        }
85        $tz = unpack($format, base64_decode($data));
86        $tz['timezone'] = $tz['bias'];
87        $tz['timezonedst'] = $tz['dstbias'];
88
89        if (!Horde_Mapi::isLittleEndian()) {
90            $tz['bias'] = self::_chbo($tz['bias']);
91            $tz['stdbias'] = self::_chbo($tz['stdbias']);
92            $tz['dstbias'] = self::_chbo($tz['dstbias']);
93        }
94
95        return $tz;
96    }
97
98    /**
99     * Build an ActiveSync TZ blob given a TZ Offset hash.
100     *
101     * @param array $offsets  A TZ offset hash
102     *
103     * @return string  A base64_encoded ActiveSync Timezone structure suitable
104     *                 for transmitting via wbxml.
105     */
106    public static function getSyncTZFromOffsets(array $offsets)
107    {
108        if (!Horde_Mapi::isLittleEndian()) {
109            $offsets['bias'] = self::_chbo($offsets['bias']);
110            $offsets['stdbias'] = self::_chbo($offsets['stdbias']);
111            $offsets['dstbias'] = self::_chbo($offsets['dstbias']);
112        }
113
114        $packed = pack('la64vvvvvvvvla64vvvvvvvvl',
115                $offsets['bias'], '', 0, $offsets['stdmonth'], $offsets['stdday'], $offsets['stdweek'], $offsets['stdhour'], $offsets['stdminute'], $offsets['stdsecond'], $offsets['stdmillis'],
116                $offsets['stdbias'], '', 0, $offsets['dstmonth'], $offsets['dstday'], $offsets['dstweek'], $offsets['dsthour'], $offsets['dstminute'], $offsets['dstsecond'], $offsets['dstmillis'],
117                $offsets['dstbias']);
118
119        return base64_encode($packed);
120    }
121
122    /**
123     * Create a offset hash suitable for use in ActiveSync transactions
124     *
125     * @param Horde_Date $date  A date object representing the date to base the
126     *                          the tz data on.
127     *
128     * @return array  An offset hash.
129     */
130    public static function getOffsetsFromDate(Horde_Date $date)
131    {
132        $offsets = array(
133            'bias' => 0,
134            'stdname' => '',
135            'stdyear' => 0,
136            'stdmonth' => 0,
137            'stdday' => 0,
138            'stdweek' => 0,
139            'stdhour' => 0,
140            'stdminute' => 0,
141            'stdsecond' => 0,
142            'stdmillis' => 0,
143            'stdbias' => 0,
144            'dstname' => '',
145            'dstyear' => 0,
146            'dstmonth' => 0,
147            'dstday' => 0,
148            'dstweek' => 0,
149            'dsthour' => 0,
150            'dstminute' => 0,
151            'dstsecond' => 0,
152            'dstmillis' => 0,
153            'dstbias' => 0
154        );
155
156        $timezone = $date->toDateTime()->getTimezone();
157        list($std, $dst) = self::_getTransitions($timezone, $date);
158        if ($std) {
159            $offsets['bias'] = $std['offset'] / 60 * -1;
160            if ($dst) {
161                $offsets = self::_generateOffsetsForTransition($offsets, $std, 'std');
162                $offsets = self::_generateOffsetsForTransition($offsets, $dst, 'dst');
163                $offsets['stdhour'] += $dst['offset'] / 3600;
164                $offsets['dsthour'] += $std['offset'] / 3600;
165                $offsets['dstbias'] = ($dst['offset'] - $std['offset']) / 60 * -1;
166            }
167        }
168
169        return $offsets;
170    }
171
172    /**
173     * Get the transition data for moving from DST to STD time.
174     *
175     * @param DateTimeZone $timezone  The timezone to get the transition for
176     * @param Horde_Date $date        The date to start from. Really only the
177     *                                year we are interested in is needed.
178     *
179     * @return array  An array containing the the STD and DST transitions
180     */
181    protected static function _getTransitions(DateTimeZone $timezone, Horde_Date $date)
182    {
183
184        $std = $dst = array();
185        $transitions = $timezone->getTransitions(
186            mktime(0, 0, 0, 12, 1, $date->year - 1),
187            mktime(24, 0, 0, 12, 31, $date->year)
188        );
189
190        foreach ($transitions as $i => $transition) {
191            try {
192               $d = new Horde_Date($transition['time']);
193               $d->setTimezone('UTC');
194            } catch (Exception $e) {
195                continue;
196            }
197            if (($d->format('Y') == $date->format('Y')) && isset($transitions[$i + 1])) {
198                $next = new Horde_Date($transitions[$i + 1]['ts']);
199                if ($d->format('Y') == $next->format('Y')) {
200                    $dst = $transition['isdst'] ? $transition : $transitions[$i + 1];
201                    $std = $transition['isdst'] ? $transitions[$i + 1] : $transition;
202                } else {
203                    $dst = $transition['isdst'] ? $transition: null;
204                    $std = $transition['isdst'] ? null : $transition;
205                }
206                break;
207            } elseif ($i == count($transitions) - 1) {
208                $std = $transition;
209            }
210        }
211
212        return array($std, $dst);
213    }
214
215    /**
216     * Calculate the offsets for the specified transition
217     *
218     * @param array $offsets      A TZ offset hash
219     * @param array $transition   A transition hash
220     * @param string $type        Transition type - dst or std
221     *
222     * @return array  A populated offset hash
223     */
224    protected static function _generateOffsetsForTransition(array $offsets, array $transition, $type)
225    {
226        // We can't use Horde_Date directly here, since it is unable to
227        // properly convert to UTC from local ON the exact hour of a std -> dst
228        // transition. This is due to a conversion to DateTime in the localtime
229        // zone internally before the timezone change is applied
230        $transitionDate = new DateTime($transition['time']);
231        $transitionDate->setTimezone(new DateTimeZone('UTC'));
232        $transitionDate = new Horde_Date($transitionDate);
233        $offsets[$type . 'month'] = $transitionDate->format('n');
234        $offsets[$type . 'day'] = $transitionDate->format('w');
235        $offsets[$type . 'minute'] = (int)$transitionDate->format('i');
236        $offsets[$type . 'hour'] = (int)$transitionDate->format('H');
237        for ($i = 5; $i > 0; $i--) {
238            if (self::_isNthOcurrenceOfWeekdayInMonth($transition['ts'], $i)) {
239                $offsets[$type . 'week'] = $i;
240                break;
241            }
242        }
243
244        return $offsets;
245    }
246
247    /**
248     * Attempt to guess the timezone identifier from the $offsets array.
249     *
250     * @param array|string $offsets     The timezone to check. Either an array
251     *                                  of offsets or an activesynz tz blob.
252     * @param string $expectedTimezone  The expected timezone. If not empty, and
253     *                                  present in the results, will return.
254     *
255     * @return string  The timezone identifier
256     */
257    public function getTimezone($offsets, $expectedTimezone = null)
258    {
259        $timezones = $this->getListOfTimezones($offsets, $expectedTimezone);
260        if (isset($timezones[$expectedTimezone])) {
261            return $expectedTimezone;
262        } else {
263            return current($timezones);
264        }
265    }
266
267    /**
268     * Get the list of timezone identifiers that match the given offsets, having
269     * a preference for $expectedTimezone if it's present in the results.
270     *
271     * @param array|string $offsets     Either an offset array, or a AS timezone
272     *                                  structure.
273     * @param string $expectedTimezone  The expected timezone.
274     *
275     * @return array  An array of timezone identifiers
276     */
277    public function getListOfTimezones($offsets, $expectedTimezone = null)
278    {
279        if (is_string($offsets)) {
280            $offsets = self::getOffsetsFromSyncTZ($offsets);
281        }
282        $this->_setDefaultStartDate($offsets);
283        $timezones = array();
284        foreach (DateTimeZone::listIdentifiers() as $timezoneIdentifier) {
285            $timezone = new DateTimeZone($timezoneIdentifier);
286            if (false !== ($matchingTransition = $this->_checkTimezone($timezone, $offsets))) {
287                if ($timezoneIdentifier == $expectedTimezone) {
288                    $timezones = array($timezoneIdentifier => $matchingTransition['abbr']);
289                    break;
290                } else {
291                    $timezones[$timezoneIdentifier] = $matchingTransition['abbr'];
292                }
293            }
294        }
295
296        if (empty($timezones)) {
297           throw new Horde_ActiveSync_Exception('No timezone found for the given offsets');
298        }
299
300        return $timezones;
301    }
302
303    /**
304     * Set default value for $_startDate.
305     *
306     * Tries to guess the correct startDate depending on object property falls
307     * back to current date.
308     *
309     * @param array $offsets  Offsets may be avaluated for a given start year
310     */
311    protected function _setDefaultStartDate(array $offsets = null)
312    {
313        if (!empty($this->_startDate)) {
314            return;
315        }
316
317        if (!empty($offsets['stdyear'])) {
318            $this->_startDate = new Horde_Date($offsets['stdyear'] . '-01-01');
319        } else {
320            $start = new Horde_Date(time());
321            $start->year--;
322            $this->_startDate = $start;
323        }
324    }
325
326    /**
327     * Check if the given timezone matches the offsets and also evaluate the
328     * daylight saving time transitions for this timezone if necessary.
329     *
330     * @param DateTimeZone $timezone  The timezone to check.
331     * @param array $offsets          The offsets to check.
332     *
333     * @return array|boolean  An array of transition data or false if timezone
334     *                        does not match offset.
335     */
336    protected function _checkTimezone(DateTimeZone $timezone, array $offsets)
337    {
338        list($std, $dst) = $this->_getTransitions($timezone, $this->_startDate);
339        if ($this->_checkTransition($std, $dst, $offsets)) {
340            return $std;
341        }
342
343        return false;
344    }
345
346    /**
347     * Check if the given standardTransition and daylightTransition match to the
348     * given offsets.
349     *
350     * @param array $std      The Standard transition date.
351     * @param array $dst      The DST transition date.
352     * @param array $offsets  The offsets to check.
353     *
354     * @return boolean
355     */
356    protected function _checkTransition(array $std, array $dst, array $offsets)
357    {
358        if (empty($std) || empty($offsets)) {
359            return false;
360        }
361
362        $standardOffset = ($offsets['bias'] + $offsets['stdbias']) * 60 * -1;
363
364        // check each condition in a single if statement and break the chain
365        // when one condition is not met - for performance reasons
366        if ($standardOffset == $std['offset']) {
367            if ((empty($offsets['dstmonth']) && (empty($dst) || empty($dst['isdst']))) ||
368                (empty($dst) && !empty($offsets['dstmonth']))) {
369                // Offset contains DST, but no dst to compare
370                return true;
371            }
372            $daylightOffset = ($offsets['bias'] + $offsets['dstbias']) * 60 * -1;
373            // the milestone is sending a positive value for daylightBias while it should send a negative value
374            $daylightOffsetMilestone = ($offsets['dstbias'] + ($offsets['dstbias'] * -1) ) * 60 * -1;
375
376            if ($daylightOffset == $dst['offset'] || $daylightOffsetMilestone == $dst['offset']) {
377                $standardParsed = new DateTime($std['time']);
378                $daylightParsed = new DateTime($dst['time']);
379
380                if ($standardParsed->format('n') == $offsets['stdmonth'] &&
381                    $daylightParsed->format('n') == $offsets['dstmonth'] &&
382                    $standardParsed->format('w') == $offsets['stdday'] &&
383                    $daylightParsed->format('w') == $offsets['dstday'])
384                {
385                    return self::_isNthOcurrenceOfWeekdayInMonth($dst['ts'], $offsets['dstweek']) &&
386                           self::_isNthOcurrenceOfWeekdayInMonth($std['ts'], $offsets['stdweek']);
387                }
388            }
389        }
390
391        return false;
392    }
393
394    /**
395     * Test if the weekday of the given timestamp is the nth occurence of this
396     * weekday within its month, where '5' indicates the last occurrence even if
397     * there is less than five occurrences.
398     *
399     * @param integer $timestamp  The timestamp to check.
400     * @param integer $occurence  1 to 5, where 5 indicates the final occurrence
401     *                            during the month if that day of the week does
402     *                            not occur 5 times
403     * @return boolean
404     */
405    protected static function _isNthOcurrenceOfWeekdayInMonth($timestamp, $occurence)
406    {
407        $original = new Horde_Date($timestamp);
408        $original->setTimezone('UTC');
409        if ($occurence == 5) {
410            $modified = $original->add(array('mday' => 7));
411            return $modified->month > $original->month;
412        } else {
413            $modified = $original->sub(array('mday' => 7 * $occurence));
414            $modified2 = $original->sub(array('mday' => 7 * ($occurence - 1)));
415
416            return $modified->month < $original->month &&
417                   $modified2->month == $original->month;
418       }
419    }
420
421    /**
422     * Change the byte order of a number. Used to allow big endian machines to
423     * decode the timezone blobs, which are encoded in little endian order.
424     *
425     * @param integer $num  The number to reverse.
426     *
427     * @return integer  The number, in the reverse byte order.
428     */
429    protected static function _chbo($num) {
430        $u = unpack('l', strrev(pack('l', $num)));
431
432        return $u[1];
433    }
434
435}
436