Files
server/usr/share/psa-pear/pear/php/Horde/ActiveSync/Collections.php
2026-01-07 20:52:11 +01:00

1421 lines
46 KiB
PHP

<?php
/**
* Horde_ActiveSync_Collections::
*
* @license http://www.horde.org/licenses/gpl GPLv2
*
* @copyright 2010-2020 Horde LLC (http://www.horde.org)
* @author Michael J Rubinsky <mrubinsk@horde.org>
* @package ActiveSync
*/
/**
* Horde_ActiveSync_Collections:: Responsible for all functionality related to
* collections and managing the sync cache.
*
* @license http://www.horde.org/licenses/gpl GPLv2
*
* @copyright 2010-2020 Horde LLC (http://www.horde.org)
* @author Michael J Rubinsky <mrubinsk@horde.org>
* @package ActiveSync
* @internal Not intended for use outside of the ActiveSync library.
*/
class Horde_ActiveSync_Collections implements IteratorAggregate
{
const COLLECTION_ERR_FOLDERSYNC_REQUIRED = -1;
const COLLECTION_ERR_SERVER = -2;
const COLLECTION_ERR_STALE = -3;
const COLLECTION_ERR_SYNC_REQUIRED = -4;
const COLLECTION_ERR_AUTHENTICATION = -6;
/**
* The collection data
*
* @var array
*/
protected $_collections = array();
/**
* Cache a temporary syncCache.
*
* @var Horde_ActiveSync_SyncCache
*/
protected $_tempSyncCache;
/**
* The syncCache
*
* @var Horde_ActiveSync_SyncCache
*/
protected $_cache;
/**
* The logger
*
* @var Horde_Log_Logger
*/
protected $_logger;
/**
* Count of unchanged collections calculated for PARTIAL sync.
*
* @var integer
*/
protected $_unchangedCount = 0;
/**
* Count of available synckeys
*
* @var integer
*/
protected $_synckeyCount = 0;
/**
* Global WINDOWSIZE
* Defaults to 100 (MS-ASCMD 2.2.3.188)
*
* @var integer
*/
protected $_globalWindowSize = 100;
/**
* Flag to indicate we have overridden the globalWindowSize
*
* @var boolean
*/
protected $_windowsizeOverride = false;
/**
* Imported changes flag.
*
* @var boolean
*/
protected $_importedChanges = false;
/**
* Short sync request flag.
*
* @var boolean
*/
protected $_shortSyncRequest = false;
/**
* Cache of collections that have had changes detected.
*
* @var array
*/
protected $_changedCollections = array();
/**
* The ActiveSync server object.
*
* @var Horde_ActiveSync
*/
protected $_as;
/**
* Cache the process id for logging.
*
* @var integer
*/
protected $_procid;
/**
* Cache of changes.
*
* @var array
*/
protected $_changes;
/**
* Flag to indicate the client is requesting a hanging SYNC.
*
* @var boolean
*/
protected $_hangingSync = false;
/**
* Const'r
*
* @param Horde_ActiveSync_SyncCache $cache The SyncCache.
* @param Horde_ActiveSync $as The ActiveSync server object.
*/
public function __construct(
Horde_ActiveSync_SyncCache $cache,
Horde_ActiveSync $as)
{
$this->_cache = $cache;
$this->_as = $as;
$this->_logger = $as->logger;
$this->_procid = getmypid();
}
/**
* Load all the collections we know about from the cache.
*/
public function loadCollectionsFromCache()
{
foreach ($this->_cache->getCollections(false) as $collection) {
if (empty($collection['synckey']) && !empty($collection['lastsynckey'])) {
$collection['synckey'] = $collection['lastsynckey'];
}
// Load the class if needed for EAS >= 12.1
if (empty($collection['class'])) {
$collection['class'] = $this->getCollectionClass($collection['id']);
}
if (empty($collection['serverid'])) {
try {
$collection['serverid'] = $this->getBackendIdForFolderUid($collection['id']);
} catch (Horde_ActiveSync_Exception $e) {
$this->_logger->err($e->getMessage());
continue;
}
}
$this->_collections[$collection['id']] = $collection;
$this->_logger->meta(sprintf(
'COLLECTIONS: Loaded %s from the cache.',
$collection['serverid'])
);
}
}
/**
* Magic...
*/
public function __call($method, $parameters)
{
switch ($method) {
case 'hasPingChangeFlag':
case 'addConfirmedKey':
case 'updateCollection':
case 'collectionExists':
case 'updateWindowSize':
return call_user_func_array(array($this->_cache, $method), $parameters);
}
throw new BadMethodCallException('Unknown method: ' . $method);
}
/**
* Property getter
*/
public function __get($property)
{
switch ($property) {
case 'hbinterval':
case 'wait':
case 'confirmed_synckeys':
case 'lasthbsyncstarted':
case 'lastsyncendnormal':
return $this->_cache->$property;
case 'importedChanges':
case 'shortSyncRequest':
case 'hangingSync':
$p = '_' . $property;
return $this->$p;
}
throw new InvalidArgumentException('Unknown property: ' . $property);
}
/**
* Property setter.
*/
public function __set($property, $value)
{
switch ($property) {
case 'importedChanges':
case 'shortSyncRequest':
case 'hangingSync':
$p = '_' . $property;
$this->$p = $value;
return;
case 'lasthbsyncstarted':
case 'lastsyncendnormal':
case 'hbinterval':
case 'wait':
$this->_cache->$property = $value;
return;
case 'confirmed_synckeys':
throw new InvalidArgumentException($property . ' is READONLY.');
}
throw new InvalidArgumentException('Unknown property: ' . $property);
}
/**
* Get a new collection array, populated with default values.
*
* @return array
*/
public function getNewCollection()
{
return array(
'clientids' => array(),
'fetchids' => array(),
'windowsize' => 100,
'soft' => false,
'conflict' => Horde_ActiveSync::CONFLICT_OVERWRITE_PIM
);
}
/**
* Ensure default OPTIONS values are populated, while not overwriting any
* existing values.
*
* @since 2.20.0
*/
public function ensureOptions()
{
foreach ($this->_collections as &$collection) {
$this->_logger->meta(sprintf(
'COLLECTIONS: Loading default OPTIONS for %s collection.',
$collection['id'])
);
if (!isset($collection['mimesupport'])) {
$collection['mimesupport'] = Horde_ActiveSync::MIME_SUPPORT_NONE;
}
if (!isset($collection['bodyprefs'])) {
$collection['bodyprefs'] = array();
}
}
}
/**
* Add a new populated collection array to the sync cache.
*
* @param array $collection The collection array.
* @param boolean $requireSyncKey Attempt to read missing synckey from
* cache if true. If not found, set to 0.
*
* @throws Horde_ActiveSync_Exception_StateGone Thrown when no synckey
* is provided when one is specified as required, indicating
* the state on the client is possibly corrupt or when the
* serverid can not be found by the backend.
*/
public function addCollection(array $collection, $requireSyncKey = false)
{
if ($requireSyncKey && empty($collection['synckey'])) {
$cached_collections = $this->_cache->getCollections(false);
$collection['synckey'] = !empty($cached_collections[$collection['id']])
? $cached_collections[$collection['id']]['lastsynckey']
: 0;
if ($collection['synckey'] === 0) {
$this->_logger->err('COLLECTIONS: Attempting to add a collection
to the sync cache while requiring a synckey, but no
synckey could be found. Most likely a client error in
requesting a collection during PING before it has issued a
SYNC.'
);
throw new Horde_ActiveSync_Exception_StateGone(
'Synckey required in Horde_ActiveSync_Collections::addCollection, but none was found.'
);
}
$this->_logger->meta(sprintf(
'COLLECTIONS: Obtained synckey for collection %s from cache: %s',
$collection['id'],
$collection['synckey'])
);
}
// Load the class if needed for EAS >= 12.1 and ensure we have the
// backend folder id.
if (empty($collection['class'])) {
$collection['class'] = $this->getCollectionClass($collection['id']);
}
try {
$collection['serverid'] = $this->getBackendIdForFolderUid($collection['id']);
} catch (Horde_ActiveSync_Exception $e) {
throw new Horde_ActiveSync_Exception_StateGone($e->getMessage());
}
$this->_collections[$collection['id']] = $collection;
$this->_logger->meta(sprintf(
'COLLECTIONS: Collection added to collection handler: collection: %s, synckey: %s.',
$collection['serverid'],
!empty($collection['synckey']) ? $collection['synckey'] : 'NONE')
);
}
/**
* Translate an EAS folder uid into a backend serverid.
*
* @param $id The uid.
*
* @return string The backend server id.
* @throws Horde_ActiveSync_Exception_FolderGone
*/
public function getBackendIdForFolderUid($folderid)
{
// Always use RI for recipient cache.
if ($folderid == 'RI') {
return $folderid;
}
$folder = $this->_cache->getFolder($folderid);
if ($folder) {
return $folder['serverid'];
} else {
$this->_logger->err('COLLECTIONS: Horde_ActiveSync_Collections::getBackendIdForFolderUid failed because folder was not found in cache.');
throw new Horde_ActiveSync_Exception_FolderGone('Folder not found in cache.');
}
}
/**
* Translate a backend id E.g., INBOX into an EAS folder uid.
*
* @param string $folderid The backend id.
*
* @return string The EAS uid.
*/
public function getFolderUidForBackendId($folderid)
{
// Always use 'RI' for Recipient cache.
if ($folderid == 'RI') {
return $folderid;
}
$map = $this->_as->state->getFolderUidToBackendIdMap();
if (empty($map[$folderid])) {
return false;
}
return $map[$folderid];
}
/**
* Return the count of available collections.
*
* @return integer
*/
public function collectionCount()
{
return count($this->_collections);
}
/**
* Return the count of collections in the cache only.
*
* @return integer
*/
public function cachedCollectionCount()
{
return $this->_cache->countCollections();
}
/**
* Set the getchanges flag on the specified collection.
*
* @param string $collection_id The collection id.
*
* @throws Horde_ActiveSync_Exception
*/
public function setGetChangesFlag($collection_id)
{
if (empty($this->_collections[$collection_id])) {
throw new Horde_ActiveSync_Exception('Missing collection data');
}
$this->_collections[$collection_id]['getchanges'] = true;
}
/**
* Get the getchanges flag on the specified collection.
*
* @param string $collection_id The collection id.
*
* @return boolean
* @throws Horde_ActiveSync_Exception
*/
public function getChangesFlag($collection_id)
{
if (empty($this->_collections[$collection_id])) {
throw new Horde_ActiveSync_Exception('Missing collection data');
}
return !empty($this->_collections[$collection_id]['getchanges']);
}
/**
* Sets the default WINDOWSIZE.
*
* Note that this is really a ceiling on the number of TOTAL responses
* that can be sent (including all collections). This method should be
* renamed for 3.0
*
* @param integer $window The windowsize
* @param boolean $override If true, this value will override any client
* supplied value.
*/
public function setDefaultWindowSize($window, $override = false)
{
if ($override) {
$this->_windowsizeOverride = true;
}
if ($override || empty($this->_windowsizeOverride)) {
$this->_globalWindowSize = $window;
}
}
public function getDefaultWindowSize()
{
return $this->_globalWindowSize;
}
/**
* Validates the collection data from the syncCache, filling in missing
* values from the folder cache.
*/
public function validateFromCache()
{
$this->_cache->validateCollectionsFromCache($this->_collections);
}
/**
* Updates data from the cache for collectons that are already loaded. Used
* to ensure looping SYNC and PING requests are operating on the most
* recent syncKey.
*/
public function updateCollectionsFromCache()
{
$this->_cache->refreshCollections();
$collections = $this->_cache->getCollections();
foreach (array_keys($this->_collections) as $id) {
if (!empty($collections[$id])) {
$this->_logger->meta(sprintf(
'COLLECTIONS: Refreshing %s from the cache.',
$id)
);
$this->_collections[$id] = $collections[$id];
}
}
}
/**
* Return a collection class given the collection id.
*
* @param string $id The collection id.
*
* @return string|boolean The collection class or false if not found.
*/
public function getCollectionClass($id)
{
if ($id == 'RI') {
return $id;
}
// First try existing, loaded collections.
if (!empty($this->_collections[$id])) {
return $this->_collections[$id]['class'];
}
// Next look in the SyncCache.
if (isset($this->_cache->folders[$id]['class'])) {
$class = $this->_cache->folders[$id]['class'];
$this->_logger->meta(sprintf(
'COLLECTIONS: Obtaining collection class of %s for collection id %s',
$class, $id)
);
return $class;
}
return false;
}
public function getCollectionType($id)
{
if ($id == 'RI') {
return $id;
}
// First try existing, loaded collections.
if (!empty($this->_collections[$id]['type'])) {
return $this->_collections[$id]['type'];
}
// Next look in the SyncCache.
if (isset($this->_cache->folders[$id]['type'])) {
$type = $this->_cache->folders[$id]['type'];
$this->_logger->meta(sprintf(
'COLLECTIONS: Obtaining collection type of %s for collection id %s',
$type, $id)
);
return $type;
}
return false;
}
/**
* Determine if we have any syncable collections either locally or in the
* sync cache.
*
* @param long $version The EAS version
*
* @return boolean
*/
public function haveSyncableCollections($version)
{
// Ensure we have syncable collections, using the cache if needed.
if ($version >= Horde_ActiveSync::VERSION_TWELVEONE && empty($this->_collections)) {
$this->_logger->meta('COLLECTIONS: No collections loaded, looking in sync_cache.');
$found = false;
foreach ($this->_cache->getCollections() as $value) {
if (isset($value['synckey'])) {
$this->_logger->meta(sprintf(
'COLLECTIONS: Found syncable collection: %s : %s.',
$value['serverid'], $value['synckey'])
);
$this->_collections[$value['id']] = $value;
$found = true;
}
}
return $found;
} elseif (empty($this->_collections)) {
return false;
}
$this->_logger->meta('COLLECTIONS: Have syncable collections!');
return true;
}
/**
* Set the looping sync heartbeat values.
*
* @param array $hb An array containing one or both of: hbinterval, wait.
*/
public function setHeartbeat(array $hb)
{
if (isset($hb['wait'])) {
$this->_cache->wait = $hb['wait'];
}
if (isset($hb['hbinterval'])) {
$this->_cache->hbinterval = $hb['hbinterval'];
}
}
/**
* Return the heartbeat interval. Always returned as the heartbeat (seconds)
* not wait interval (minutes).
*
* @return integer|boolean The number of seconds in a heartbeat, or false
* if no heartbeat set.
*/
public function getHeartbeat()
{
return !empty($this->_cache->hbinterval)
? $this->_cache->hbinterval
: (!empty($this->_cache->wait)
? $this->_cache->wait * 60
: false);
}
/**
* Return whether or not we want a looping sync. We can do a looping sync
* if we have no imported changes AND we have either a hbinterval, wait,
* or a shortSync.
*
* @return boolean True if we want a looping sync, false otherwise.
*/
public function canDoLoopingSync()
{
return $this->_hangingSync && !$this->_importedChanges && ($this->_shortSyncRequest || $this->_cache->hbinterval !== false || $this->_cache->wait !== false);
}
/**
* Return if the current looping sync is stale. A stale looping sync is one
* which has begun earlier than the most recently running sync reported by
* the syncCache.
*
* @return boolean True if the current looping sync is stale. False
* otherwise.
*/
public function checkStaleRequest()
{
return !$this->_cache->validateCache(true);
}
/**
* Return if we have a current folder hierarchy.
*
* @return boolean
*/
public function haveHierarchy()
{
return isset($this->_cache->hierarchy);
}
/**
* Prepare for a hierarchy sync.
*
* @param string $synckey The current synckey from the client.
*
* @return array An array of known folders.
*/
public function initHierarchySync($synckey)
{
$this->_as->state->loadState(
array(),
$synckey,
Horde_ActiveSync::REQUEST_TYPE_FOLDERSYNC);
// Refresh the cache since it might have changed like e.g., if synckey
// was empty.
$this->_cache->loadCacheFromStorage();
return $this->_as->state->getKnownFolders();
}
/**
* Update/Add a folder in the hierarchy cache.
*
* @param Horde_ActiveSync_Message_Folder $folder The folder object.
* @param boolean $update Update the state objects? @since 2.4.0
*/
public function updateFolderinHierarchy(
Horde_ActiveSync_Message_Folder $folder, $update = false)
{
$this->_cache->updateFolder($folder);
$cols = $this->_cache->getCollections(false);
$cols[$folder->serverid]['serverid'] = $folder->_serverid;
$this->_cache->updateCollection($cols[$folder->serverid]);
if ($update) {
$this->_as->state->updateServerIdInState($folder->serverid, $folder->_serverid);
}
}
/**
* Delete a folder from the hierarchy cache.
*
* @param string $id The folder's uid.
*/
public function deleteFolderFromHierarchy($uid)
{
$this->_cache->deleteFolder($uid);
$this->_as->state->removeState(array(
'id' => $uid,
'devId' => $this->_as->device->id,
'user' => $this->_as->device->user));
}
/**
* Return all know hierarchy changes.
*
* @return array An array of changes.
*/
public function getHierarchyChanges()
{
return $this->_as->state->getChanges();
}
/**
* Validate and perform some sanity checks on the hierarchy changes before
* being sent to the client.
*
* @param Horde_ActiveSync_Connector_Exporter_FolderSync $exporter The exporter.
* @param array $seenFolders An array of folders.
*/
public function validateHierarchyChanges(Horde_ActiveSync_Connector_Exporter_FolderSync $exporter, array $seenFolders)
{
if ($this->_as->device->version < Horde_ActiveSync::VERSION_TWELVEONE ||
count($exporter->changed)) {
return;
}
// Remove unnecessary changes.
foreach ($exporter->changed as $key => $folder) {
if (isset($folder->serverid) &&
$syncFolder = $this->_cache->getFolder($folder->serverid) &&
in_array($folder->serverid, $seenfolders) &&
$syncFolder['parentid'] == $folder->parentid &&
$syncFolder['displayname'] == $folder->displayname &&
$syncFolder['type'] == $folder->type) {
$this->_logger->meta(sprintf(
'COLLECTIONS: Ignoring %s from changes because it contains no changes from device.',
$folder->serverid)
);
unset($exporter->changed[$key]);
$exporter->count--;
}
}
// Remove unnecessary deletions.
foreach ($exporter->deleted as $key => $folder) {
if (($sid = array_search($folder, $seenfolders)) === false) {
$this->_logger->meta(sprintf(
'COLLECTIONS: Ignoring %s from deleted list because the device does not know it',
$folder)
);
unset($exporter->deleted[$key]);
$exporter->count--;
}
}
}
/**
* Update the hierarchy synckey in the cache.
*
* @param string $key The new/existing synckey.
*/
public function updateHierarchyKey($key)
{
$this->_cache->hierarchy = $key;
}
/**
* Prepares the syncCache for a full sync request.
*/
public function initFullSync()
{
$this->_cache->confirmed_synckeys = array();
$this->_cache->clearCollectionKeys();
}
/**
* Prepare the syncCache for an EMPTY sync request.
*
* @return boolean False if EMPTY request cannot be performed, otherwise
* true.
* @since 2.25.0
*/
public function initEmptySync()
{
$this->loadCollectionsFromCache();
foreach ($this->_collections as $value) {
// Remove keys from confirmed synckeys array and count them
if (isset($value['synckey'])) {
if (isset($this->_cache->confirmed_synckeys[$value['synckey']])) {
$this->_logger->meta(sprintf(
'COLLECTIONS: Removed %s from confirmed_synckeys',
$value['synckey'])
);
$this->_cache->removeConfirmedKey($value['synckey']);
}
$this->_synckeyCount++;
}
}
if (!$this->_checkConfirmedKeys()) {
$this->_logger->err('COLLECTIONS: Some synckeys were not confirmed, but handling an empty request. Requesting full SYNC');
$this->save();
return false;
}
$this->shortSyncRequest = true;
$this->hangingSync = true;
$this->save(true);
return true;
}
/**
* Prepares the syncCache for a partial sync request and checks that
* it is allowed.
*
* MS-ASCMD 2.2.3.124
*
* @return boolean True if parital sync is possible, false otherwise.
*/
public function initPartialSync()
{
// Need this for all PARTIAL sync requests.
$this->_tempSyncCache = clone $this->_cache;
// Short circuit if we only have a changed ping/wait interval.
if (empty($this->_collections)) {
$emptyCollections = true;
$this->_logger->meta('COLLECTIONS: No collections loaded, loading full collection set from cache.');
$this->loadCollectionsFromCache();
} else {
// Collect collection options sent from client and compare against
// last known collection options to determine which collections
// changed.
$emptyCollections = false;
$c = $this->_tempSyncCache->getCollections();
foreach ($this->_collections as $key => $value) {
// Collections from cache might not all have synckeys.
if (empty($c[$key])) {
continue;
}
$v1 = $value;
foreach ($v1 as $k => $o) {
if (is_null($o)) {
unset($v1[$k]);
}
}
unset($v1['id'], $v1['serverid'], $v1['clientids'], $v1['fetchids'],
$v1['getchanges'], $v1['changeids'], $v1['pingable'],
$v1['class'], $v1['synckey'], $v1['lastsynckey']
);
$v2 = $c[$key];
foreach ($v2 as $k => $o) {
if (is_null($o)) {
unset($v2[$k]);
}
}
unset($v2['id'], $v2['serverid'], $v2['pingable'], $v2['class'],
$v2['synckey'], $v2['lastsynckey']
);
ksort($v1);
if (isset($v1['bodyprefs'])) {
ksort($v1['bodyprefs']);
foreach (array_keys($v1['bodyprefs']) as $k) {
if (is_array($v1['bodyprefs'][$k])) {
ksort($v1['bodyprefs'][$k]);
}
}
}
ksort($v2);
if (isset($v2['bodyprefs'])) {
ksort($v2['bodyprefs']);
foreach (array_keys($v2['bodyprefs']) as $k) {
if (is_array($v2['bodyprefs'][$k])) {
ksort($v2['bodyprefs'][$k]);
}
}
}
if (md5(serialize($v1)) == md5(serialize($v2))) {
$this->_unchangedCount++;
}
// Unset in tempSyncCache, since we have it from device.
$this->_tempSyncCache->removeCollection($key);
// Populate _collections with missing collection data not sent.
$this->_getMissingCollectionsFromCache();
}
}
// Ensure we are both talking about the same synckey.
foreach ($this->_collections as $value) {
if (isset($value['synckey'])) {
if (isset($this->_cache->confirmed_synckeys[$value['synckey']])) {
$this->_logger->meta(sprintf(
'COLLECTIONS: Removed %s from confirmed_synckeys',
$value['synckey'])
);
$this->_cache->removeConfirmedKey($value['synckey']);
}
$this->_synckeyCount++;
}
}
if (!$this->_checkConfirmedKeys()) {
$this->_logger->warn('COLLECTIONS: Some synckeys were not confirmed. Requesting full SYNC');
$this->save();
return false;
}
if (!$emptyCollections && $this->_haveNoChangesInPartialSync()) {
$this->_logger->warn('COLLECTIONS: Partial Request with completely unchanged collections. Request a full SYNC');
return false;
}
return true;
}
protected function _checkConfirmedKeys()
{
$csk = $this->_cache->confirmed_synckeys;
if ($csk) {
$this->_logger->meta(sprintf(
'COLLECTIONS: Confirmed Synckeys contains %s',
serialize($csk))
);
return false;
}
return true;
}
/**
* Return if we can do an empty response
*
* @return boolean
*/
public function canSendEmptyResponse()
{
return !$this->_importedChanges &&
($this->_hangingSync && ($this->_cache->wait !== false || $this->_cache->hbinterval !== false));
}
/**
* Return if we have no changes to collection options, but have requested
* a partial sync. A partial sync must have either a wait, hbinterval,
* or some subset of collections to be valid.
*
* @return boolean
*/
protected function _haveNoChangesInPartialSync()
{
return $this->_synckeyCount > 0 &&
$this->_unchangedCount == $this->_synckeyCount &&
$this->_cache->wait == false && $this->_cache->hbinterval == false;
}
/**
* Populate the collections data with missing data from the syncCache during
* a PARTIAL SYNC.
*/
protected function _getMissingCollectionsFromCache()
{
if (empty($this->_tempSyncCache)) {
throw new Horde_ActiveSync_Exception('Did not initialize the PARTIAL sync.');
}
// Update _collections with all data that was not sent, but we
// have a synckey for in the sync_cache.
foreach ($this->_tempSyncCache->getCollections() as $value) {
// The collection might have been updated due to incoming
// changes. Some clients send COMMANDS in a PARTIAL sync and
// initializing the PARTIAL afterwards will overwrite the various
// flags stored in $collection['id'][]
if (!empty($this->_collections[$value['id']])) {
continue;
}
$this->_logger->meta(sprintf(
'COLLECTIONS: Using SyncCache State for %s',
$value['serverid']
));
if (empty($value['synckey'])) {
$value['synckey'] = $value['lastsynckey'];
}
$this->_collections[$value['id']] = $value;
}
}
/**
* Check for an update FILTERTYPE
*
* @param string $id The collection id to check
* @param string $filter The new filter value.
*
* @return boolean True if filtertype passed, false if it has changed.
*/
public function checkFilterType($id, $filter)
{
$cc = $this->_cache->getCollections();
if (!empty($cc[$id]['filtertype']) &&
!is_null($filter) &&
$cc[$id]['filtertype'] != $filter) {
$this->_logger->meta(sprintf(
'COLLECTIONS: Filtertype change from: %d to %d',
$cc[$id]['filtertype'], $filter)
);
$this->_cache->updateFiltertype($id, $filter);
return false;
}
return true;
}
/**
* Update the syncCache with current collection data.
*/
public function updateCache()
{
foreach ($this->_collections as $value) {
$this->_cache->updateCollection($value);
}
}
/**
* Save the syncCache to storage.
*
* @param boolean $preserve_folders If true, ensure the folder cache is not
* overwritten. @since 2.18.0
* @todo Refactor this hack away. Requires a complete refactor of the cache.
*/
public function save($preserve_folders = false)
{
// HOTFIX. Need to check the timestamp to see if we should reload the
// folder cache before saving to ensure it isn't overwritten. See
// Bug: 13273
if ($preserve_folders && !$this->_cache->validateCache()) {
$this->_logger->meta('COLLECTIONS: Updating the foldercache before saving.');
$this->_cache->refreshFolderCache();
}
$this->_cache->save();
}
/**
* Attempt to initialize the sync state.
*
* @param array $collection The collection array.
* @param boolean $requireSyncKey Require collection to have a synckey and
* throw exception if it's not present.
*
* @throws Horde_ActiveSync_Exception_InvalidRequest
* @throws Horde_ActiveSync_Exception_FolderGone
*/
public function initCollectionState(array &$collection, $requireSyncKey = false)
{
// Clear the changes cache.
$this->_changes = null;
// Ensure we have a collection class.
if (empty($collection['class'])) {
if (!($collection['class'] = $this->getCollectionClass($collection['id']))) {
throw new Horde_ActiveSync_Exception_FolderGone('Could not load collection class for ' . $collection['id']);
}
}
// Load the collection's type if we can.
if (empty($collection['type'])) {
$collection['type'] = $this->getCollectionType($collection['id']);
}
// Get the backend serverid.
if (empty($collection['serverid'])) {
$collection['serverid'] = $this->getBackendIdForFolderUid($collection['id']);
}
if ($requireSyncKey && empty($collection['synckey'])) {
throw new Horde_ActiveSync_Exception_InvalidRequest(sprintf(
'Empty synckey for %s.',
$collection['id'])
);
}
// Initialize the state
$this->_logger->info(sprintf(
'COLLECTIONS: Initializing state for collection: %s, synckey: %s',
$collection['serverid'],
$collection['synckey'])
);
$this->_as->state->loadState(
$collection,
$collection['synckey'],
Horde_ActiveSync::REQUEST_TYPE_SYNC,
$collection['id']);
}
/**
* Poll the backend for changes.
*
* @param integer $heartbeat The heartbeat lifetime to wait for changes.
* @param integer $interval The wait interval between poll iterations.
* @param array $options An options array containing any of:
* - pingable: (boolean) Only poll collections with the pingable flag set.
* DEFAULT: false
*
* @return boolean|integer True if changes were detected in any of the
* collections, false if no changes detected
* or a status code if failed.
*/
public function pollForChanges($heartbeat, $interval, array $options = array())
{
$dataavailable = false;
$started = time();
$until = $started + $heartbeat;
$this->_logger->info(sprintf(
'COLLECTIONS: Waiting for changes for %s seconds',
$heartbeat)
);
// If pinging, make sure we have pingable collections. Note we can't
// filter on them here because the collections might change during the
// loop below.
if (!empty($options['pingable']) && !$this->havePingableCollections()) {
$this->_logger->err('COLLECTIONS: No pingable collections.');
return self::COLLECTION_ERR_SERVER;
}
// Need to update AND SAVE the timestamp for race conditions to be
// detected.
$this->lasthbsyncstarted = $started;
$this->save();
// We only check for remote wipe request once every 5 iterations to
// save on DB load since we must reload the device's state each time.
$rw_check_countdown = 5;
while (($now = time()) < $until) {
// Try not to go over the heartbeat interval.
if ($until - $now < $interval) {
$interval = $until - $now;
}
// See if another process has altered the sync_cache.
if ($this->checkStaleRequest()) {
return self::COLLECTION_ERR_STALE;
}
// Make sure the collections are still there (there might have been
// an error in refreshing them from the cache). Ideally this should
// NEVER happen.
if (!count($this->_collections)) {
$this->_logger->err('NO COLLECTIONS! This should not happen!');
return self::COLLECTION_ERR_SERVER;
}
// Check for WIPE request once every 5 iterations to balance between
// performance and speed of catching a remote wipe request.
if ($rw_check_countdown-- == 0) {
$rw_check_countdown = 5;
if ($this->_as->provisioning != Horde_ActiveSync::PROVISIONING_NONE) {
$rwstatus = $this->_as->state->getDeviceRWStatus($this->_as->device->id, true);
if ($rwstatus == Horde_ActiveSync::RWSTATUS_PENDING || $rwstatus == Horde_ActiveSync::RWSTATUS_WIPED) {
return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED;
}
}
}
// Check each collection we are interested in.
foreach ($this->_collections as $id => $collection) {
// Initialize the collection's state data in the state handler.
try {
$this->initCollectionState($collection, true);
} catch (Horde_ActiveSync_Exception_StateGone $e) {
$this->_logger->notice(sprintf(
'COLLECTIONS: State not found for %s. Continuing by rquesting a SYNC.',
$id)
);
$dataavailable = true;
$this->setGetChangesFlag($id);
continue;
} catch (Horde_ActiveSync_Exception_InvalidRequest $e) {
// Thrown when state is unable to be initialized because the
// collection has not yet been synched, but was requested to
// be pinged.
$this->_logger->err(sprintf(
'COLLECTIONS: Unable to initialize state for %s. Ignoring during pollForChanges: %s.',
$id,
$e->getMessage())
);
continue;
} catch (Horde_ActiveSync_Exception_FolderGone $e) {
$this->_logger->warn('COLLECTIONS: Folder gone for collection ' . $collection['id']);
return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED;
} catch (Horde_ActiveSync_Exception $e) {
$this->_logger->err('COLLECTIONS: Error loading state: ' . $e->getMessage());
$this->_as->state->loadState(
array(),
null,
Horde_ActiveSync::REQUEST_TYPE_SYNC,
$id);
$this->setGetChangesFlag($id);
$dataavailable = true;
continue;
}
if (!empty($options['pingable']) && !$this->_cache->collectionIsPingable($id)) {
$this->_logger->notice(sprintf('COLLECTIONS: Skipping %s because it is not PINGable.', $id));
continue;
}
try {
if ($cnt = $this->getCollectionChangeCount(true)) {
$dataavailable = true;
$this->setGetChangesFlag($id);
if (!empty($options['pingable'])) {
$this->_cache->setPingChangeFlag($id);
}
} else {
try {
$this->_as->state->updateSyncStamp();
} catch (Horde_ActiveSync_Exception $e) {
$this->_logger->err($e->getMessage());
}
}
} catch (Horde_ActiveSync_Exception_StaleState $e) {
$this->_logger->notice(sprintf(
'COLLECTIONS: SYNC terminating and force-clearing device state: %s',
$e->getMessage())
);
$this->_as->state->loadState(
array(),
null,
Horde_ActiveSync::REQUEST_TYPE_SYNC,
$id);
$this->setGetChangesFlag($id);
$dataavailable = true;
} catch (Horde_ActiveSync_Exception_FolderGone $e) {
$this->_logger->notice(sprintf(
'COLLECTIONS: SYNC terminating: %s',
$e->getMessage())
);
// If we are missing a folder, we should clear the PING
// cache also, to be sure it picks up any hierarchy changes
// since most clients don't seem smart enough to figure this
// out on their own.
$this->resetPingCache();
return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED;
} catch (Horde_Exception_AuthenticationFailure $e) {
// We lost authentication for some reason.
$this->_logger->err('COLLECTIONS: Authentication lost during PING!!');
return self::COLLECTION_ERR_AUTHENTICATION;
} catch (Horde_ActiveSync_Exception $e) {
$this->_logger->err(sprintf(
'COLLECTIONS: Sync object cannot be configured, throttling: %s',
$e->getMessage())
);
$this->_sleep(30);
continue;
}
}
if (!empty($dataavailable)) {
$this->_logger->info('COLLECTIONS: Found changes!');
break;
}
// Wait a bit...
$this->_sleep($interval);
// Refresh the collections.
$this->updateCollectionsFromCache();
}
// Check that no other Sync process already started
// If so, we exit here and let the other process do the export.
if ($this->checkStaleRequest()) {
$this->_logger->meta('COLLECTIONS: Changes in cache determined during Sync Wait/Heartbeat, exiting here.');
return self::COLLECTION_ERR_STALE;
}
$this->_logger->meta(sprintf(
'COLLECTIONS: Looping Sync complete: DataAvailable: %s, DataImported: %s',
$dataavailable,
$this->importedChanges)
);
return $dataavailable;
}
/**
* Wait for specified interval, and close any backend connections while
* we wait.
*
* @param integer $interval The number of seconds to sleep.
*/
protected function _sleep($interval)
{
// Wait.
$this->_logger->info(sprintf(
'%sCOLLECTIONS: Sleeping for %s seconds.',
str_repeat('-', 10),
$interval));
// Close any backend connections.
$this->_cache->state->disconnect();
sleep ($interval);
$this->_cache->state->connect();
}
/**
* Check if we have any pingable collections.
*
* @return boolean True if we have collections marked as pingable.
*/
public function havePingableCollections()
{
foreach (array_keys($this->_collections) as $id) {
if ($this->_cache->collectionIsPingable($id)) {
return true;
}
}
return false;
}
/**
* Marks all loaded collections with a synckey as pingable.
*/
public function updatePingableFlag()
{
$collections = $this->_cache->getCollections(false);
foreach ($collections as $id => $collection) {
if (!empty($this->_collections[$id]['synckey'])) {
$this->_logger->meta(sprintf(
'COLLECTIONS: Setting collection %s (%s) PINGABLE.',
$collection['serverid'],
$id)
);
$this->_cache->setPingableCollection($id);
} else {
$this->_logger->meta(sprintf(
'COLLECTIONS: UNSETTING collection %s (%s) PINGABLE flag.',
$collection['serverid'],
$id)
);
$this->_cache->removePingableCollection($id);
}
}
}
/**
* Force reset all collection's PINGABLE flag. Used to force client
* to issue a non-empty PING request.
*
*/
public function resetPingCache()
{
$collections = $this->_cache->getCollections(false);
foreach ($collections as $id => $collection) {
$this->_logger->meta(sprintf(
'COLLECTIONS: UNSETTING collection %s (%s) PINGABLE flag.',
$collection['serverid'],
$id)
);
$this->_cache->removePingableCollection($id);
}
}
/**
* Return any changes for the current collection.
*
* @param boolean $ping True if this is a PING request, false otherwise.
* If true, we only detect that a change has occured,
* not the data on all of the changes.
* @param array $ensure An array of UIDs that should be sent in the
* current response if possible, and not put off
* because of a MOREAVAILABLE situation.
* @deprecated and no longer used.
*
* @return array The changes array.
*/
public function getCollectionChanges($ping = false, array $ensure = array())
{
if (empty($this->_changes)) {
$this->_changes = $this->_as->state->getChanges(array('ping' => $ping));
}
return $this->_changes;
}
/**
* Return the count of the current collection's chagnes.
*
* @param boolean $ping Only ping the collection if true.
*
* @return integer The change count.
*/
public function getCollectionChangeCount($ping = false)
{
if (empty($this->_changes)) {
$this->getCollectionChanges($ping);
}
return count($this->_changes);
}
/**
* Iterator
*/
public function getIterator()
{
return new ArrayIterator($this->_collections);
}
}