diff --git a/lib/kolab_sync_timezone_converter.php b/lib/kolab_sync_timezone_converter.php index 800be59..95fcddf 100644 --- a/lib/kolab_sync_timezone_converter.php +++ b/lib/kolab_sync_timezone_converter.php @@ -1,650 +1,669 @@ | | Copyright (C) 2008-2012, Metaways Infosystems GmbH | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | | Author: Jonas Fischer | +--------------------------------------------------------------------------+ */ /** * Activesync timezone converter */ class kolab_sync_timezone_converter { /** * holds the instance of the singleton * * @var kolab_sync_timezone_onverter */ private static $_instance = NULL; protected $_startDate = array(); /** * If set then the timezone guessing results will be cached. * This is strongly recommended for performance reasons. * * @var rcube_cache */ protected $cache = null; /** * array of offsets known by ActiceSync clients, but unknown by php * @var array */ protected $_knownTimezones = array( '0AIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==' => array( 'Pacific/Kwajalein' => 'MHT' ) ); /** * don't use the constructor. Use the singleton. * * @param $_logger */ private function __construct() { } /** * don't clone. Use the singleton. */ private function __clone() { } /** * the singleton pattern * * @return kolab_sync_timezone_converter */ public static function getInstance() { if (self::$_instance === NULL) { self::$_instance = new kolab_sync_timezone_converter(); } return self::$_instance; } /** * Returns a timezone with an offset matching the time difference * of $dt from $referenceDt. * * If set and matching the offset, kolab_format::$timezone is preferred. * * @param DateTime $dt The date time value for which we * calculate the offset. * @param DateTime $referenceDt The reference value, for instance in UTC. * * @return DateTimeZone|null */ public function getOffsetTimezone($dt, $referenceDt) { $interval = $referenceDt->diff($dt); $tz = new DateTimeZone($interval->format('%R%H%I')); //e.g. +0200 $utcOffset = $tz->getOffset($dt); //Prefer the configured timezone if it matches the offset. if (kolab_format::$timezone) { if (kolab_format::$timezone->getOffset($dt) == $utcOffset) { return kolab_format::$timezone; } } //Look for any timezone with a matching offset. foreach (DateTimeZone::listIdentifiers() as $timezoneIdentifier) { $timezone = new DateTimeZone($timezoneIdentifier); if ($timezone->getOffset($dt) == $utcOffset) { return $timezone; } } return null; } /** * Returns a list of timezones that match to the {@param $_offsets} * * If {@see $_expectedTimezone} is set then the method will terminate as soon * as the expected timezone has matched and the expected timezone will be the * first entry to the returned array. * * @param string|array $_offsets * * @return array */ public function getListOfTimezones($_offsets) { if (is_string($_offsets) && isset($this->_knownTimezones[$_offsets])) { $timezones = $this->_knownTimezones[$_offsets]; } else { if (is_string($_offsets)) { // unpack timezone info to array $_offsets = $this->_unpackTimezoneInfo($_offsets); } if (!$this->_validateOffsets($_offsets)) { return array(); } $this->_setDefaultStartDateIfEmpty($_offsets); $cacheId = $this->_getCacheId('timezones', $_offsets); $timezones = $this->_loadFromCache($cacheId); if (!is_array($timezones)) { $timezones = array(); foreach (DateTimeZone::listIdentifiers() as $timezoneIdentifier) { $timezone = new DateTimeZone($timezoneIdentifier); if (false !== ($matchingTransition = $this->_checkTimezone($timezone, $_offsets))) { $timezones[$timezoneIdentifier] = $matchingTransition['abbr']; } } $this->_saveInCache($timezones, $cacheId); } } return $timezones; } /** * Returns PHP timezone that matches to the {@param $_offsets} * * If {@see $_expectedTimezone} is set then the method will return this timezone if it matches. * * @param string|array $_offsets Activesync timezone definition * @param string $_expectedTomezone Expected timezone name * * @return string Expected timezone name */ public function getTimezone($_offsets, $_expectedTimezone = null) { $timezones = $this->getListOfTimezones($_offsets); if ($_expectedTimezone && isset($timezones[$_expectedTimezone])) { return $_expectedTimezone; } else { return key($timezones); } } /** * Return packed string for given {@param $_timezone} * * @param string $_timezone Timezone identifier * @param string|int $_startDate Start date * * @return string Packed timezone offsets */ public function encodeTimezone($_timezone, $_startDate = null) { foreach ($this->_knownTimezones as $packedString => $knownTimezone) { if (array_key_exists($_timezone, $knownTimezone)) { return $packedString; } } $offsets = $this->getOffsetsForTimezone($_timezone, $_startDate); return $this->_packTimezoneInfo($offsets); } /** * Get offsets for given timezone * * @param string $_timezone Timezone identifier * @param string|int $_startDate Start date * * @return array Timezone offsets */ public function getOffsetsForTimezone($_timezone, $_startDate = null) { $this->_setStartDate($_startDate); $cacheId = $this->_getCacheId('offsets', array($_timezone)); if (false === ($offsets = $this->_loadFromCache($cacheId))) { $offsets = $this->_getOffsetsTemplate(); try { $timezone = new DateTimeZone($_timezone); } catch (Exception $e) { return null; } list($standardTransition, $daylightTransition) = $this->_getTransitionsForTimezoneAndYear($timezone, $this->_startDate['year']); if ($standardTransition) { $offsets['bias'] = $standardTransition['offset']/60*-1; if ($daylightTransition) { $offsets = $this->_generateOffsetsForTransition($offsets, $standardTransition, 'standard', $timezone); $offsets = $this->_generateOffsetsForTransition($offsets, $daylightTransition, 'daylight', $timezone); //@todo how do we get the standardBias (is usually 0)? //$offsets['standardBias'] = ... $offsets['daylightBias'] = ($daylightTransition['offset'] - $standardTransition['offset'])/60*-1; $offsets['standardHour'] -= $offsets['daylightBias'] / 60; $offsets['daylightHour'] += $offsets['daylightBias'] / 60; } } $this->_saveInCache($offsets, $cacheId); } return $offsets; } /** * Get offsets for timezone transition * * @param array $_offsets Timezone offsets * @param array $_transition Timezone transition information * @param string $_type Transition type: 'standard' or 'daylight' * @param DateTimeZone $_timezone Timezone of the transition * * @return array */ protected function _generateOffsetsForTransition(array $_offsets, array $_transition, $_type, $_timezone) { $transitionDate = new DateTime($_transition['time'], $_timezone); if ($_transition['offset']) { $transitionDate->modify($_transition['offset'] . ' seconds'); } $_offsets[$_type . 'Month'] = (int) $transitionDate->format('n'); $_offsets[$_type . 'DayOfWeek'] = (int) $transitionDate->format('w'); $_offsets[$_type . 'Minute'] = (int) $transitionDate->format('i'); $_offsets[$_type . 'Hour'] = (int) $transitionDate->format('G'); for ($i=5; $i>0; $i--) { if ($this->_isNthOcurrenceOfWeekdayInMonth($transitionDate, $i)) { $_offsets[$_type . 'Day'] = $i; break; }; } return $_offsets; } /** * Test if the weekday of the given {@param $_timestamp} is the {@param $_occurence}th occurence of this weekday within its month. * * @param DateTime $_datetime * @param int $_occurence [1 to 5, where 5 indicates the final occurrence during the month if that day of the week does not occur 5 times] * * @return bool */ protected function _isNthOcurrenceOfWeekdayInMonth($_datetime, $_occurence) { if ($_occurence <= 1) { return true; } $orig = $_datetime->format('n'); if ($_occurence == 5) { $modified = clone($_datetime); $modified->modify('1 week'); $mod = $modified->format('n'); // modified date is a next month return $mod > $orig || ($mod == 1 && $orig == 12); } $modified = clone($_datetime); $modified->modify(sprintf('-%d weeks', $_occurence - 1)); $mod = $modified->format('n'); if ($mod != $orig) { return false; } $modified = clone($_datetime); $modified->modify(sprintf('-%d weeks', $_occurence)); $mod = $modified->format('n'); // modified month is earlier than original return $mod < $orig || ($mod == 12 && $orig == 1); } /** * Check if the given {@param $_standardTransition} and {@param $_daylightTransition} * match to the object property {@see $_offsets} * * @param array $standardTransition * @param array $daylightTransition * * @return bool */ protected function _checkTransition($_standardTransition, $_daylightTransition, $_offsets, $tz) { if (empty($_standardTransition) || empty($_offsets)) { return false; } $standardOffset = ($_offsets['bias'] + $_offsets['standardBias']) * 60 * -1; // check each condition in a single if statement and break the chain when one condition is not met - for performance reasons if ($standardOffset == $_standardTransition['offset'] ) { if (empty($_offsets['daylightMonth']) && (empty($_daylightTransition) || empty($_daylightTransition['isdst']))) { // No DST return true; } $daylightOffset = ($_offsets['bias'] + $_offsets['daylightBias']) * 60 * -1; // the milestone is sending a positive value for daylightBias while it should send a negative value $daylightOffsetMilestone = ($_offsets['bias'] + ($_offsets['daylightBias'] * -1) ) * 60 * -1; if ( !empty($_daylightTransition) && ($daylightOffset == $_daylightTransition['offset'] || $daylightOffsetMilestone == $_daylightTransition['offset']) ) { $standardDate = new DateTime($_standardTransition['time'], $tz); $daylightDate = new DateTime($_daylightTransition['time'], $tz); if ($standardDate->format('n') == $_offsets['standardMonth'] && $daylightDate->format('n') == $_offsets['daylightMonth'] && $standardDate->format('w') == $_offsets['standardDayOfWeek'] && $daylightDate->format('w') == $_offsets['daylightDayOfWeek'] ) { return $this->_isNthOcurrenceOfWeekdayInMonth($daylightDate, $_offsets['daylightDay']) && $this->_isNthOcurrenceOfWeekdayInMonth($standardDate, $_offsets['standardDay']); } } } return false; } /** * decode timezone info from activesync * * @param string $_packedTimezoneInfo the packed timezone info * @return array */ protected function _unpackTimezoneInfo($_packedTimezoneInfo) { $timezoneUnpackString = 'lbias/a64standardName/vstandardYear/vstandardMonth/vstandardDayOfWeek/vstandardDay/vstandardHour/vstandardMinute/vstandardSecond/vstandardMilliseconds/lstandardBias/a64daylightName/vdaylightYear/vdaylightMonth/vdaylightDayOfWeek/vdaylightDay/vdaylightHour/vdaylightMinute/vdaylightSecond/vdaylightMilliseconds/ldaylightBias'; $timezoneInfo = unpack($timezoneUnpackString, base64_decode($_packedTimezoneInfo)); + if ($timezoneInfo['standardHour'] == 23 && $timezoneInfo['standardMilliseconds'] == 999 + && $timezoneInfo['standardMinute'] == 59 && $timezoneInfo['standardSecond'] == 59 + ) { + $timezoneInfo['standardHour'] = 24; + $timezoneInfo['standardMinute'] = 0; + $timezoneInfo['standardSecond'] = 0; + $timezoneInfo['standardMilliseconds'] = 0; + } + return $timezoneInfo; } /** * encode timezone info to activesync * * @param array $_timezoneInfo * @return string */ protected function _packTimezoneInfo($_timezoneInfo) { if (!is_array($_timezoneInfo)) { return null; } + // According to e.g. https://docs.microsoft.com/en-us/windows/win32/api/minwinbase/ns-minwinbase-systemtime, + // 24 is not allowed in the Hour field, and consequently Outlook can't deal with it. + // This is the same workaround that Outlook applies. + if ($_timezoneInfo['standardHour'] == 24) { + $_timezoneInfo['standardHour'] = 23; + $_timezoneInfo['standardMinute'] = 59; + $_timezoneInfo['standardSecond'] = 59; + $_timezoneInfo['standardMilliseconds'] = 999; + } + $packed = pack( "la64vvvvvvvvla64vvvvvvvvl", $_timezoneInfo['bias'], $_timezoneInfo['standardName'], $_timezoneInfo['standardYear'], $_timezoneInfo['standardMonth'], $_timezoneInfo['standardDayOfWeek'], $_timezoneInfo['standardDay'], $_timezoneInfo['standardHour'], $_timezoneInfo['standardMinute'], $_timezoneInfo['standardSecond'], $_timezoneInfo['standardMilliseconds'], $_timezoneInfo['standardBias'], $_timezoneInfo['daylightName'], $_timezoneInfo['daylightYear'], $_timezoneInfo['daylightMonth'], $_timezoneInfo['daylightDayOfWeek'], $_timezoneInfo['daylightDay'], $_timezoneInfo['daylightHour'], $_timezoneInfo['daylightMinute'], $_timezoneInfo['daylightSecond'], $_timezoneInfo['daylightMilliseconds'], $_timezoneInfo['daylightBias'] ); return base64_encode($packed); } /** * Returns complete offsets array with all fields empty * * Used e.g. when reverse-generating ActiveSync Timezone Offset Information * based on a given Timezone, {@see getOffsetsForTimezone} * * @return unknown_type */ protected function _getOffsetsTemplate() { return array( 'bias' => 0, 'standardName' => '', 'standardYear' => 0, 'standardMonth' => 0, 'standardDayOfWeek' => 0, 'standardDay' => 0, 'standardHour' => 0, 'standardMinute' => 0, 'standardSecond' => 0, 'standardMilliseconds' => 0, 'standardBias' => 0, 'daylightName' => '', 'daylightYear' => 0, 'daylightMonth' => 0, 'daylightDayOfWeek' => 0, 'daylightDay' => 0, 'daylightHour' => 0, 'daylightMinute' => 0, 'daylightSecond' => 0, 'daylightMilliseconds' => 0, 'daylightBias' => 0 ); } /** * Validate and set offsets * * @param array $value * * @return bool Validation result */ protected function _validateOffsets($value) { // validate $value if ((!empty($value['standardMonth']) || !empty($value['standardDay']) || !empty($value['daylightMonth']) || !empty($value['daylightDay'])) && (empty($value['standardMonth']) || empty($value['standardDay']) || empty($value['daylightMonth']) || empty($value['daylightDay'])) ) { // It is not possible not set standard offsets without setting daylight offsets and vice versa return false; } return true; } /** * Parse and set object property {@see $_startDate} * * @param string|int $_startDate * @return void */ protected function _setStartDate($_startDate) { if (empty($_startDate)) { $this->_setDefaultStartDateIfEmpty(); return; } $startDateParsed = array(); if (is_string($_startDate)) { $startDateParsed['string'] = $_startDate; $startDateParsed['ts'] = strtotime($_startDate); } else if (is_int($_startDate)) { $startDateParsed['ts'] = $_startDate; $startDateParsed['string'] = strftime('%F', $_startDate); } else { $this->_setDefaultStartDateIfEmpty(); return; } $startDateParsed['object'] = new DateTime($startDateParsed['string']); $startDateParsed = array_merge($startDateParsed, getdate($startDateParsed['ts'])); $this->_startDate = $startDateParsed; } /** * Set default value for object property {@see $_startdate} if it is not set yet. * Tries to guess the correct startDate depending on object property {@see $_offsets} and * falls back to current date. * * @param array $_offsets [offsets may be avaluated for a given start year] * @return void */ protected function _setDefaultStartDateIfEmpty($_offsets = null) { if (!empty($this->_startDate)) { return; } if (!empty($_offsets['standardYear'])) { $this->_setStartDate($_offsets['standardYear'].'-01-01'); } else { $this->_setStartDate(time()); } } /** * Check if the given {@param $_timezone} matches the {@see $_offsets} * and also evaluate the daylight saving time transitions for this timezone if necessary. * * @param DateTimeZone $timezone * @param array $offsets * * @return array|bool */ protected function _checkTimezone(DateTimeZone $timezone, $offsets) { list($standardTransition, $daylightTransition) = $this->_getTransitionsForTimezoneAndYear($timezone, $this->_startDate['year']); if ($this->_checkTransition($standardTransition, $daylightTransition, $offsets, $timezone)) { return $standardTransition; } return false; } /** * Returns the standard and daylight transitions for the given {@param $_timezone} * and {@param $_year}. * * @param DateTimeZone $_timezone * @param int $_year * * @return array */ protected function _getTransitionsForTimezoneAndYear(DateTimeZone $_timezone, $_year) { $standardTransition = null; $daylightTransition = null; $start = mktime(0, 0, 0, 12, 1, $_year - 1); $end = mktime(24, 0, 0, 12, 31, $_year); $transitions = $_timezone->getTransitions($start, $end); if ($transitions === false) { return array(); } foreach ($transitions as $index => $transition) { if (strftime('%Y', $transition['ts']) == $_year) { if (isset($transitions[$index+1]) && strftime('%Y', $transitions[$index]['ts']) == strftime('%Y', $transitions[$index+1]['ts'])) { $daylightTransition = $transition['isdst'] ? $transition : $transitions[$index+1]; $standardTransition = $transition['isdst'] ? $transitions[$index+1] : $transition; } else { $daylightTransition = $transition['isdst'] ? $transition : null; $standardTransition = $transition['isdst'] ? null : $transition; } break; } else if ($index == count($transitions) -1) { $standardTransition = $transition; } } return array($standardTransition, $daylightTransition); } protected function _getCacheId($_prefix, $_offsets) { return $_prefix . md5(serialize($_offsets)); } protected function _loadFromCache($key) { if ($cache = $this->getCache()) { return $cache->get($key); } return false; } protected function _saveInCache($value, $key) { if ($cache = $this->getCache()) { $cache->set($key, $value); } } /** * Getter for the cache engine object */ protected function getCache() { if ($this->cache === null) { $rcube = rcube::get_instance(); $cache = $rcube->get_cache_shared('activesync'); $this->cache = $cache ? $cache : false; } return $this->cache; } } diff --git a/tests/timezone_converter.php b/tests/timezone_converter.php index 6088322..743b76d 100644 --- a/tests/timezone_converter.php +++ b/tests/timezone_converter.php @@ -1,116 +1,128 @@ getListOfTimezones($input); $this->assertTrue(is_array($output)); $converter = timezone_converter_test::getInstance(); $output = $converter->getListOfTimezones('xP///0MAZQBuAHQAcgBhAGwAIABFAHUAcgBvAHAAZQAgAFMAdABhAG4AZABhAHIAZAAgAFQAaQBtAGUAAAAAAAAAAAAAAAoAAAAFAAMAAAAAAAAAAAAAAEMAZQBuAHQAcgBhAGwAIABFAHUAcgBvAHAAZQAgAEQAYQB5AGwAaQBnAGgAdAAgAFQAaQBtAGUAAAAAAAAAAAAAAAMAAAAFAAIAAAAAAAAAxP///w=='); $this->assertTrue(is_array($output)); $this->assertTrue(isset($output['Europe/Warsaw'])); $converter = timezone_converter_test::getInstance(); $output = $converter->getListOfTimezones('4AEAAFAAYQBjAGkAZgBpAGMAIABTAHQAYQBuAGQAYQByAGQAIABUAGkAbQBlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsAAAABAAIAAAAAAAAAAAAAAFAAYQBjAGkAZgBpAGMAIABEAGEAeQBsAGkAZwBoAHQAIABUAGkAbQBlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAACAAIAAAAAAAAAxP///w=='); $this->assertTrue(is_array($output)); $this->assertTrue(isset($output['America/Los_Angeles'])); } function test_get_timezone() { date_default_timezone_set('America/Los_Angeles'); $converter = timezone_converter_test::getInstance(); $datetime = new DateTime('2017-01-01T12:00:00Z'); $offsets = $converter->getOffsetsForTimezone('UTC', $datetime); $output = $converter->getTimezone($offsets, 'UTC'); $this->assertSame('UTC', $output); $offsets = $converter->getOffsetsForTimezone('Europe/Warsaw', $datetime); $output = $converter->getTimezone($offsets, 'Europe/Warsaw'); $this->assertSame('Europe/Warsaw', $output); $offsets = $converter->getOffsetsForTimezone('America/Los_Angeles', $datetime); $output = $converter->getTimezone($offsets, 'America/Los_Angeles'); $this->assertSame('America/Los_Angeles', $output); } function test_get_offsets_for_timezone() { date_default_timezone_set('America/Los_Angeles'); $converter = timezone_converter_test::getInstance(); $datetime = new DateTime('2017-01-01T12:00:00Z'); $output = $converter->getOffsetsForTimezone('UTC', $datetime); $this->assertSame($output['bias'], 0); $this->assertSame($output['standardBias'], 0); $this->assertSame($output['daylightBias'], 0); $this->assertSame($output['standardMonth'], 0); $this->assertSame($output['daylightMonth'], 0); $output = $converter->getOffsetsForTimezone('Europe/Warsaw', $datetime); $this->assertSame($output['standardBias'], 0); $this->assertSame($output['standardMonth'], 10); $this->assertSame($output['standardDay'], 5); $this->assertSame($output['standardHour'], 3); $this->assertSame($output['daylightBias'], -60); $this->assertSame($output['daylightMonth'], 3); $this->assertSame($output['daylightDay'], 5); $this->assertSame($output['daylightHour'], 2); $output = $converter->getOffsetsForTimezone('America/Los_Angeles', $datetime); $this->assertSame($output['bias'], 480); $this->assertSame($output['standardBias'], 0); $this->assertSame($output['standardMonth'], 11); $this->assertSame($output['standardDay'], 1); $this->assertSame($output['standardHour'], 2); $this->assertSame($output['daylightBias'], -60); $this->assertSame($output['daylightMonth'], 3); $this->assertSame($output['daylightDay'], 2); $this->assertSame($output['daylightHour'], 2); $output = $converter->getOffsetsForTimezone('Atlantic/Azores', $datetime); $this->assertSame($output['bias'], 60); $this->assertSame($output['standardBias'], 0); $this->assertSame($output['standardMonth'], 10); $this->assertSame($output['standardDay'], 5); $this->assertSame($output['standardHour'], 1); $this->assertSame($output['daylightBias'], -60); $this->assertSame($output['daylightMonth'], 3); $this->assertSame($output['daylightDay'], 5); $this->assertSame($output['daylightHour'], 0); + + $output = $converter->getOffsetsForTimezone('Asia/Tehran', $datetime); + + $this->assertSame($output['bias'], -210); + $this->assertSame($output['standardBias'], 0); + $this->assertSame($output['standardMonth'], 9); + $this->assertSame($output['standardDay'], 3); + $this->assertSame($output['standardHour'], 24); + $this->assertSame($output['daylightBias'], -60); + $this->assertSame($output['daylightMonth'], 3); + $this->assertSame($output['daylightDay'], 4); + $this->assertSame($output['daylightHour'], 0); } } class timezone_converter_test extends kolab_sync_timezone_converter { // disable cache function getCache() { return null; } }