diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 3a5134a..bbc67a1 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -12,9 +12,10 @@ import { AboutPage } from './pages/about/about.page'; import { MainPage } from './pages/main/main.page'; import { SettingsPage } from './pages/settings/settings.page'; import { FilesizePipe } from './pipes/filesize.pipe'; +import { RecordingsSortPipe } from './pipes/recordings-sort.pipe'; import { ToHmsPipe } from './pipes/to-hms.pipe'; +import { RecordingsService } from './services/recordings.service'; import { SettingsService } from './services/settings.service'; -import { RecordingsSortPipe } from './pipes/recordings-sort.pipe'; @NgModule({ declarations: [ @@ -37,7 +38,7 @@ import { RecordingsSortPipe } from './pipes/recordings-sort.pipe'; RouterModule, ], providers: [ - { provide: APP_INITIALIZER, useFactory: appInitializer, deps: [ SettingsService ], multi: true }, + { provide: APP_INITIALIZER, useFactory: appInitializer, deps: [ RecordingsService, SettingsService ], multi: true }, { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, { provide: LOCALE_ID, useFactory: (settings: SettingsService) => settings.deviceCulture, deps: [ SettingsService ] }, ], @@ -50,10 +51,15 @@ export class AppModule {} /** * Load settings and set languages before app start */ -function appInitializer(settings: SettingsService) { +function appInitializer(recordingsService: RecordingsService, settings: SettingsService) { return async () => { + + // initialize recordings service + await recordingsService.initialize(); + // initialize settings - return await settings.initialize(); + await settings.initialize(); + } } diff --git a/src/app/models/recording.ts b/src/app/models/recording.ts index 29225ef..45fa347 100644 --- a/src/app/models/recording.ts +++ b/src/app/models/recording.ts @@ -25,8 +25,8 @@ export class Recording { // Call direction direction!: 'in' | 'out' | 'conference' | ''; - // Recording date - date: Date = new Date(); + // Recording date (JS timestamp) + date: number = 0; // Recording duration (in seconds) duration: number = 0; @@ -40,6 +40,9 @@ export class Recording { // Audio file MIME type mimeType: string = ''; + // record status + status?: 'new' | 'unchanged' | 'deleted' = 'new'; + /** * Use createInstance()... */ @@ -59,7 +62,7 @@ export class Recording { // save Android file props res.filesize = file.size; res.mimeType = file.type; - res.date = new Date(file.lastModified); + res.date = file.lastModified; res.opName = file.name; res.opNumber = file.name; @@ -85,7 +88,7 @@ export class Recording { res.simSlot = metadata.sim_slot ?? 0; res.duration = Math.ceil(metadata.output?.recording?.duration_secs_total ?? 0); if (metadata.timestamp_unix_ms) { - res.date = new Date(metadata.timestamp_unix_ms); + res.date = metadata.timestamp_unix_ms; } // extract "other party" data diff --git a/src/app/pages/main/main.page.ts b/src/app/pages/main/main.page.ts index 122e5f9..7297a55 100644 --- a/src/app/pages/main/main.page.ts +++ b/src/app/pages/main/main.page.ts @@ -4,7 +4,7 @@ import { MessageBoxService } from 'src/app/services/message-box.service'; import { RecordingsService } from 'src/app/services/recordings.service'; import { SettingsService } from 'src/app/services/settings.service'; import { bringIntoView } from 'src/app/utils/scroll'; -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import version from '../../version'; @Component({ @@ -12,7 +12,7 @@ import version from '../../version'; templateUrl: './main.page.html', styleUrls: ['./main.page.scss'], }) -export class MainPage implements OnInit { +export class MainPage { version = version; selectedItem?: Recording; @@ -23,10 +23,6 @@ export class MainPage implements OnInit { protected settings: SettingsService, ) { } - ngOnInit(): void { - this.refreshList(); - } - refreshList() { this.recordingsService.refreshContent(); } @@ -63,7 +59,6 @@ export class MainPage implements OnInit { confirmText: 'Delete', onConfirm: () => { this.recordingsService.deleteRecording(item); - this.recordingsService.refreshContent(); } }); diff --git a/src/app/pages/settings/settings.page.html b/src/app/pages/settings/settings.page.html index c1c6b9d..1241236 100644 --- a/src/app/pages/settings/settings.page.html +++ b/src/app/pages/settings/settings.page.html @@ -2,7 +2,7 @@ - +

General

@@ -15,6 +15,11 @@

BCR recordings directory

+ + Cache + Clear + +

Recordings

diff --git a/src/app/pages/settings/settings.page.ts b/src/app/pages/settings/settings.page.ts index b75a640..2561f29 100644 --- a/src/app/pages/settings/settings.page.ts +++ b/src/app/pages/settings/settings.page.ts @@ -1,4 +1,5 @@ import { SortMode } from 'src/app/pipes/recordings-sort.pipe'; +import { MessageBoxService } from 'src/app/services/message-box.service'; import { RecordingsService } from 'src/app/services/recordings.service'; import { Component } from '@angular/core'; import { SettingsService } from '../../services/settings.service'; @@ -13,6 +14,7 @@ export class SettingsPage { SortMode = SortMode; constructor( + private mbs: MessageBoxService, protected settings: SettingsService, protected recordingsService: RecordingsService, ) { } @@ -25,4 +27,14 @@ export class SettingsPage { this.recordingsService.selectRecordingsDirectory(); } + clearCache() { + this.mbs.showConfirm({ + header: 'Clear cache', + message: 'Do you really want to clear the cache and reload all recordings?', + onConfirm: () => { + this.recordingsService.refreshContent(true); + } + }); + } + } diff --git a/src/app/pipes/recordings-sort.pipe.ts b/src/app/pipes/recordings-sort.pipe.ts index 4ee2220..a0c54b6 100644 --- a/src/app/pipes/recordings-sort.pipe.ts +++ b/src/app/pipes/recordings-sort.pipe.ts @@ -30,14 +30,14 @@ export class RecordingsSortPipe implements PipeTransform { switch (sortMode) { // date - case SortMode.Date_ASC: return (a: Recording, b: Recording) => (a.date.getTime() - b.date.getTime()); + case SortMode.Date_ASC: return (a: Recording, b: Recording) => (a.date - b.date); // duration case SortMode.Duration_ASC: return (a: Recording, b: Recording) => (a.duration - b.duration); case SortMode.Duration_DESC: return (a: Recording, b: Recording) => (b.duration - a.duration); // Date_DESC is the default mode - default: return (a: Recording, b: Recording) => (b.date.getTime() - a.date.getTime()); + default: return (a: Recording, b: Recording) => (b.date - a.date); } } diff --git a/src/app/services/recordings.service.ts b/src/app/services/recordings.service.ts index 27fbe79..83de663 100644 --- a/src/app/services/recordings.service.ts +++ b/src/app/services/recordings.service.ts @@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'; import { AlertController, IonicSafeString } from '@ionic/angular'; import { Recording } from '../models/recording'; import { replaceExtension } from '../utils/filesystem'; +import { RecordingsCache } from '../utils/recordings-cache'; import { MessageBoxService } from './message-box.service'; import { SettingsService } from './settings.service'; @@ -20,46 +21,55 @@ export class RecordingsService { * value === 0 ==> no refresh running * 0 < value <=1 ==> refresh progress (%) */ - public refreshProgress = new BehaviorSubject(-1); + public refreshProgress = new BehaviorSubject(0); constructor( private alertController: AlertController, private mbs: MessageBoxService, protected settings: SettingsService, - ) { } + ) {} + + /** + * Initialize the recordings DB + */ + async initialize() { + + // read DB from cache + const cache = await RecordingsCache.load(); + this.recordings.next(cache); + + } /** * Refresh recordings list */ - async refreshContent() { + async refreshContent(clearCache: boolean = false) { - if (!this.settings.recordingsDirectoryUri) { - const alert = await this.alertController.create({ - header: 'Recordings directory not selected', - message: new IonicSafeString( - `This app needs access to BCR recordings directory. - - Click OK and Android will show you the default folder-selector. - - Now select the recordings directory used by BCR and allow access to its content...` - .replace(/[\r\n]/g, '
')), - buttons: [ - 'Cancel', - { - text: 'OK', - handler: () => this.selectRecordingsDirectory(), - }, - ], - backdropDismiss: false, - }); + // exit if we're already refreshing + if (this.refreshProgress.value) { + return; + } - await alert.present(); + if (!this.settings.recordingsDirectoryUri) { + this.selectRecordingsDirectory(); return; } - this.refreshProgress.next(0.0000001); // immediately send a non-zero progress + // immediately send a non-zero progress + this.refreshProgress.next(0.0001); console.log("Reading files in folder:"); + // save current DB and set statuses as 'deleted' + // (if a file with the same filename won't be found, then DB record must be removed) + let currentDB: Recording[]; + if (clearCache) { + currentDB = []; + } + else { + currentDB = this.recordings.value; + currentDB.forEach(r => r.status = 'deleted'); + } + try { // keep files only (no directories) const allFiles = (await AndroidSAF.listFiles({ uri: this.settings.recordingsDirectoryUri })) @@ -68,9 +78,7 @@ export class RecordingsService { // extract supported audio file types const audioFiles = allFiles.filter(i => this.settings.supportedTypes.includes(i.type)); - // compose an array of Recording class instances based on - // each audio file and its corresponding (optional) metadata file - const recordings: Recording[] = []; + // parse each audio file and its corresponding (optional) metadata file const count = audioFiles.length; // no files? @@ -81,44 +89,44 @@ export class RecordingsService { // send progress update this.refreshProgress.next(++i / count); - // test if current file has a corresponding metadata .json file + // check if current audio file already exists in current DB + const dbRecord = currentDB.find(r => r.file.uri === file.uri); + if (dbRecord) { + // mark file as "unchanged" and continue + dbRecord.status = 'unchanged'; + continue; + } + + // check if audio file has a corresponding metadata .json file + if (file.name.startsWith('test_file_0086') || i >= 85) { + const a = 1; + } const metadataFileName = replaceExtension(file.name, '.json'); const metadataFile = allFiles.find(i => i.name === metadataFileName); - // add to result array - recordings.push(await Recording.createInstance(file, metadataFile)); + // add to currentDB + currentDB.push(await Recording.createInstance(file, metadataFile)); } } - // update collection - this.recordings.next(recordings); + // remove deleted files + currentDB = currentDB.filter(r => r.status !== 'deleted'); + + // update collection & cache + this.recordings.next(currentDB); + await RecordingsCache.save(currentDB); this.refreshProgress.next(0); } catch(err) { console.error(err); + this.mbs.showError({ message: 'Error updating cache', error: err }); this.refreshProgress.next(0); }; } - /** - * Open Android SAF directory selector to choose recordings dir - */ - selectRecordingsDirectory() { - - AndroidSAF.selectDirectory({}) - .then(res => { - this.settings.recordingsDirectoryUri = res.selectedUri; - console.log('Selected folder:', this.settings.recordingsDirectoryUri); - this.settings.save(); - this.recordings.next([]); - this.refreshContent(); - }); - - } - /** * Deletes the given recording file and its optional JSON metadata */ @@ -128,18 +136,62 @@ export class RecordingsService { const deleteFileFn = async (uri: string) => { try { await AndroidSAF.deleteFile({ uri }); + return true; } catch(err) { this.mbs.showError({ message: 'There was an error while deleting item: ' + uri, error: err, }); + return false; } } - item && await deleteFileFn(item.file.uri); - item?.metadataFile && await deleteFileFn(item.metadataFile.uri); + if ( + item && await deleteFileFn(item.file.uri) + && item?.metadataFile && await deleteFileFn(item.metadataFile.uri) + ){ + // update DB + this.recordings.next(this.recordings.value.filter(r => r !== item)); + } + + } + + /** + * Show user the SAF directory selection dialog + */ + async selectRecordingsDirectory() { + + const alert = await this.alertController.create({ + header: 'Recordings directory not selected', + message: new IonicSafeString( + `This app needs access to BCR recordings directory. + + Click OK and Android will show you the default folder-selector. + + Now select the recordings directory used by BCR and allow access to its content...` + .replace(/[\r\n]/g, '
')), + buttons: [ + 'Cancel', + { + text: 'OK', + handler: () => { + // show directory selector + AndroidSAF.selectDirectory({}) + .then(res => { + this.settings.recordingsDirectoryUri = res.selectedUri; + console.log('Selected folder:', this.settings.recordingsDirectoryUri); + this.settings.save(); + this.recordings.next([]); + this.refreshContent(true); + }); + }, + }, + ], + backdropDismiss: false, + }); + await alert.present(); } } diff --git a/src/app/utils/recordings-cache.ts b/src/app/utils/recordings-cache.ts new file mode 100644 index 0000000..6c7f196 --- /dev/null +++ b/src/app/utils/recordings-cache.ts @@ -0,0 +1,93 @@ +import { Directory, Filesystem, WriteFileResult } from '@capacitor/filesystem'; +import { Recording } from '../models/recording'; + +// filename of the cache +const DB_CACHE_FILENAME = 'db-cache.json'; + +// version of cache schema +// (must be changed if we need to "invalidate" cache content (eg: a new Recording field must be filled in) +const CACHE_SCHEMA_VERSION = 1; + +/** + * Defines content of cache file + */ +type CacheContent = { + cacheSchemaVersion: number, + recordings: Recording[], +} + +/** + * Application cache for the recordings database + */ +export class RecordingsCache { + + // no constructor needed, only static methods... + private constructor() {} + + /** + * Load the cache from app storage + */ + static async load(): Promise { + + // recover recordings DB from cache + if (await RecordingsCache.checkFileExists(DB_CACHE_FILENAME)) { + + // read file content + const { data: cacheContent } = await Filesystem.readFile({ path: DB_CACHE_FILENAME, directory: Directory.Cache }); + let cacheData: Partial; + try { + cacheData = JSON.parse(atob(cacheContent as string)) as Partial; + } catch (error) { + cacheData = {}; + } + + // check schema version + if (cacheData.cacheSchemaVersion === CACHE_SCHEMA_VERSION) { + // return cache content + return cacheData.recordings ?? []; + } + } + + // empty cache + return []; + + } + + /** + * Save cache to app storage + */ + static save(recordings: Recording[]): Promise { + + const cacheContent: string = JSON.stringify({ + cacheSchemaVersion: CACHE_SCHEMA_VERSION, + recordings: recordings, + }); + + return Filesystem.writeFile({ + path: DB_CACHE_FILENAME, + directory: Directory.Cache, + data: btoa(cacheContent), + }); + + } + + /** + * Clear the cache + */ + static clear(): Promise { + return this.save([]); + } + + /** + * Test if the given file exists + */ + private static async checkFileExists(filePath: string): Promise { + try { + await Filesystem.stat({ path: filePath, directory: Directory.Cache }); + return true; + } catch (checkDirException) { + return false; + } + } + +} \ No newline at end of file