diff --git a/client/src/app/core/core-services/storage.service.ts b/client/src/app/core/core-services/storage.service.ts index 431246b84..39611324f 100644 --- a/client/src/app/core/core-services/storage.service.ts +++ b/client/src/app/core/core-services/storage.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@angular/core'; import { LocalStorage } from '@ngx-pwa/local-storage'; import { OpenSlidesStatusService } from './openslides-status.service'; +import { StoragelockService } from '../local-storage/storagelock.service'; /** * Provides an async API to an key-value store using ngx-pwa which is internally @@ -16,7 +17,11 @@ export class StorageService { * Constructor to create the StorageService. Needs the localStorage service. * @param localStorage */ - public constructor(private localStorage: LocalStorage, private OSStatus: OpenSlidesStatusService) {} + public constructor( + private localStorage: LocalStorage, + private OSStatus: OpenSlidesStatusService, + private lock: StoragelockService + ) {} /** * Sets the item into the store asynchronously. @@ -24,6 +29,8 @@ export class StorageService { * @param item */ public async set(key: string, item: any): Promise { + await this.lock.promise; + this.assertNotHistroyMode(); if (item === null || item === undefined) { await this.remove(key); // You cannot do a setItem with null or undefined... @@ -44,6 +51,8 @@ export class StorageService { * @returns The requested value to the key */ public async get(key: string): Promise { + await this.lock.promise; + return await this.localStorage.getUnsafeItem(key).toPromise(); } @@ -52,6 +61,8 @@ export class StorageService { * @param key The key to remove the value from */ public async remove(key: string): Promise { + await this.lock.promise; + this.assertNotHistroyMode(); if (!(await this.localStorage.removeItem(key).toPromise())) { throw new Error('Could not delete the item.'); @@ -62,6 +73,8 @@ export class StorageService { * Clear the whole cache */ public async clear(): Promise { + await this.lock.promise; + this.assertNotHistroyMode(); if (!(await this.localStorage.clear().toPromise())) { throw new Error('Could not clear the storage.'); diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts index f3fbf837a..f7ae34fba 100644 --- a/client/src/app/core/core.module.ts +++ b/client/src/app/core/core.module.ts @@ -1,13 +1,18 @@ -import { NgModule, Optional, SkipSelf, Type } from '@angular/core'; +import { NgModule, Optional, SkipSelf, Type, PLATFORM_ID } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Title } from '@angular/platform-browser'; +import { LocalDatabase, LOCAL_STORAGE_PREFIX } from '@ngx-pwa/local-storage'; + // Shared Components import { PromptDialogComponent } from '../shared/components/prompt-dialog/prompt-dialog.component'; import { ChoiceDialogComponent } from '../shared/components/choice-dialog/choice-dialog.component'; import { ProjectionDialogComponent } from 'app/shared/components/projection-dialog/projection-dialog.component'; + import { OperatorService } from './core-services/operator.service'; import { OnAfterAppsLoaded } from './onAfterAppsLoaded'; +import { StoragelockService } from './local-storage/storagelock.service'; +import { customLocalDatabaseFactory } from './local-storage/custom-indexeddb-database'; export const ServicesToLoadOnAppsLoaded: Type[] = [OperatorService]; @@ -16,7 +21,15 @@ export const ServicesToLoadOnAppsLoaded: Type[] = [OperatorSe */ @NgModule({ imports: [CommonModule], - providers: [Title], + providers: [ + Title, + // Use our own localStorage wrapper. + { + provide: LocalDatabase, + useFactory: customLocalDatabaseFactory, + deps: [PLATFORM_ID, StoragelockService, [new Optional(), LOCAL_STORAGE_PREFIX]] + } + ], entryComponents: [PromptDialogComponent, ChoiceDialogComponent, ProjectionDialogComponent] }) export class CoreModule { diff --git a/client/src/app/core/local-storage/custom-indexeddb-database.ts b/client/src/app/core/local-storage/custom-indexeddb-database.ts new file mode 100644 index 000000000..1bc4cca19 --- /dev/null +++ b/client/src/app/core/local-storage/custom-indexeddb-database.ts @@ -0,0 +1,108 @@ +import { Optional, Inject, Injectable } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +import { + IndexedDBDatabase, + LOCAL_STORAGE_PREFIX, + LocalStorageDatabase, + MockLocalDatabase, + LocalDatabase +} from '@ngx-pwa/local-storage'; +import { fromEvent, race, Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; + +import { StoragelockService } from './storagelock.service'; + +@Injectable({ + providedIn: 'root' +}) +export class CustomIndexedDBDatabase extends IndexedDBDatabase { + public constructor( + private storageLock: StoragelockService, + @Optional() @Inject(LOCAL_STORAGE_PREFIX) protected prefix: string | null = null + ) { + super(prefix); + } + + /** + * Connects to IndexedDB and creates the object store on first time + */ + protected connect(prefix: string | null = null): void { + let request: IDBOpenDBRequest; + + // Connecting to IndexedDB + try { + request = indexedDB.open(this.dbName); + } catch (error) { + // Fallback storage if IndexedDb connection is failing + this.setFallback(prefix); + return; + } + + // Listening the event fired on first connection, creating the object store for local storage + (fromEvent(request, 'upgradeneeded') as Observable).pipe(first()).subscribe(event => { + // Getting the database connection + const database = (event.target as IDBRequest).result as IDBDatabase; + + // Checking if the object store already exists, to avoid error + if (!database.objectStoreNames.contains(this.objectStoreName)) { + // Creating the object store for local storage + database.createObjectStore(this.objectStoreName); + } + }); + + // Listening the success event and converting to an RxJS Observable + const success = fromEvent(request, 'success') as Observable; + + // Merging success and errors events + (race(success, this.toErrorObservable(request, `connection`)) as Observable).pipe(first()).subscribe( + event => { + const db = (event.target as IDBRequest).result as IDBDatabase; + + // CUSTOM: If the indexedDB initialization fails, because 'upgradeneeded' didn't fired + // the fallback will be used. + if (!db.objectStoreNames.contains(this.objectStoreName)) { + this.setFallback(prefix); + } else { + // Storing the database connection for further access + this.database.next(db); + this.storageLock.OK(); + } + }, + () => { + // Fallback storage if IndexedDb connection is failing + this.setFallback(prefix); + } + ); + } + + // CUSTOM: If the fallback is used, unlock the storage service + public setFallback(prefix: string): void { + console.log('uses localStorage as IndexedDB fallback!'); + super.setFallback(prefix); + this.storageLock.OK(); + } +} + +export function customLocalDatabaseFactory( + platformId: Object, + storagelock: StoragelockService, + prefix: string | null +): LocalDatabase { + if (isPlatformBrowser(platformId) && 'indexedDB' in window && indexedDB !== undefined && indexedDB !== null) { + // Try with IndexedDB in modern browsers + // CUSTOM: Use our own IndexedDB implementation + return new CustomIndexedDBDatabase(storagelock, prefix); + } else if ( + isPlatformBrowser(platformId) && + 'localStorage' in window && + localStorage !== undefined && + localStorage !== null + ) { + // Try with localStorage in old browsers (IE9) + return new LocalStorageDatabase(prefix); + } else { + // Fake database for server-side rendering (Universal) + return new MockLocalDatabase(); + } +} diff --git a/client/src/app/core/local-storage/storagelock.service.ts b/client/src/app/core/local-storage/storagelock.service.ts new file mode 100644 index 000000000..810ed9302 --- /dev/null +++ b/client/src/app/core/local-storage/storagelock.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; + +/** + * Provides a storage lock for waiting to database to be initialized. + */ +@Injectable({ + providedIn: 'root' +}) +export class StoragelockService { + private lock: Promise; + private resolve: () => void; + + public get promise(): Promise { + return this.lock; + } + + public constructor() { + this.lock = new Promise(resolve => (this.resolve = resolve)); + } + + public OK(): void { + this.resolve(); + } +}