import angular from 'angular' ; import { Injectable } from 'angular-ts-decorators' ; import autobind from 'autobind-decorator' ; import { ApiService } from '../api/api.interface' ; import { Bookmark } from '../bookmark/bookmark.interface' ; import { BookmarkHelperService } from '../bookmark/bookmark-helper/bookmark-helper.service' ; import { CryptoService } from '../crypto/crypto.service' ; import { BaseError , BookmarkMappingNotFoundError , BookmarkNotFoundError , ClientDataNotFoundError , ContainerChangedError , DataOutOfSyncError , FailedCreateNativeBookmarksError , FailedGetNativeBookmarksError , FailedRemoveNativeBookmarksError , NativeBookmarkNotFoundError , SyncDisabledError , SyncFailedError , SyncNotFoundError , SyncUncommittedError , SyncVersionNotSupportedError , TooManyRequestsError } from '../errors/errors' ; import { ExceptionHandler } from '../errors/errors.interface' ; import { PlatformService } from '../global-shared.interface' ; import { LogService } from '../log/log.service' ; import { NetworkService } from '../network/network.service' ; import { StoreKey } from '../store/store.enum' ; import { StoreService } from '../store/store.service' ; import { UtilityService } from '../utility/utility.service' ; import { BookmarkSyncProviderService } from './bookmark-sync-provider/bookmark-sync-provider.service' ; import { SyncType } from './sync.enum' ; import { RemovedSync , Sync , SyncProvider } from './sync.interface' ; @ autobind @ Injectable ( 'SyncService' ) export class SyncService { $exceptionHandler : ExceptionHandler ; $q : ng . IQService ; $timeout : ng . ITimeoutService ; apiSvc : ApiService ; bookmarkHelperSvc : BookmarkHelperService ; cryptoSvc : CryptoService ; logSvc : LogService ; networkSvc : NetworkService ; platformSvc : PlatformService ; storeSvc : StoreService ; utilitySvc : UtilityService ; currentSync : Sync ; providers : SyncProvider [ ] ; syncQueue : Sync [ ] = [ ] ; static $inject = [ '$exceptionHandler' , '$q' , '$timeout' , 'ApiService' , 'BookmarkHelperService' , 'BookmarkSyncProviderService' , 'CryptoService' , 'LogService' , 'NetworkService' , 'PlatformService' , 'StoreService' , 'UtilityService' ] ; constructor ( $exceptionHandler , $q , $timeout , ApiSvc , BookmarkHelperSvc , BookmarkSyncProviderSvc , CryptoSvc , LogSvc , NetworkSvc , PlatformSvc , StoreSvc , UtilitySvc ) { this . $exceptionHandler = $exceptionHandler ; this . $q = $q ; this . $timeout = $timeout ; this . apiSvc = ApiSvc ; this . bookmarkHelperSvc = BookmarkHelperSvc ; this . cryptoSvc = CryptoSvc ; this . logSvc = LogSvc ; this . networkSvc = NetworkSvc ; this . platformSvc = PlatformSvc ; this . storeSvc = StoreSvc ; this . utilitySvc = UtilitySvc ; this . providers = [ BookmarkSyncProviderSvc ] ; } cancelSync ( ) { return this . disableSync ( ) ; } checkIfDisableSyncOnError ( err ) { return ( err && ( err instanceof ClientDataNotFoundError || err instanceof SyncNotFoundError || err instanceof SyncVersionNotSupportedError || err instanceof TooManyRequestsError ) ) ; } checkIfRefreshSyncedDataOnError ( err ) { return ( err && ( err instanceof BookmarkMappingNotFoundError || err instanceof ContainerChangedError || err instanceof DataOutOfSyncError || err instanceof FailedCreateNativeBookmarksError || err instanceof FailedGetNativeBookmarksError || err instanceof FailedRemoveNativeBookmarksError || err instanceof NativeBookmarkNotFoundError || err instanceof BookmarkNotFoundError ) ) ; } checkForUpdates ( isBackgroundSync = false , outputToLog = true ) { return this . storeSvc . get < string > ( StoreKey . LastUpdated ) . then ( ( storedLastUpdated ) => { const storedLastUpdatedDate = new Date ( storedLastUpdated ) ; return this . apiSvc . getBookmarksLastUpdated ( isBackgroundSync ) . then ( ( response ) => { const remoteLastUpdated = new Date ( response . lastUpdated ) ; const updatesAvailable = storedLastUpdatedDate ?. getTime ( ) !== remoteLastUpdated . getTime ( ) ; if ( updatesAvailable && outputToLog ) { this . logSvc . logInfo ( `Updates available, local:${ storedLastUpdatedDate ?. toISOString ( ) ?? 'none' } remote:${ remoteLastUpdated . toISOString ( ) }` ) ; } return updatesAvailable ; } ) ; } ) ; } checkSyncVersionIsSupported ( ) { return this . storeSvc . get < string > ( StoreKey . SyncId ) . then ( ( syncId ) => { return this . $q . all ( [ this . apiSvc . getBookmarksVersion ( syncId ) , this . platformSvc . getAppVersion ( ) ] ) . then ( ( results ) => { const [ response , appVersion ] = results ; const { version : bookmarksVersion } = response ; if ( this . utilitySvc . compareVersions ( bookmarksVersion ?? '0' , appVersion , '>' ) ) { throw new SyncVersionNotSupportedError ( ) ; } } ) ; } ) ; } disableSync ( ) { return this . utilitySvc . isSyncEnabled ( ) . then ( ( syncEnabled ) => { if ( ! syncEnabled ) { return ; } return this . $q . all ( [ this . platformSvc . stopSyncUpdateChecks ( ) , this . storeSvc . remove ( StoreKey . Password ) , this . storeSvc . remove ( StoreKey . SyncVersion ) , this . storeSvc . set ( StoreKey . SyncEnabled , false ) ] ) . then ( ( ) => { return this . $q . all ( this . providers . map ( ( provider ) => provider . disable ( ) ) ) ; } ) . then ( ( ) => { this . syncQueue = [ ] ; this . showInterfaceAsSyncing ( ) ; this . platformSvc . refreshNativeInterface ( ) ; this . logSvc . logInfo ( 'Sync disabled' ) ; } ) ; } ) ; } enableSync ( ) { return this . $q . all ( [ this . storeSvc . remove ( StoreKey . RemovedSync ) , this . storeSvc . set ( StoreKey . SyncEnabled , true ) , this . platformSvc . startSyncUpdateChecks ( ) ] ) . then ( ( ) => { return this . $q . all ( this . providers . map ( ( provider ) => provider . enable ( ) ) ) ; } ) . then ( ( ) => this . platformSvc . refreshNativeInterface ( true ) ) ; } executeSync ( isBackgroundSync = false ) { return this . utilitySvc . isSyncEnabled ( ) . then ( ( syncEnabled ) => { if ( ! syncEnabled ) { throw new SyncDisabledError ( ) ; } return ( this . syncQueue . length === 0 ? this . checkForUpdates ( isBackgroundSync ) . catch ( ( ) => true ) : this . $q . resolve ( false ) ) . then ( ( updatesAvailable ) => { return ( updatesAvailable && this . queueSync ( { type : SyncType . Local } ) ) ; } ) . then ( ( ) => this . processSyncQueue ( isBackgroundSync ) ) ; } ) ; } getCurrentSync ( ) { return this . currentSync ; } getSyncQueueLength ( ) { return this . syncQueue . length ; } getSyncSize ( ) { return this . bookmarkHelperSvc . getCachedBookmarks ( ) . then ( ( ) => this . storeSvc . get < string > ( StoreKey . Bookmarks ) ) . then ( ( encryptedBookmarks ) => { const sizeInBytes = new TextEncoder ( ) . encode ( encryptedBookmarks ) . byteLength ; return sizeInBytes ; } ) ; } handleFailedSync ( failedSync , err , isBackgroundSync = false ) { let syncError = err ; return this . $q . resolve ( ) . then ( ( ) => { if ( this . networkSvc . isNetworkConnectionError ( err ) && failedSync . type !== SyncType . Local ) { this . syncQueue . unshift ( failedSync ) ; if ( ! isBackgroundSync ) { this . logSvc . logWarning ( 'No connection, changes re-queued for syncing' ) ; } syncError = new SyncUncommittedError ( undefined , err ) ; return ; } if ( ! ( err instanceof BaseError ) ) { syncError = new SyncFailedError ( undefined , err ) ; } this . logSvc . logWarning ( `Sync ${ failedSync . uniqueId } failed` ) ; this . $exceptionHandler ( syncError , null , false ) ; if ( failedSync . changeInfo && failedSync . changeInfo . type ) { this . logSvc . logInfo ( failedSync . changeInfo ) ; } return this . utilitySvc . isSyncEnabled ( ) . then ( ( syncEnabled ) => { return this . showInterfaceAsSyncing ( ) . then ( ( ) => { if ( ! syncEnabled ) { return ; } if ( err instanceof SyncNotFoundError ) { return this . setSyncRemoved ( ) ; } return this . $q . resolve ( ) . then ( ( ) => { if ( failedSync . type !== SyncType . Local ) { this . syncQueue = [ ] ; if ( this . checkIfRefreshSyncedDataOnError ( syncError ) ) { this . currentSync = undefined ; return this . platformSvc . queueLocalResync ( ) . catch ( ( refreshErr ) => { syncError = refreshErr ; } ) ; } } } ) . then ( ( ) => { if ( this . checkIfDisableSyncOnError ( syncError ) ) { return this . disableSync ( ) ; } } ) ; } ) ; } ) ; } ) . then ( ( ) => { throw syncError ; } ) . finally ( ( ) => { failedSync . deferred . reject ( syncError ) ; return this . showInterfaceAsSyncing ( ) ; } ) ; } processSyncQueue ( isBackgroundSync = false ) { let cancel = false ; let processedBookmarksData ; let updateRemote = false ; let updateSyncVersion = false ; if ( this . currentSync || this . syncQueue . length === 0 ) { return this . $q . resolve ( ) ; } const condition = ( ) : ng . IPromise < boolean > => { return this . $q . resolve ( this . syncQueue . length > 0 ) ; } ; const action = ( ) : ng . IPromise < void > => { this . currentSync = this . syncQueue . shift ( ) ; this . logSvc . logInfo ( `Processing sync ${ this . currentSync . uniqueId }${ isBackgroundSync ? ' in background' : '' } (${ this . syncQueue . length } waiting in queue)` ) ; return this . showInterfaceAsSyncing ( this . currentSync . type ) . then ( ( ) => { if ( this . currentSync . type === SyncType . Cancel ) { return this . cancelSync ( ) . then ( ( ) => { cancel = true ; return false ; } ) ; } if ( this . currentSync . type === SyncType . Upgrade ) { updateSyncVersion = true ; } if ( angular . isUndefined ( this . currentSync . bookmarks ) && ! angular . isUndefined ( processedBookmarksData ) ) { this . currentSync . bookmarks = processedBookmarksData ; } return this . $q . all ( this . providers . map ( ( provider ) => provider . processSync ( this . currentSync ) ) ) . then ( ( processSyncResults ) => { processSyncResults . forEach ( ( result , index ) => { switch ( this . providers [ index ] . constructor ) { case BookmarkSyncProviderService : processedBookmarksData = result . data ; break ; default : this . logSvc . logWarning ( 'Sync provider not specified' ) ; } } ) ; return processSyncResults . reduce ( ( prev , current ) => { return current . updateRemote ? prev : prev && current . updateRemote ; } , true ) ; } ) ; } ) . then ( ( syncChange ) => { this . currentSync . deferred . resolve ( ) ; updateRemote = ! ! syncChange ; return this . showInterfaceAsSyncing ( ) ; } ) ; } ; return ( this . utilitySvc . isSyncEnabled ( ) . then ( ( syncEnabled ) => { if ( syncEnabled ) { return this . platformSvc . stopSyncUpdateChecks ( ) ; } } ) . then ( ( ) => this . utilitySvc . asyncWhile < any > ( this . syncQueue , condition , action ) ) . then ( ( ) => { if ( cancel ) { return ; } return this . cryptoSvc . encryptData ( JSON . stringify ( processedBookmarksData ) ) . then ( ( encryptedBookmarks ) => { return ( ! updateRemote ? this . $q . resolve ( ) . then ( ( ) => this . logSvc . logInfo ( 'No changes made, skipping remote update.' ) ) : this . checkSyncVersionIsSupported ( ) . then ( ( ) => this . apiSvc . updateBookmarks ( encryptedBookmarks , updateSyncVersion , isBackgroundSync ) ) . then ( ( response ) => { const updateCache = [ this . storeSvc . set ( StoreKey . LastUpdated , response . lastUpdated ) ] ; if ( updateSyncVersion ) { updateCache . push ( this . platformSvc . getAppVersion ( ) . then ( ( appVersion ) => this . storeSvc . set ( StoreKey . SyncVersion , appVersion ) ) ) ; } return this . $q . all ( updateCache ) . then ( ( ) => { this . logSvc . logInfo ( `Remote bookmarks updated at ${ response . lastUpdated }` ) ; } ) ; } ) . catch ( ( err ) => { return this . $q . all ( this . providers . map ( ( provider ) => { let lastResult ; switch ( provider . constructor ) { case BookmarkSyncProviderService : lastResult = processedBookmarksData ; break ; default : } return provider . handleUpdateRemoteFailed ( err , lastResult , this . currentSync ) ; } ) ) . then ( ( ) => { throw err ; } ) ; } ) ) . then ( ( ) => this . bookmarkHelperSvc . updateCachedBookmarks ( processedBookmarksData , encryptedBookmarks ) ) ; } ) ; } ) . catch ( ( err ) => this . handleFailedSync ( this . currentSync , err , isBackgroundSync ) ) . finally ( ( ) => { this . currentSync = undefined ; return this . utilitySvc . isSyncEnabled ( ) . then ( ( cachedSyncEnabled ) => { if ( cachedSyncEnabled ) { return this . platformSvc . startSyncUpdateChecks ( ) ; } } ) ; } ) ) ; } queueSync ( syncToQueue , runSync = true ) { return this . $q < void > ( ( resolve , reject ) => { this . utilitySvc . isSyncEnabled ( ) . then ( ( syncEnabled ) => { if ( ! syncEnabled ) { this . syncQueue = [ ] ; } let queuedSync ; if ( syncToQueue ) { if ( syncToQueue . type === SyncType . Cancel ) { this . syncQueue = [ ] ; } queuedSync = this . $q . defer < void > ( ) ; syncToQueue . deferred = queuedSync ; syncToQueue . uniqueId = syncToQueue . uniqueId ?? this . utilitySvc . getUniqueishId ( ) ; this . syncQueue . push ( syncToQueue ) ; this . logSvc . logInfo ( `Sync ${ syncToQueue . uniqueId } (${ syncToQueue . type }) queued` ) ; } const promises = [ queuedSync . promise ] ; if ( runSync ) { promises . push ( this . $q < void > ( ( syncedResolve , syncedReject ) => this . $timeout ( ( ) => this . processSyncQueue ( ) . then ( syncedResolve ) . catch ( syncedReject ) ) ) ) ; } return this . $q . all ( promises ) . then ( ( ) => { if ( ! syncEnabled && ( ( syncToQueue . type === SyncType . Local && angular . isUndefined ( syncToQueue . bookmarks ?? undefined ) ) || syncToQueue . type === SyncType . Remote || syncToQueue . type === SyncType . Upgrade ) ) { return this . enableSync ( ) . then ( ( ) => { this . logSvc . logInfo ( 'Sync enabled' ) ; } ) ; } } ) . then ( resolve ) ; } ) . catch ( reject ) ; } ) ; } setSyncRemoved ( ) { return this . $q . all ( [ this . bookmarkHelperSvc . getCachedBookmarks ( ) , this . storeSvc . get ( [ StoreKey . LastUpdated , StoreKey . SyncId , StoreKey . SyncVersion ] ) , this . utilitySvc . getServiceUrl ( ) ] ) . then ( ( data ) => { const [ bookmarks , storeContent , serviceUrl ] = data ; const removedSync = { bookmarks , lastUpdated : storeContent . lastUpdated , serviceUrl , syncId : storeContent . syncId , syncVersion : storeContent . syncVersion } ; return this . storeSvc . set ( StoreKey . RemovedSync , removedSync ) . then ( ( ) => this . logSvc . logWarning ( `Sync ID ${ storeContent . syncId } removed from service ${ serviceUrl }; last updated ${ storeContent . lastUpdated }` ) ) ; } ) . then ( ( ) => this . disableSync ( ) ) . then ( ( ) => this . storeSvc . remove ( StoreKey . SyncId ) ) ; } shouldDisplayDefaultPageOnError ( err ) { return this . checkIfDisableSyncOnError ( err ) || err instanceof SyncUncommittedError ; } showInterfaceAsSyncing ( syncType ? ) { if ( ! angular . isUndefined ( syncType ?? undefined ) ) { return this . platformSvc . refreshNativeInterface ( undefined , syncType ) ; } return this . utilitySvc . isSyncEnabled ( ) . then ( this . platformSvc . refreshNativeInterface ) ; } }