diff --git a/lib/Horde/ActiveSync/Collections.php b/lib/Horde/ActiveSync/Collections.php index 98b1f63e..5b268285 100644 --- a/lib/Horde/ActiveSync/Collections.php +++ b/lib/Horde/ActiveSync/Collections.php @@ -666,7 +666,8 @@ public function checkStaleRequest() */ public function haveHierarchy() { - return isset($this->_cache->hierarchy); + return !empty($this->_cache->hierarchy) + && $this->_cache->hierarchy !== '0'; } /** @@ -1163,12 +1164,33 @@ public function pollForChanges($heartbeat, $interval, array $options = []) ) ); - // 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; + if (!empty($options['pingable'])) { + if ($this->collectionsNeedFolderResync()) { + return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED; + } + + $this->restorePingableCollectionsFromCache(); + + // 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 (!$this->havePingableCollections()) { + $this->_logger->err('COLLECTIONS: No pingable collections.'); + if ($this->_as->device->version >= Horde_ActiveSync::VERSION_TWELVEONE + && !$this->haveHierarchy()) { + return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED; + } + + return self::COLLECTION_ERR_SERVER; + } + } + + if ($this->_as->device->version >= Horde_ActiveSync::VERSION_TWELVEONE + && !$this->haveHierarchy()) { + $this->_logger->info( + 'COLLECTIONS: Hierarchy sync required, terminating pollForChanges.' + ); + return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED; } // Need to update AND SAVE the timestamp for race conditions to be @@ -1218,9 +1240,22 @@ public function pollForChanges($heartbeat, $interval, array $options = []) try { $this->initCollectionState($collection, true); } catch (Horde_ActiveSync_Exception_StateGone $e) { + if (!empty($options['pingable'])) { + $this->_logger->notice( + sprintf( + 'COLLECTIONS: State not found for %s during PING; dropping orphan collection from cache.', + $id + ) + ); + $this->_cache->removePingableCollection($id); + $this->_cache->removeCollection($id, true); + unset($this->_collections[$id]); + $this->save(); + continue; + } $this->_logger->notice( sprintf( - 'COLLECTIONS: State not found for %s. Continuing by rquesting a SYNC.', + 'COLLECTIONS: State not found for %s. Continuing by requesting a SYNC.', $id ) ); @@ -1386,13 +1421,109 @@ public function havePingableCollections() return false; } + /** + * True if a collection has item sync state but no folder cache entry. + * + * The client must run FolderSync to remap folder ids (common after a + * partial cache reset or hierarchy invalidation). + * + * @return boolean + */ + public function collectionsNeedFolderResync() + { + foreach ($this->_cache->getCollections(false) as $id => $collection) { + $synckey = $collection['synckey'] ?? ''; + if ($synckey === '' && !empty($collection['lastsynckey'])) { + $synckey = $collection['lastsynckey']; + } + if ($synckey === '' || $synckey === '0') { + continue; + } + if (!$this->_cache->getFolder($id)) { + $this->_logger->info( + sprintf( + 'COLLECTIONS: Collection %s has sync state but no folder cache entry; FolderSync required.', + $id + ) + ); + + return true; + } + } + + return false; + } + + /** + * Re-mark collections that have synckeys as PINGable. + * + * Outlook and similar clients may send explicit PING folder lists before + * synckeys exist; a previous request must not leave the device with no + * pingable collections until the user resets the account. + * + * @return boolean True if the sync cache was updated. + */ + public function restorePingableCollectionsFromCache() + { + $updated = false; + + foreach ($this->_cache->getCollections(false) as $id => $collection) { + $synckey = $collection['synckey'] ?? ''; + if ($synckey === '' && !empty($collection['lastsynckey'])) { + $synckey = $collection['lastsynckey']; + } + if ($synckey === '' || $synckey === '0' || !$this->_cache->getFolder($id)) { + continue; + } + + if (!$this->_cache->collectionIsPingable($id)) { + $this->_cache->setPingableCollection($id); + $updated = true; + } + + if (!isset($this->_collections[$id])) { + if (empty($collection['class'])) { + try { + $collection['class'] = $this->getCollectionClass($id); + } catch (Horde_ActiveSync_Exception $e) { + continue; + } + } + if (empty($collection['serverid'])) { + try { + $collection['serverid'] = $this->getBackendIdForFolderUid($id); + } catch (Horde_ActiveSync_Exception $e) { + continue; + } + } + if (empty($collection['synckey']) && !empty($collection['lastsynckey'])) { + $collection['synckey'] = $collection['lastsynckey']; + } + $this->_collections[$id] = $collection; + } + } + + if ($updated) { + $this->save(); + } + + return $updated; + } + /** * Marks all loaded collections with a synckey as pingable. */ public function updatePingableFlag() { + if (!count($this->_collections)) { + return; + } + $collections = $this->_cache->getCollections(false); foreach ($collections as $id => $collection) { + if (!isset($this->_collections[$id])) { + continue; + } if (!empty($this->_collections[$id]['synckey'])) { $this->_logger->meta( sprintf( diff --git a/lib/Horde/ActiveSync/Request/Ping.php b/lib/Horde/ActiveSync/Request/Ping.php index c9a2c3f5..4349bd9e 100644 --- a/lib/Horde/ActiveSync/Request/Ping.php +++ b/lib/Horde/ActiveSync/Request/Ping.php @@ -143,10 +143,22 @@ protected function _handle() $this->_logger->info('Handling empty PING request.'); $isEmpty = true; $collections->loadCollectionsFromCache(); + $collections->restorePingableCollectionsFromCache(); if ($collections->collectionCount() == 0 || !$collections->havePingableCollections()) { - $this->_logger->warn('Empty PING request with no cached collections. Request full PING.'); - $this->_statusCode = self::STATUS_MISSING; + if ($collections->collectionsNeedFolderResync() + || ($this->_device->version >= Horde_ActiveSync::VERSION_TWELVEONE + && !$collections->haveHierarchy())) { + $this->_logger->info( + 'Empty PING with stale folder or hierarchy state; requesting FolderSync.' + ); + $this->_statusCode = self::STATUS_FOLDERSYNCREQD; + } else { + $this->_logger->warn( + 'Empty PING request with no cached collections. Request full PING.' + ); + $this->_statusCode = self::STATUS_MISSING; + } $this->_handleGlobalError(); return true; } @@ -162,6 +174,7 @@ protected function _handle() } $this->_logger->meta(sprintf('Actual heartbeat value in use is %s.', $heartbeat)); if ($this->_decoder->getElementStartTag(self::FOLDERS)) { + $pingCollectionsLoaded = false; while ($this->_decoder->getElementStartTag(self::FOLDER)) { $collection = []; if ($this->_decoder->getElementStartTag(self::SERVERENTRYID)) { @@ -181,14 +194,43 @@ protected function _handle() // iOS clients that request collections in PING before // they issue an initial SYNC for them. $collections->addCollection($collection, true); + $pingCollectionsLoaded = true; } catch (Horde_ActiveSync_Exception_StateGone $e) { } } // Since PING sends all or none (no PARTIAL) we update the // pingable flags so we have it for an empty PING. - $collections->validateFromCache(); - $collections->updatePingableFlag(); + if ($pingCollectionsLoaded) { + $collections->validateFromCache(); + $collections->updatePingableFlag(); + } else { + // Outlook and others may list folders in PING before a + // synckey exists; do not clear pingable flags on cached + // collections. Fall back to the last known PING set. + $this->_logger->info( + 'No collections loaded from explicit PING folder list; using cached collections.' + ); + $collections->loadCollectionsFromCache(); + $collections->restorePingableCollectionsFromCache(); + if ($collections->collectionCount() == 0) { + if ($collections->collectionsNeedFolderResync() + || ($this->_device->version >= Horde_ActiveSync::VERSION_TWELVEONE + && !$collections->haveHierarchy())) { + $this->_logger->info( + 'Explicit PING with stale folder or hierarchy state; requesting FolderSync.' + ); + $this->_statusCode = self::STATUS_FOLDERSYNCREQD; + } else { + $this->_logger->warn( + 'Explicit PING request with no loadable collections.' + ); + $this->_statusCode = self::STATUS_MISSING; + } + $this->_handleGlobalError(); + return true; + } + } if (!$this->_decoder->getElementEndTag()) { throw new Horde_ActiveSync_Exception('Protocol Error'); @@ -210,6 +252,16 @@ protected function _handle() // Start waiting for changes, but only if we don't have any errors if ($this->_statusCode == self::STATUS_NOCHANGES) { + if ($this->_device->version >= Horde_ActiveSync::VERSION_TWELVEONE + && !$collections->haveHierarchy()) { + $this->_logger->info( + 'No HIERARCHY SYNCKEY in sync_cache during PING, requesting FolderSync.' + ); + $this->_statusCode = self::STATUS_FOLDERSYNCREQD; + $this->_handleGlobalError(); + return true; + } + $changes = $collections->pollForChanges($heartbeat, $interval, ['pingable' => true]); if ($changes !== true && $changes !== false) { switch ($changes) { @@ -236,12 +288,14 @@ protected function _handle() if ($this->_device->version < Horde_ActiveSync::VERSION_FOURTEEN) { $this->_logger->warn('Version is < 14.0, returning false since we have no PINGABLE collections.'); return false; - } else { - $this->_logger->warn('Version is >= 14.0 returning status code 132 since we have no PINGABLE collections.'); - $this->_statusCode = Horde_ActiveSync_Status::STATEFILE_NOT_FOUND; - $this->_handleGlobalError(); - return true; } + + $this->_logger->warn( + 'No PINGABLE collections; requesting FolderSync instead of status 132.' + ); + $this->_statusCode = self::STATUS_FOLDERSYNCREQD; + $this->_handleGlobalError(); + return true; } } elseif ($changes) { $collections->save(true);