1<?php
2/***********************************************
3* File      :   synccollections.php
4* Project   :   Z-Push
5* Descr     :   This is basically a list of synched folders with it's
6*               respective SyncParameters, while some additional parameters
7*               which are not stored there can be kept here.
8*               The class also provides CheckForChanges which is basically
9*               a loop through all collections checking for changes.
10*               SyncCollections is used for Sync (with and without heartbeat)
11*               and Ping connections.
12*               To check for changes in Heartbeat and Ping requeste the same
13*               sync states as for the default synchronization are used.
14*
15* Created   :   06.01.2012
16*
17* Copyright 2007 - 2016 Zarafa Deutschland GmbH
18*
19* This program is free software: you can redistribute it and/or modify
20* it under the terms of the GNU Affero General Public License, version 3,
21* as published by the Free Software Foundation.
22*
23* This program is distributed in the hope that it will be useful,
24* but WITHOUT ANY WARRANTY; without even the implied warranty of
25* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
26* GNU Affero General Public License for more details.
27*
28* You should have received a copy of the GNU Affero General Public License
29* along with this program.  If not, see <http://www.gnu.org/licenses/>.
30*
31* Consult LICENSE file for details
32************************************************/
33
34class SyncCollections implements Iterator {
35    const ERROR_NO_COLLECTIONS = 1;
36    const ERROR_WRONG_HIERARCHY = 2;
37    const OBSOLETE_CONNECTION = 3;
38    const HIERARCHY_CHANGED = 4;
39
40    private $stateManager;
41
42    private $collections = array();
43    private $addparms = array();
44    private $changes = array();
45    private $saveData = true;
46
47    private $refPolicyKey = false;
48    private $refLifetime = false;
49
50    private $globalWindowSize;
51    private $lastSyncTime;
52
53    private $waitingTime = 0;
54    private $hierarchyExporterChecked = false;
55    private $loggedGlobalWindowSizeOverwrite = false;
56
57
58    /**
59     * Invalidates all pingable flags for all folders.
60     *
61     * @access public
62     * @return boolean
63     */
64    static public function InvalidatePingableFlags() {
65        ZLog::Write(LOGLEVEL_DEBUG, "SyncCollections::InvalidatePingableFlags(): Invalidating now");
66        try {
67            $sc = new SyncCollections();
68            $sc->LoadAllCollections();
69            foreach ($sc as $folderid => $spa) {
70                if ($spa->GetPingableFlag() == true) {
71                    $spa->DelPingableFlag();
72                    $sc->SaveCollection($spa);
73                }
74            }
75            return true;
76        }
77        catch (ZPushException $e) {}
78        return false;
79    }
80
81    /**
82     * Constructor
83     */
84    public function __construct() {
85    }
86
87    /**
88     * Sets the StateManager for this object
89     * If this is not done and a method needs it, the StateManager will be
90     * requested from the DeviceManager
91     *
92     * @param StateManager  $statemanager
93     *
94     * @access public
95     * @return
96     */
97    public function SetStateManager($statemanager) {
98        $this->stateManager = $statemanager;
99    }
100
101    /**
102     * Loads all collections known for the current device
103     *
104     * @param boolean $overwriteLoaded          (opt) overwrites Collection with saved state if set to true
105     * @param boolean $loadState                (opt) indicates if the collection sync state should be loaded, default false
106     * @param boolean $checkPermissions         (opt) if set to true each folder will pass
107     *                                          through a backend->Setup() to check permissions.
108     *                                          If this fails a StatusException will be thrown.
109     * @param boolean $loadHierarchy            (opt) if the hierarchy sync states should be loaded, default false
110     * @param boolean $confirmedOnly            (opt) indicates if only confirmed states should be loaded, default: false
111     *
112     * @access public
113     * @throws StatusException                  with SyncCollections::ERROR_WRONG_HIERARCHY if permission check fails
114     * @throws StateInvalidException            if the sync state can not be found or relation between states is invalid ($loadState = true)
115     * @return boolean
116     */
117    public function LoadAllCollections($overwriteLoaded = false, $loadState = false, $checkPermissions = false, $loadHierarchy = false, $confirmedOnly = false) {
118        $this->loadStateManager();
119
120        // this operation should not remove old state counters
121        $this->stateManager->DoNotDeleteOldStates();
122
123        $invalidStates = false;
124        foreach($this->stateManager->GetSynchedFolders() as $folderid) {
125            if ($overwriteLoaded === false && isset($this->collections[$folderid]))
126                continue;
127
128            // Load Collection!
129            if (! $this->LoadCollection($folderid, $loadState, $checkPermissions, $confirmedOnly))
130                $invalidStates = true;
131        }
132
133        // load the hierarchy data - there are no permissions to verify so we just set it to false
134        if ($loadHierarchy && !$this->LoadCollection(false, $loadState, false, false))
135            throw new StatusException("Invalid states found while loading hierarchy data. Forcing hierarchy sync");
136
137        if ($invalidStates)
138            throw new StateInvalidException("Invalid states found while loading collections. Forcing sync");
139
140        return true;
141    }
142
143    /**
144     * Loads all collections known for the current device
145     *
146     * @param string $folderid                  folder id to be loaded
147     * @param boolean $loadState                (opt) indicates if the collection sync state should be loaded, default true
148     * @param boolean $checkPermissions         (opt) if set to true each folder will pass
149     *                                          through a backend->Setup() to check permissions.
150     *                                          If this fails a StatusException will be thrown.
151     * @param boolean $confirmedOnly            (opt) indicates if only confirmed states should be loaded, default: false
152     *
153     * @access public
154     * @throws StatusException                  with SyncCollections::ERROR_WRONG_HIERARCHY if permission check fails
155     * @throws StateInvalidException            if the sync state can not be found or relation between states is invalid ($loadState = true)
156     * @return boolean
157     */
158    public function LoadCollection($folderid, $loadState = false, $checkPermissions = false, $confirmedOnly = false) {
159        $this->loadStateManager();
160
161        try {
162            // Get SyncParameters for the folder from the state
163            $spa = $this->stateManager->GetSynchedFolderState($folderid, !$loadState);
164
165            // TODO remove resync of folders for < Z-Push 2 beta4 users
166            // this forces a resync of all states previous to Z-Push 2 beta4
167            if (! $spa instanceof SyncParameters) {
168                throw new StateInvalidException("Saved state are not of type SyncParameters");
169            }
170
171            if ($spa->GetUuidCounter() == 0) {
172                ZLog::Write(LOGLEVEL_DEBUG, "SyncCollections->LoadCollection(): Found collection with move state only, ignoring.");
173                return true;
174            }
175        }
176        catch (StateInvalidException $sive) {
177            // in case there is something wrong with the state, just stop here
178            // later when trying to retrieve the SyncParameters nothing will be found
179
180            if ($folderid === false) {
181                throw new StatusException(sprintf("SyncCollections->LoadCollection(): could not get FOLDERDATA state of the hierarchy uuid: %s", $spa->GetUuid()), self::ERROR_WRONG_HIERARCHY);
182            }
183
184            // we also generate a fake change, so a sync on this folder is triggered
185            $this->changes[$folderid] = 1;
186
187            return false;
188        }
189
190        // if this is an additional folder the backend has to be setup correctly
191        if ($checkPermissions === true && ! ZPush::GetBackend()->Setup(ZPush::GetAdditionalSyncFolderStore($spa->GetBackendFolderId())))
192            throw new StatusException(sprintf("SyncCollections->LoadCollection(): could not Setup() the backend for folder id %s/%s", $spa->GetFolderId(), $spa->GetBackendFolderId()), self::ERROR_WRONG_HIERARCHY);
193
194        // add collection to object
195        $addStatus = $this->AddCollection($spa);
196
197        // load the latest known syncstate if requested
198        if ($addStatus && $loadState === true) {
199            try {
200                // make sure the hierarchy cache is loaded when we are loading hierarchy states
201                $this->addparms[$folderid]["state"] = $this->stateManager->GetSyncState($spa->GetLatestSyncKey($confirmedOnly), ($folderid === false));
202            }
203            catch (StateNotFoundException $snfe) {
204                // if we can't find the state, first we should try a sync of that folder, so
205                // we generate a fake change, so a sync on this folder is triggered
206                $this->changes[$folderid] = 1;
207
208                // make sure this folder is fully synched on next Sync request
209                $this->invalidateFolderStat($spa);
210
211                return false;
212            }
213        }
214
215        return $addStatus;
216    }
217
218    /**
219     * Saves a SyncParameters Object
220     *
221     * @param SyncParamerts $spa
222     *
223     * @access public
224     * @return boolean
225     */
226    public function SaveCollection($spa) {
227        if (! $this->saveData || !$spa->HasFolderId())
228            return false;
229
230        if ($spa->IsDataChanged()) {
231            $this->loadStateManager();
232            ZLog::Write(LOGLEVEL_DEBUG, sprintf("SyncCollections->SaveCollection(): Data of folder '%s' changed", $spa->GetFolderId()));
233
234            // save new windowsize
235            if (isset($this->globalWindowSize))
236                $spa->SetWindowSize($this->globalWindowSize);
237
238            // update latest lifetime
239            if (isset($this->refLifetime))
240                $spa->SetReferenceLifetime($this->refLifetime);
241
242            return $this->stateManager->SetSynchedFolderState($spa);
243        }
244        return false;
245    }
246
247    /**
248     * Adds a SyncParameters object to the current list of collections
249     *
250     * @param SyncParameters $spa
251     *
252     * @access public
253     * @return boolean
254     */
255    public function AddCollection($spa) {
256        if (! $spa->HasFolderId())
257            return false;
258
259        if ($spa->GetKoeGabFolder() === true) {
260            // put KOE GAB at the beginning of the sync
261            $this->collections = [$spa->GetFolderId() => $spa] + $this->collections;
262            ZLog::Write(LOGLEVEL_DEBUG, "SyncCollections->AddCollection(): Prioritizing KOE GAB folder for synchronization");
263        }
264        else {
265            $this->collections[$spa->GetFolderId()] = $spa;
266        }
267
268        ZLog::Write(LOGLEVEL_DEBUG, sprintf("SyncCollections->AddCollection(): Folder id '%s' : ref. PolicyKey '%s', ref. Lifetime '%s', last sync at '%s'", $spa->GetFolderId(), $spa->GetReferencePolicyKey(), $spa->GetReferenceLifetime(), $spa->GetLastSyncTime()));
269        if ($spa->HasLastSyncTime() && $spa->GetLastSyncTime() > $this->lastSyncTime) {
270            $this->lastSyncTime = $spa->GetLastSyncTime();
271
272            // use SyncParameters PolicyKey as reference if available
273            if ($spa->HasReferencePolicyKey())
274                $this->refPolicyKey = $spa->GetReferencePolicyKey();
275
276            // use SyncParameters LifeTime as reference if available
277            if ($spa->HasReferenceLifetime())
278                $this->refLifetime = $spa->GetReferenceLifetime();
279
280            ZLog::Write(LOGLEVEL_DEBUG, sprintf("SyncCollections->AddCollection(): Updated reference PolicyKey '%s', reference Lifetime '%s', Last sync at '%s'", $this->refPolicyKey, $this->refLifetime, $this->lastSyncTime));
281        }
282
283        return true;
284    }
285
286    /**
287     * Returns a previousily added or loaded SyncParameters object for a folderid
288     *
289     * @param SyncParameters $spa
290     *
291     * @access public
292     * @return SyncParameters / boolean      false if no SyncParameters object is found for folderid
293     */
294    public function GetCollection($folderid) {
295        if (isset($this->collections[$folderid]))
296            return $this->collections[$folderid];
297        else
298            return false;
299    }
300
301    /**
302     * Indicates if there are any loaded CPOs
303     *
304     * @access public
305     * @return boolean
306     */
307    public function HasCollections() {
308        return ! empty($this->collections);
309    }
310
311    /**
312     * Indicates the amount of collections loaded.
313     *
314     * @access public
315     * @return int
316     */
317    public function GetCollectionCount() {
318        return count($this->collections);
319    }
320
321    /**
322     * Add a non-permanent key/value pair for a SyncParameters object
323     *
324     * @param SyncParameters    $spa    target SyncParameters
325     * @param string            $key
326     * @param mixed             $value
327     *
328     * @access public
329     * @return boolean
330     */
331    public function AddParameter($spa, $key, $value) {
332        if (!$spa->HasFolderId())
333            return false;
334
335        $folderid = $spa->GetFolderId();
336        if (!isset($this->addparms[$folderid]))
337            $this->addparms[$folderid] = array();
338
339        $this->addparms[$folderid][$key] = $value;
340        return true;
341    }
342
343    /**
344     * Returns a previousily set non-permanent value for a SyncParameters object
345     *
346     * @param SyncParameters    $spa    target SyncParameters
347     * @param string            $key
348     *
349     * @access public
350     * @return mixed            returns 'null' if nothing set
351     */
352    public function GetParameter($spa, $key) {
353        if (!$spa->HasFolderId())
354            return null;
355
356        if (isset($this->addparms[$spa->GetFolderId()]) && isset($this->addparms[$spa->GetFolderId()][$key]))
357            return $this->addparms[$spa->GetFolderId()][$key];
358        else
359            return null;
360    }
361
362    /**
363     * Returns the latest known PolicyKey to be used as reference
364     *
365     * @access public
366     * @return int/boolen       returns false if nothing found in collections
367     */
368    public function GetReferencePolicyKey() {
369        return $this->refPolicyKey;
370    }
371
372    /**
373     * Sets a global window size which should be used for all collections
374     * in a case of a heartbeat and/or partial sync
375     *
376     * @param int   $windowsize
377     *
378     * @access public
379     * @return boolean
380     */
381    public function SetGlobalWindowSize($windowsize) {
382        $this->globalWindowSize = $windowsize;
383        return true;
384    }
385
386    /**
387     * Returns the global window size of items to be exported in total over all
388     * requested collections.
389     *
390     * @access public
391     * @return int/boolean          returns requested windows size, 512 (max) or the
392     *                              value of config SYNC_MAX_ITEMS if it is lower
393     */
394    public function GetGlobalWindowSize() {
395        // take the requested global windowsize or the max 512 if not defined
396        if (isset($this->globalWindowSize)) {
397            $globalWindowSize = $this->globalWindowSize;
398        }
399        else {
400            $globalWindowSize = WINDOW_SIZE_MAX; // 512 by default
401        }
402
403        if (defined("SYNC_MAX_ITEMS") && SYNC_MAX_ITEMS < $globalWindowSize) {
404            if (!$this->loggedGlobalWindowSizeOverwrite) {
405                ZLog::Write(LOGLEVEL_DEBUG, sprintf("SyncCollections->GetGlobalWindowSize() overwriting requested global window size of %d by %d forced in configuration.", $globalWindowSize, SYNC_MAX_ITEMS));
406                $this->loggedGlobalWindowSizeOverwrite = true;
407            }
408            $globalWindowSize = SYNC_MAX_ITEMS;
409        }
410
411        return $globalWindowSize;
412    }
413
414    /**
415     * Sets the lifetime for heartbeat or ping connections
416     *
417     * @param int   $lifetime       time in seconds
418     *
419     * @access public
420     * @return boolean
421     */
422    public function SetLifetime($lifetime) {
423        $this->refLifetime = $lifetime;
424        return true;
425    }
426
427    /**
428     * Sets the lifetime for heartbeat or ping connections
429     * previousily set or saved in a collection
430     *
431     * @access public
432     * @return int                  returns PING_HIGHER_BOUND_LIFETIME as default if nothing set or not available.
433     *                              If PING_HIGHER_BOUND_LIFETIME is not set, returns 600.
434     */
435    public function GetLifetime() {
436        if (!isset($this->refLifetime) || $this->refLifetime === false) {
437            if (PING_HIGHER_BOUND_LIFETIME !== false) {
438                return PING_HIGHER_BOUND_LIFETIME;
439            }
440            return 600;
441        }
442
443        return $this->refLifetime;
444    }
445
446    /**
447     * Returns the timestamp of the last synchronization for all
448     * loaded collections
449     *
450     * @access public
451     * @return int                  timestamp
452     */
453    public function GetLastSyncTime() {
454        return $this->lastSyncTime;
455    }
456
457    /**
458     * Checks if the currently known collections for changes for $lifetime seconds.
459     * If the backend provides a ChangesSink the sink will be used.
460     * If not every $interval seconds an exporter will be configured for each
461     * folder to perform GetChangeCount().
462     *
463     * @param int       $lifetime       (opt) total lifetime to wait for changes / default 600s
464     * @param int       $interval       (opt) time between blocking operations of sink or polling / default 30s
465     * @param boolean   $onlyPingable   (opt) only check for folders which have the PingableFlag
466     *
467     * @access public
468     * @return boolean              indicating if changes were found
469     * @throws StatusException      with code SyncCollections::ERROR_NO_COLLECTIONS if no collections available
470     *                              with code SyncCollections::ERROR_WRONG_HIERARCHY if there were errors getting changes
471     */
472    public function CheckForChanges($lifetime = 600, $interval = 30, $onlyPingable = false) {
473        $classes = array();
474        foreach ($this->collections as $folderid => $spa){
475            if ($onlyPingable && $spa->GetPingableFlag() !== true || ! $folderid)
476                continue;
477
478            // the class name will be overwritten for KOE-GAB
479            $class = $this->getPingClass($spa);
480
481            if (!isset($classes[$class]))
482                $classes[$class] = 0;
483            $classes[$class] += 1;
484        }
485        if (empty($classes))
486            $checkClasses = "policies only";
487        else if (array_sum($classes) > 4) {
488            $checkClasses = "";
489            foreach($classes as $class=>$count) {
490                if ($count == 1)
491                    $checkClasses .= sprintf("%s ", $class);
492                else
493                    $checkClasses .= sprintf("%s(%d) ", $class, $count);
494            }
495        }
496        else
497            $checkClasses = implode(" ", array_keys($classes));
498
499        $pingTracking = new PingTracking();
500        $this->changes = array();
501
502        ZPush::GetDeviceManager()->AnnounceProcessAsPush();
503        ZPush::GetTopCollector()->AnnounceInformation(sprintf("lifetime %ds", $lifetime), true);
504        ZLog::Write(LOGLEVEL_INFO, sprintf("SyncCollections->CheckForChanges(): Waiting for %s changes... (lifetime %d seconds)", (empty($classes))?'policy':'store', $lifetime));
505
506        // use changes sink where available
507        $changesSink = ZPush::GetBackend()->HasChangesSink();
508
509        // create changessink and check folder stats if there are folders to Ping
510        if (!empty($classes)) {
511            // initialize all possible folders
512            foreach ($this->collections as $folderid => $spa) {
513                if (($onlyPingable && $spa->GetPingableFlag() !== true) || ! $folderid)
514                    continue;
515
516                $backendFolderId = $spa->GetBackendFolderId();
517
518                // get the user store if this is a additional folder
519                $store = ZPush::GetAdditionalSyncFolderStore($backendFolderId);
520
521                // initialize sink if no immediate changes were found so far
522                if ($changesSink && empty($this->changes)) {
523                    ZPush::GetBackend()->Setup($store);
524                    if (! ZPush::GetBackend()->ChangesSinkInitialize($backendFolderId))
525                        throw new StatusException(sprintf("Error initializing ChangesSink for folder id %s/%s", $folderid, $backendFolderId), self::ERROR_WRONG_HIERARCHY);
526                }
527
528                // check if the folder stat changed since the last sync, if so generate a change for it (only on first run)
529                $currentFolderStat = ZPush::GetBackend()->GetFolderStat($store, $backendFolderId);
530                if ($this->waitingTime == 0 && ZPush::GetBackend()->HasFolderStats() && $currentFolderStat !== false && $spa->IsExporterRunRequired($currentFolderStat, true)) {
531                    $this->changes[$spa->GetFolderId()] = 1;
532                }
533            }
534        }
535
536        if (!empty($this->changes)) {
537            ZLog::Write(LOGLEVEL_DEBUG, "SyncCollections->CheckForChanges(): Using ChangesSink but found changes verifying the folder stats");
538            return true;
539        }
540
541        // wait for changes
542        $started = time();
543        $endat = time() + $lifetime;
544
545        // always use policy key from the request if it was sent
546        $policyKey = $this->GetReferencePolicyKey();
547        if (Request::WasPolicyKeySent() && Request::GetPolicyKey() != 0) {
548            ZLog::Write(LOGLEVEL_DEBUG, sprintf("refpolkey:'%s', sent polkey:'%s'", $policyKey, Request::GetPolicyKey()));
549            $policyKey = Request::GetPolicyKey();
550        }
551        while(($now = time()) < $endat) {
552            // how long are we waiting for changes
553            $this->waitingTime = $now-$started;
554
555            $nextInterval = $interval;
556            // we should not block longer than the lifetime
557            if ($endat - $now < $nextInterval)
558                $nextInterval = $endat - $now;
559
560            // Check if provisioning is necessary
561            // if a PolicyKey was sent use it. If not, compare with the ReferencePolicyKey
562            if (PROVISIONING === true && $policyKey !== false && ZPush::GetDeviceManager()->ProvisioningRequired($policyKey, true, false))
563                // the hierarchysync forces provisioning
564                throw new StatusException("SyncCollections->CheckForChanges(): Policies or PolicyKey changed. Provisioning required.", self::ERROR_WRONG_HIERARCHY);
565
566            // Check if a hierarchy sync is necessary
567            if ($this->countHierarchyChange())
568                throw new StatusException("SyncCollections->CheckForChanges(): HierarchySync required.", self::HIERARCHY_CHANGED);
569
570            // Check if there are newer requests
571            // If so, this process should be terminated if more than 60 secs to go
572            if ($pingTracking->DoForcePingTimeout()) {
573                // do not update CPOs because another process has already read them!
574                $this->saveData = false;
575
576                // more than 60 secs to go?
577                if (($now + 60) < $endat) {
578                    ZPush::GetTopCollector()->AnnounceInformation(sprintf("Forced timeout after %ds", ($now-$started)), true);
579                    throw new StatusException(sprintf("SyncCollections->CheckForChanges(): Timeout forced after %ss from %ss due to other process", ($now-$started), $lifetime), self::OBSOLETE_CONNECTION);
580                }
581            }
582
583            // Use changes sink if available
584            if ($changesSink) {
585                ZPush::GetTopCollector()->AnnounceInformation(sprintf("Sink %d/%ds on %s", ($now-$started), $lifetime, $checkClasses));
586                $notifications = ZPush::GetBackend()->ChangesSink($nextInterval);
587
588                // how long are we waiting for changes
589                $this->waitingTime = time()-$started;
590
591                $validNotifications = false;
592                foreach ($notifications as $backendFolderId) {
593                    // Check hierarchy notifications
594                    if ($backendFolderId === IBackend::HIERARCHYNOTIFICATION) {
595                        // wait two seconds before validating this notification, because it could potentially be made by the mobile and we need some time to update the states.
596                        sleep(2);
597                        // check received hierarchy notifications by exporting
598                        if ($this->countHierarchyChange(true)) {
599                            throw new StatusException("SyncCollections->CheckForChanges(): HierarchySync required.", self::HIERARCHY_CHANGED);
600                        }
601                    }
602                    else {
603                        // the backend will notify on the backend folderid
604                        $folderid = ZPush::GetDeviceManager()->GetFolderIdForBackendId($backendFolderId);
605
606                        // check if the notification on the folder is within our filter
607                        if ($this->CountChange($folderid)) {
608                            ZLog::Write(LOGLEVEL_DEBUG, sprintf("SyncCollections->CheckForChanges(): Notification received on folder '%s'", $folderid));
609                            $validNotifications = true;
610                            $this->waitingTime = time()-$started;
611                        }
612                        else {
613                            ZLog::Write(LOGLEVEL_DEBUG, sprintf("SyncCollections->CheckForChanges(): Notification received on folder '%s', but it is not relevant", $folderid));
614                        }
615                    }
616                }
617                if ($validNotifications)
618                    return true;
619            }
620            // use polling mechanism
621            else {
622                ZPush::GetTopCollector()->AnnounceInformation(sprintf("Polling %d/%ds on %s", ($now-$started), $lifetime, $checkClasses));
623                if ($this->CountChanges($onlyPingable)) {
624                    ZLog::Write(LOGLEVEL_DEBUG, sprintf("SyncCollections->CheckForChanges(): Found changes polling"));
625                    return true;
626                }
627                else {
628                    sleep($nextInterval);
629                }
630            } // end polling
631        } // end wait for changes
632        ZLog::Write(LOGLEVEL_DEBUG, sprintf("SyncCollections->CheckForChanges(): no changes found after %ds", time() - $started));
633
634        return false;
635    }
636
637    /**
638     * Checks if the currently known collections for
639     * changes performing Exporter->GetChangeCount()
640     *
641     * @param boolean   $onlyPingable   (opt) only check for folders which have the PingableFlag
642     *
643     * @access public
644     * @return boolean      indicating if changes were found or not
645     */
646    public function CountChanges($onlyPingable = false) {
647        $changesAvailable = false;
648        foreach ($this->collections as $folderid => $spa) {
649            if ($onlyPingable && $spa->GetPingableFlag() !== true)
650                continue;
651
652            if (isset($this->addparms[$spa->GetFolderId()]["status"]) && $this->addparms[$spa->GetFolderId()]["status"] != SYNC_STATUS_SUCCESS)
653                continue;
654
655            if ($this->CountChange($folderid))
656                $changesAvailable = true;
657        }
658
659        return $changesAvailable;
660    }
661
662    /**
663     * Checks a folder for changes performing Exporter->GetChangeCount()
664     *
665     * @param string    $folderid   counts changes for a folder
666     *
667     * @access private
668     * @return boolean      indicating if changes were found or not
669     */
670     private function CountChange($folderid) {
671        $spa = $this->GetCollection($folderid);
672
673        if (!$spa) {
674            ZLog::Write(LOGLEVEL_DEBUG, sprintf("SyncCollections->CountChange(): Could not get SyncParameters object from cache for folderid '%s' to verify notification. Ignoring.", $folderid));
675            return false;
676        }
677
678        // Prevent ZP-623 by checking if the states have been used before, if so force a sync on this folder.
679        // ZCP/KC 7.2.3 and newer support SYNC_STATE_READONLY so this behaviour is not required (see ZP-968).
680        if (!Utils::CheckMapiExtVersion('7.2.3')) {
681            if (ZPush::GetDeviceManager()->CheckHearbeatStateIntegrity($spa->GetFolderId(), $spa->GetUuid(), $spa->GetUuidCounter())) {
682                ZLog::Write(LOGLEVEL_DEBUG, "SyncCollections->CountChange(): Cannot verify changes for state as it was already used. Forcing sync of folder.");
683                $this->changes[$folderid] = 1;
684                return true;
685            }
686        }
687
688        $backendFolderId = ZPush::GetDeviceManager()->GetBackendIdForFolderId($folderid);
689        // switch user store if this is a additional folder (additional true -> do not debug)
690        ZPush::GetBackend()->Setup(ZPush::GetAdditionalSyncFolderStore($backendFolderId, true));
691        $changecount = false;
692
693        try {
694            $exporter = ZPush::GetBackend()->GetExporter($backendFolderId);
695            if ($exporter !== false && isset($this->addparms[$folderid]["state"])) {
696                $importer = false;
697
698                $exporter->SetMoveStates($spa->GetMoveState());
699                $exporter->Config($this->addparms[$folderid]["state"], BACKEND_DISCARD_DATA);
700                $exporter->ConfigContentParameters($spa->GetCPO());
701                $ret = $exporter->InitializeExporter($importer);
702
703                if ($ret !== false)
704                    $changecount = $exporter->GetChangeCount();
705            }
706        }
707        catch (StatusException $ste) {
708            if ($ste->getCode() == SYNC_STATUS_FOLDERHIERARCHYCHANGED) {
709                ZLog::Write(LOGLEVEL_WARN, "SyncCollections->CountChange(): exporter can not be re-configured due to state error, emulating change in folder to force Sync.");
710                $this->changes[$folderid] = 1;
711                // make sure this folder is fully synched on next Sync request
712                $this->invalidateFolderStat($spa);
713
714                return true;
715            }
716            throw new StatusException("SyncCollections->CountChange(): exporter can not be re-configured.", self::ERROR_WRONG_HIERARCHY, null, LOGLEVEL_WARN);
717        }
718
719        // start over if exporter can not be configured atm
720        if ($changecount === false)
721            ZLog::Write(LOGLEVEL_WARN, "SyncCollections->CountChange(): no changes received from Exporter.");
722
723        $this->changes[$folderid] = $changecount;
724
725        return ($changecount > 0);
726     }
727
728     /**
729      * Checks the hierarchy for changes.
730      *
731      * @param boolean       export changes, default: false
732      *
733      * @access private
734      * @return boolean      indicating if changes were found or not
735      */
736     private function countHierarchyChange($exportChanges = false) {
737         $folderid = false;
738
739         // Check with device manager if the hierarchy should be reloaded.
740         // New additional folders are loaded here.
741         if (ZPush::GetDeviceManager()->IsHierarchySyncRequired()) {
742             ZLog::Write(LOGLEVEL_DEBUG, "SyncCollections->countHierarchyChange(): DeviceManager says HierarchySync is required.");
743             return true;
744         }
745
746         $changecount = false;
747         if ($exportChanges || $this->hierarchyExporterChecked === false) {
748             try {
749                 // if this is a validation (not first run), make sure to load the hierarchy data again
750                 if ($this->hierarchyExporterChecked === true && !$this->LoadCollection(false, true, false))
751                     throw new StatusException("Invalid states found while re-loading hierarchy data.");
752
753
754                 $changesMem = ZPush::GetDeviceManager()->GetHierarchyChangesWrapper();
755                 // the hierarchyCache should now fully be initialized - check for changes in the additional folders
756                 $changesMem->Config(ZPush::GetAdditionalSyncFolders(false));
757
758                 // reset backend to the main store
759                 ZPush::GetBackend()->Setup(false);
760                 $exporter = ZPush::GetBackend()->GetExporter();
761                 if ($exporter !== false && isset($this->addparms[$folderid]["state"])) {
762                     $exporter->Config($this->addparms[$folderid]["state"]);
763                     $ret = $exporter->InitializeExporter($changesMem);
764                     while(is_array($exporter->Synchronize()));
765
766                     if ($ret !== false)
767                         $changecount = $changesMem->GetChangeCount();
768
769                     $this->hierarchyExporterChecked = true;
770                 }
771             }
772             catch (StatusException $ste) {
773                 throw new StatusException("SyncCollections->countHierarchyChange(): exporter can not be re-configured.", self::ERROR_WRONG_HIERARCHY, null, LOGLEVEL_WARN);
774             }
775
776             // start over if exporter can not be configured atm
777             if ($changecount === false )
778                 ZLog::Write(LOGLEVEL_WARN, "SyncCollections->countHierarchyChange(): no changes received from Exporter.");
779         }
780         return ($changecount > 0);
781     }
782
783    /**
784     * Returns an array with all folderid and the amount of changes found
785     *
786     * @access public
787     * @return array
788     */
789    public function GetChangedFolderIds() {
790        return $this->changes;
791    }
792
793    /**
794     * Indicates if there are folders which are pingable
795     *
796     * @access public
797     * @return boolean
798     */
799    public function PingableFolders() {
800        foreach ($this->collections as $folderid => $spa) {
801            if ($spa->GetPingableFlag() == true) {
802                return true;
803            }
804        }
805
806        return false;
807    }
808
809    /**
810     * Indicates if the process did wait in a sink, polling or before running a
811     * regular export to find changes
812     *
813     * @access public
814     * @return boolean
815     */
816    public function WaitedForChanges() {
817        ZLog::Write(LOGLEVEL_DEBUG, sprintf("SyncCollections->WaitedForChanges: waited for %d seconds", $this->waitingTime));
818        return ($this->waitingTime > 0);
819    }
820
821    /**
822     * Indicates how many seconds the process did wait in a sink, polling or before running a
823     * regular export to find changes.
824     *
825     * @access public
826     * @return int
827     */
828    public function GetWaitedSeconds() {
829        return $this->waitingTime;
830    }
831
832    /**
833     * Returns how the current folder should be called in the PING comment.
834     *
835     * @param SyncParameters $spa
836     *
837     * @access public
838     * @return string
839     */
840    private function getPingClass($spa) {
841        $class = $spa->GetContentClass();
842        if ($class == "Calendar" && strpos($spa->GetFolderId(), DeviceManager::FLD_ORIGIN_GAB) === 0) {
843            $class = "GAB";
844        }
845        return $class;
846    }
847
848    /**
849     * Simple Iterator Interface implementation to traverse through collections
850     */
851
852    /**
853     * Rewind the Iterator to the first element
854     *
855     * @access public
856     * @return
857     */
858    public function rewind() {
859        return reset($this->collections);
860    }
861
862    /**
863     * Returns the current element
864     *
865     * @access public
866     * @return mixed
867     */
868    public function current() {
869        return current($this->collections);
870    }
871
872    /**
873     * Return the key of the current element
874     *
875     * @access public
876     * @return scalar on success, or NULL on failure.
877     */
878    public function key() {
879        return key($this->collections);
880    }
881
882    /**
883     * Move forward to next element
884     *
885     * @access public
886     * @return
887     */
888    public function next() {
889        return next($this->collections);
890    }
891
892    /**
893     * Checks if current position is valid
894     *
895     * @access public
896     * @return boolean
897     */
898    public function valid() {
899        return (key($this->collections) != null && key($this->collections) != false);
900    }
901
902    /**
903     * Gets the StateManager from the DeviceManager
904     * if it's not available
905     *
906     * @access private
907     * @return
908     */
909     private function loadStateManager() {
910         if (!isset($this->stateManager))
911            $this->stateManager = ZPush::GetDeviceManager()->GetStateManager();
912     }
913
914     /**
915      * Remove folder statistics from a SyncParameter object.
916      *
917      * @param SyncParameters $spa
918      *
919      * @access public
920      * @return
921      */
922     private function invalidateFolderStat($spa) {
923         if($spa->HasFolderStat()) {
924             ZLog::Write(LOGLEVEL_DEBUG, sprintf("SyncCollections->invalidateFolderStat(): removing folder stat '%s' for folderid '%s'", $spa->GetFolderStat(), $spa->GetFolderId()));
925             $spa->DelFolderStat();
926             $this->SaveCollection($spa);
927             return true;
928         }
929         return false;
930     }
931}
932