From df857b7065e7da51429d5099007127c67117245c Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Thu, 26 Nov 2020 12:28:17 +0800 Subject: [PATCH] Refactor Proof Interface (#252) Due to the limitation of Capacitor and Ionic framework, we cannot and should not use the same architecture from Starling Capture, which is based on Android framework. Thus, I have rewritten most parts regarding the data persistence. This includes: - Merge `InformationRepository`, `SignatureRepository` and `SerializationService` into `ProofRepository` and provide the same functionality. - Refactor `CollectorService` and the corresponding `InformationProvider`s and `SignatureProvider`s to accept newly merged `Proof` instance. - Refactor `PublisherAlert` and the corresponding publisher `NumbersStoragePublisher` and `NumbersStorageApi` to accept newly merged `Proof` instance. - Write unit tests to fully cover the refactored components, and thus the coverage has reached 70%. - Implement an adapter (`old-proof-adapter`) to provide the connection between Numbers Storage Back-end. - Rename `Information` to `Truth` and `Facts`. - Remove deprecated sections in README. --- README.md | 20 +- package-lock.json | 11 +- package.json | 3 +- src/app/app.component.ts | 34 +-- src/app/pages/home/asset/asset.page.ts | 57 ++--- .../asset/information/information.page.html | 22 +- .../asset/information/information.page.ts | 68 +++--- .../sending-post-capture.page.ts | 37 +-- src/app/pages/home/home.page.html | 11 +- src/app/pages/home/home.page.ts | 81 +++---- src/app/pages/home/inbox/inbox.page.ts | 2 +- src/app/pages/privacy/privacy.page.ts | 2 +- src/app/pages/profile/profile.page.ts | 4 +- .../collector/collector.service.spec.ts | 94 +++++++- .../services/collector/collector.service.ts | 89 +++----- .../capacitor-provider/capacitor-provider.ts | 58 +++++ .../collector/facts/facts-provider.ts | 6 + .../capacitor-provider.spec.ts | 1 - .../capacitor-provider/capacitor-provider.ts | 206 ----------------- .../information/information-provider.spec.ts | 1 - .../information/information-provider.ts | 22 -- .../signature/signature-provider.spec.ts | 1 - .../collector/signature/signature-provider.ts | 28 +-- .../web-crypto-api-provider.ts | 21 +- .../database/database.service.spec.ts | 9 +- .../capacitor-filesystem-table-impl.ts | 10 +- .../memory-table-impl.spec.ts | 30 +-- .../memory-table-impl/memory-table-impl.ts | 10 +- src/app/services/database/table/table.ts | 2 +- .../data/asset/asset-repository.service.ts | 81 ------- .../numbers-storage-api.service.ts | 42 ++-- .../numbers-storage-publisher.ts | 41 ++-- .../asset/asset-repository.service.spec.ts | 0 .../asset/asset-repository.service.ts | 41 ++++ .../{data => repositories}/asset/asset.ts | 6 +- ...red-transaction-repository.service.spec.ts | 0 .../ignored-transaction-repository.service.ts | 0 .../ignored-transaction.ts | 0 src/app/services/publisher/publisher.spec.ts | 1 - src/app/services/publisher/publisher.ts | 43 ++-- .../publishers-alert.service.ts | 54 +++-- .../caption-repository.service.spec.ts | 20 -- .../caption/caption-repository.service.ts | 50 ----- .../services/repositories/caption/caption.ts | 6 - .../information-repository.service.spec.ts | 20 -- .../information-repository.service.ts | 38 ---- .../repositories/information/information.ts | 21 -- .../proof/old-proof-adapter.spec.ts | 158 +++++++++++++ .../repositories/proof/old-proof-adapter.ts | 145 ++++++++++++ .../proof/proof-repository.service.spec.ts | 97 +++++++- .../proof/proof-repository.service.ts | 128 ++--------- .../services/repositories/proof/proof.spec.ts | 212 ++++++++++++++++++ src/app/services/repositories/proof/proof.ts | 123 +++++++++- .../signature-repository.service.spec.ts | 20 -- .../signature/signature-repository.service.ts | 40 ---- .../repositories/signature/signature.ts | 8 - .../serialization.service.spec.ts | 20 -- .../serialization/serialization.service.ts | 58 ----- .../post-capture-card.component.spec.ts | 9 +- .../post-capture-card.component.ts | 9 +- src/app/typings/image-blob-reduce.d.ts | 12 + src/app/utils/immutable/immutable.spec.ts | 29 +++ src/app/utils/immutable/immutable.ts | 9 + src/app/utils/mime-type.ts | 2 +- tsconfig.base.json | 2 +- typings/cordova-typings.d.ts | 3 - 66 files changed, 1331 insertions(+), 1157 deletions(-) create mode 100644 src/app/services/collector/facts/capacitor-provider/capacitor-provider.ts create mode 100644 src/app/services/collector/facts/facts-provider.ts delete mode 100644 src/app/services/collector/information/capacitor-provider/capacitor-provider.spec.ts delete mode 100644 src/app/services/collector/information/capacitor-provider/capacitor-provider.ts delete mode 100644 src/app/services/collector/information/information-provider.spec.ts delete mode 100644 src/app/services/collector/information/information-provider.ts delete mode 100644 src/app/services/collector/signature/signature-provider.spec.ts delete mode 100644 src/app/services/publisher/numbers-storage/data/asset/asset-repository.service.ts rename src/app/services/publisher/numbers-storage/{data => repositories}/asset/asset-repository.service.spec.ts (100%) create mode 100644 src/app/services/publisher/numbers-storage/repositories/asset/asset-repository.service.ts rename src/app/services/publisher/numbers-storage/{data => repositories}/asset/asset.ts (59%) rename src/app/services/publisher/numbers-storage/{data => repositories}/ignored-transaction/ignored-transaction-repository.service.spec.ts (100%) rename src/app/services/publisher/numbers-storage/{data => repositories}/ignored-transaction/ignored-transaction-repository.service.ts (100%) rename src/app/services/publisher/numbers-storage/{data => repositories}/ignored-transaction/ignored-transaction.ts (100%) delete mode 100644 src/app/services/publisher/publisher.spec.ts delete mode 100644 src/app/services/repositories/caption/caption-repository.service.spec.ts delete mode 100644 src/app/services/repositories/caption/caption-repository.service.ts delete mode 100644 src/app/services/repositories/caption/caption.ts delete mode 100644 src/app/services/repositories/information/information-repository.service.spec.ts delete mode 100644 src/app/services/repositories/information/information-repository.service.ts delete mode 100644 src/app/services/repositories/information/information.ts create mode 100644 src/app/services/repositories/proof/old-proof-adapter.spec.ts create mode 100644 src/app/services/repositories/proof/old-proof-adapter.ts create mode 100644 src/app/services/repositories/proof/proof.spec.ts delete mode 100644 src/app/services/repositories/signature/signature-repository.service.spec.ts delete mode 100644 src/app/services/repositories/signature/signature-repository.service.ts delete mode 100644 src/app/services/repositories/signature/signature.ts delete mode 100644 src/app/services/serialization/serialization.service.spec.ts delete mode 100644 src/app/services/serialization/serialization.service.ts create mode 100644 src/app/typings/image-blob-reduce.d.ts create mode 100644 src/app/utils/immutable/immutable.spec.ts create mode 100644 src/app/utils/immutable/immutable.ts delete mode 100644 typings/cordova-typings.d.ts diff --git a/README.md b/README.md index bccae67ad..b6cc7ca52 100644 --- a/README.md +++ b/README.md @@ -38,16 +38,12 @@ Preview the app in web browser. npm run serve ``` -### Verification - -The signature of proofs can be verified with [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). The signed message is generated by `services/serialization/serialization.service.ts#stringify()` method. A Python example for verification can be found in [the Starling Capture repository](https://github.com/numbersprotocol/starling-capture/tree/master/util/verification). - ## Development Start a local dev server for app dev/testing. ``` bash -ionic serve +npm run serve ``` Run tests. @@ -110,14 +106,6 @@ The script does the same thing for you. npm run build-android ``` -### Architecture - -See [the architecture of Starling Capture](https://github.com/numbersprotocol/starling-capture#architecture) for details. - -### Serialization Schema - -See [the serialization schema of Starling Capture](https://github.com/numbersprotocol/starling-capture#serialization-schema) for details. - ### Caveat * This app is still in the experimental stage. @@ -154,9 +142,3 @@ When push to the `develop` branch with new version in the `package.json` file, G 1. Publish the app to Play Console on alpha track. 1. Upload debug apk to Google Drive. 1. Send notification to the private `reminder-releases` slack channel. - -### Deploy - -#### Demo App - -The demo app is hosted on the GitHub Page. It would be updated when there is a new commit on the `master` branch. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e663dce7b..1a5e68f93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6561,9 +6561,9 @@ } }, "image-blob-reduce": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/image-blob-reduce/-/image-blob-reduce-2.0.0.tgz", - "integrity": "sha512-l4j/frV2Tyc8Argaf4GTP4hMaRV7QOIGgjCWoQE5wbJBYhHMFK4LQJPaGtmMjTcoVumlbzcoCPH9Zm+63+oJrw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/image-blob-reduce/-/image-blob-reduce-2.1.1.tgz", + "integrity": "sha512-9SwoNPezG7rm+4hjTNPHLa/WnDjxvcDfjKYXsVSYYX3BqMZzBFBkoukYsatuvR1qPhl8gCBgYCvVSDIfjrCmvg==", "requires": { "pica": "^6.1.1" } @@ -6581,6 +6581,11 @@ "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", "dev": true }, + "immutable": { + "version": "4.0.0-rc.12", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0-rc.12.tgz", + "integrity": "sha512-0M2XxkZLx/mi3t8NVwIm1g8nHoEmM9p9UBl/G9k4+hm0kBgOVdMV/B3CY5dQ8qG8qc80NN4gDV4HQv6FTJ5q7A==" + }, "import-cwd": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", diff --git a/package.json b/package.json index 721cf17ee..efdcbd789 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "@ngx-formly/material": "^5.0.0", "@ngx-formly/schematics": "^5.10.3", "async-mutex": "^0.2.4", - "image-blob-reduce": "^2.0.0", + "image-blob-reduce": "^2.1.1", + "immutable": "^4.0.0-rc.12", "lodash": "^4.17.20", "rxjs": "~6.6.3", "tslib": "^2.0.1", diff --git a/src/app/app.component.ts b/src/app/app.component.ts index f1da2c782..23ac6a429 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -5,22 +5,17 @@ import { Plugins } from '@capacitor/core'; import { Platform } from '@ionic/angular'; import { TranslocoService } from '@ngneat/transloco'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { map } from 'rxjs/operators'; +import { concatMap } from 'rxjs/operators'; import { CameraService } from './services/camera/camera.service'; import { CollectorService } from './services/collector/collector.service'; -import { CapacitorProvider } from './services/collector/information/capacitor-provider/capacitor-provider'; +import { CapacitorProvider } from './services/collector/facts/capacitor-provider/capacitor-provider'; import { WebCryptoApiProvider } from './services/collector/signature/web-crypto-api-provider/web-crypto-api-provider'; import { LanguageService } from './services/language/language.service'; import { NotificationService } from './services/notification/notification.service'; -import { AssetRepository } from './services/publisher/numbers-storage/data/asset/asset-repository.service'; import { NumbersStorageApi } from './services/publisher/numbers-storage/numbers-storage-api.service'; import { NumbersStoragePublisher } from './services/publisher/numbers-storage/numbers-storage-publisher'; +import { AssetRepository } from './services/publisher/numbers-storage/repositories/asset/asset-repository.service'; import { PublishersAlert } from './services/publisher/publishers-alert/publishers-alert.service'; -import { CaptionRepository } from './services/repositories/caption/caption-repository.service'; -import { InformationRepository } from './services/repositories/information/information-repository.service'; -import { ProofRepository } from './services/repositories/proof/proof-repository.service'; -import { SignatureRepository } from './services/repositories/signature/signature-repository.service'; -import { SerializationService } from './services/serialization/serialization.service'; import { fromExtension } from './utils/mime-type'; const { SplashScreen } = Plugins; @@ -36,11 +31,6 @@ export class AppComponent { private readonly platform: Platform, private readonly collectorService: CollectorService, private readonly publishersAlert: PublishersAlert, - private readonly serializationService: SerializationService, - private readonly proofRepository: ProofRepository, - private readonly informationRepository: InformationRepository, - private readonly signatureRepository: SignatureRepository, - private readonly captionRepository: CaptionRepository, private readonly translocoService: TranslocoService, private readonly notificationService: NotificationService, private readonly numbersStorageApi: NumbersStorageApi, @@ -60,10 +50,9 @@ export class AppComponent { restoreAppStatus() { this.cameraService.restoreKilledAppResult$().pipe( - map(cameraPhoto => this.collectorService.storeAndCollect( - cameraPhoto.base64String, - fromExtension(cameraPhoto.format) - )), + concatMap(cameraPhoto => this.collectorService.runAndStore({ + [cameraPhoto.base64String]: { mimeType: fromExtension(cameraPhoto.format) } + })), untilDestroyed(this) ).subscribe(); } @@ -76,12 +65,8 @@ export class AppComponent { initializeCollector() { WebCryptoApiProvider.initialize$().pipe(untilDestroyed(this)).subscribe(); - this.collectorService.addInformationProvider( - new CapacitorProvider(this.informationRepository, this.translocoService) - ); - this.collectorService.addSignatureProvider( - new WebCryptoApiProvider(this.signatureRepository, this.serializationService) - ); + this.collectorService.addFactsProvider(new CapacitorProvider()); + this.collectorService.addSignatureProvider(new WebCryptoApiProvider()); } initializePublisher() { @@ -89,9 +74,6 @@ export class AppComponent { new NumbersStoragePublisher( this.translocoService, this.notificationService, - this.proofRepository, - this.signatureRepository, - this.captionRepository, this.numbersStorageApi, this.assetRepository ) diff --git a/src/app/pages/home/asset/asset.page.ts b/src/app/pages/home/asset/asset.page.ts index 7d2efd4a1..0d33bb198 100644 --- a/src/app/pages/home/asset/asset.page.ts +++ b/src/app/pages/home/asset/asset.page.ts @@ -6,14 +6,12 @@ import { ActivatedRoute, Router } from '@angular/router'; import { Plugins } from '@capacitor/core'; import { TranslocoService } from '@ngneat/transloco'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { defer } from 'rxjs'; -import { map, pluck, switchMap, switchMapTo, tap } from 'rxjs/operators'; +import { combineLatest, defer, forkJoin, zip } from 'rxjs'; +import { concatMap, map, switchMap, switchMapTo, tap } from 'rxjs/operators'; import { BlockingActionService } from 'src/app/services/blocking-action/blocking-action.service'; -import { CapacitorProvider } from 'src/app/services/collector/information/capacitor-provider/capacitor-provider'; import { ConfirmAlert } from 'src/app/services/confirm-alert/confirm-alert.service'; -import { AssetRepository } from 'src/app/services/publisher/numbers-storage/data/asset/asset-repository.service'; -import { CaptionRepository } from 'src/app/services/repositories/caption/caption-repository.service'; -import { InformationRepository } from 'src/app/services/repositories/information/information-repository.service'; +import { AssetRepository } from 'src/app/services/publisher/numbers-storage/repositories/asset/asset-repository.service'; +import { getOldProof } from 'src/app/services/repositories/proof/old-proof-adapter'; import { ProofRepository } from 'src/app/services/repositories/proof/proof-repository.service'; import { isNonNullable } from 'src/app/utils/rx-operators'; import { ContactSelectionDialogComponent, SelectedContact } from './contact-selection-dialog/contact-selection-dialog.component'; @@ -35,26 +33,31 @@ export class AssetPage { switchMap(id => this.assetRepository.getById$(id)), isNonNullable() ); - readonly proof$ = this.asset$.pipe( - switchMap(asset => this.proofRepository.getByHash$(asset.proof_hash)), - isNonNullable() + private readonly proofsWithOld$ = this.proofRepository.getAll$().pipe( + concatMap(proofs => Promise.all(proofs.map(async (proof) => + ({ proof, oldProof: await getOldProof(proof) }) + ))) ); - readonly base64Src$ = this.proof$.pipe( - switchMap(proof => this.proofRepository.getRawFile$(proof)), - map(rawBase64 => `data:image/png;base64,${rawBase64}`) + readonly capture$ = combineLatest([this.asset$, this.proofsWithOld$]).pipe( + map(([asset, proofsWithThumbnailAndOld]) => ({ + asset, + proofWithThumbnailAndOld: proofsWithThumbnailAndOld.find(p => p.oldProof.hash === asset.proof_hash) + })), + isNonNullable() ); - readonly timestamp$ = this.proof$.pipe(pluck('timestamp')); - readonly latitude$ = this.proof$.pipe( - switchMap(proof => this.informationRepository.getByProof$(proof)), - map(informationList => informationList.find(information => information.provider === CapacitorProvider.ID && information.name === 'Current GPS Latitude')), + readonly base64Src$ = this.capture$.pipe( + map(capture => capture.proofWithThumbnailAndOld), isNonNullable(), - pluck('value') + map(p => `data:${Object.values(p.proof.assets)[0].mimeType};base64,${Object.keys(p.proof.assets)[0]}`) ); - readonly longitude$ = this.proof$.pipe( - switchMap(proof => this.informationRepository.getByProof$(proof)), - map(informationList => informationList.find(information => information.provider === CapacitorProvider.ID && information.name === 'Current GPS Longitude')), - isNonNullable(), - pluck('value') + readonly timestamp$ = this.capture$.pipe( + map(capture => capture.proofWithThumbnailAndOld?.proof.timestamp) + ); + readonly latitude$ = this.capture$.pipe( + map(capture => `${capture.proofWithThumbnailAndOld?.proof.geolocationLatitude}`) + ); + readonly longitude$ = this.capture$.pipe( + map(capture => `${capture.proofWithThumbnailAndOld?.proof.geolocationLongitude}`) ); constructor( @@ -64,8 +67,6 @@ export class AssetPage { private readonly confirmAlert: ConfirmAlert, private readonly assetRepository: AssetRepository, private readonly proofRepository: ProofRepository, - private readonly captionRepository: CaptionRepository, - private readonly informationRepository: InformationRepository, private readonly blockingActionService: BlockingActionService, private readonly snackBar: MatSnackBar, private readonly dialog: MatDialog, @@ -98,8 +99,12 @@ export class AssetPage { private remove() { const onConfirm = () => this.blockingActionService.run$( - this.asset$.pipe( - switchMap(asset => this.assetRepository.remove$(asset)), + zip(this.asset$, this.capture$).pipe( + concatMap(([asset, capture]) => forkJoin([ + this.assetRepository.remove$(asset), + // tslint:disable-next-line: no-non-null-assertion + this.proofRepository.remove(capture.proofWithThumbnailAndOld!.proof) + ])), switchMapTo(defer(() => this.router.navigate(['..']))) ), { message: this.translocoService.translate('processing') } diff --git a/src/app/pages/home/asset/information/information.page.html b/src/app/pages/home/asset/information/information.page.html index 7a1cecb20..1c49b7174 100644 --- a/src/app/pages/home/asset/information/information.page.html +++ b/src/app/pages/home/asset/information/information.page.html @@ -24,25 +24,11 @@
{{ mimeType$ | async }}
-
{{ t('location') }}
- +
{{ t('information') }}
+ information -
{{ information.name }}
-
{{ information.value }}
-
- -
{{ t('device') }}
- - information -
{{ information.name }}
-
{{ information.value }}
-
- -
{{ t('other') }}
- - information -
{{ information.name }}
-
{{ information.value }}
+
{{ fact.key }}
+
{{ fact.value }}
{{ t('signature') }}
diff --git a/src/app/pages/home/asset/information/information.page.ts b/src/app/pages/home/asset/information/information.page.ts index f92d44b20..33129266e 100644 --- a/src/app/pages/home/asset/information/information.page.ts +++ b/src/app/pages/home/asset/information/information.page.ts @@ -1,13 +1,11 @@ import { Component } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { UntilDestroy } from '@ngneat/until-destroy'; -import { map, pluck, switchMap } from 'rxjs/operators'; -import { WebCryptoApiProvider } from 'src/app/services/collector/signature/web-crypto-api-provider/web-crypto-api-provider'; -import { AssetRepository } from 'src/app/services/publisher/numbers-storage/data/asset/asset-repository.service'; -import { InformationType } from 'src/app/services/repositories/information/information'; -import { InformationRepository } from 'src/app/services/repositories/information/information-repository.service'; +import { combineLatest } from 'rxjs'; +import { concatMap, map, switchMap } from 'rxjs/operators'; +import { AssetRepository } from 'src/app/services/publisher/numbers-storage/repositories/asset/asset-repository.service'; +import { getOldProof, getOldSignatures } from 'src/app/services/repositories/proof/old-proof-adapter'; import { ProofRepository } from 'src/app/services/repositories/proof/proof-repository.service'; -import { SignatureRepository } from 'src/app/services/repositories/signature/signature-repository.service'; import { isNonNullable } from 'src/app/utils/rx-operators'; @UntilDestroy({ checkProperties: true }) @@ -24,41 +22,47 @@ export class InformationPage { switchMap(id => this.assetRepository.getById$(id)), isNonNullable() ); - readonly proof$ = this.asset$.pipe( - switchMap(asset => this.proofRepository.getByHash$(asset.proof_hash)), - isNonNullable() + private readonly proofsWithOld$ = this.proofRepository.getAll$().pipe( + concatMap(proofs => Promise.all(proofs.map(async (proof) => + ({ proof, oldProof: await getOldProof(proof) }) + ))) ); - - readonly timestamp$ = this.proof$.pipe(pluck('timestamp')); - readonly hash$ = this.proof$.pipe(pluck('hash')); - readonly mimeType$ = this.proof$.pipe(pluck('mimeType')); - - readonly locationInformation$ = this.proof$.pipe( - switchMap(proof => this.informationRepository.getByProof$(proof)), - map(informationList => informationList.filter(information => information.type === InformationType.Location)) + readonly capture$ = combineLatest([this.asset$, this.proofsWithOld$]).pipe( + map(([asset, proofsWithThumbnailAndOld]) => ({ + asset, + proofWithOld: proofsWithThumbnailAndOld.find(p => p.oldProof.hash === asset.proof_hash) + })) ); - - readonly deviceInformation$ = this.proof$.pipe( - switchMap(proof => this.informationRepository.getByProof$(proof)), - map(informationList => informationList.filter(information => information.type === InformationType.Device)) + readonly timestamp$ = this.capture$.pipe( + map(capture => capture.proofWithOld?.proof.timestamp) + ); + readonly hash$ = this.capture$.pipe( + map(capture => capture.proofWithOld), + isNonNullable(), + concatMap(proofWithOld => proofWithOld.proof.getId()) + ); + readonly mimeType$ = this.capture$.pipe( + map(capture => capture.proofWithOld), + isNonNullable(), + map(proofWithOld => Object.values(proofWithOld.proof.assets)[0].mimeType) ); - readonly otherInformation$ = this.proof$.pipe( - switchMap(proof => this.informationRepository.getByProof$(proof)), - map(informationList => informationList.filter(information => information.type === InformationType.Other)) + readonly facts$ = this.capture$.pipe( + map(capture => capture.proofWithOld), + isNonNullable(), + map(proofWithOld => Object.values(proofWithOld.proof.truth.providers)[0]) ); - readonly signature$ = this.proof$.pipe( - switchMap(proof => this.signatureRepository.getByProof$(proof)), - map(signatures => signatures.find(signature => signature.provider === WebCryptoApiProvider.ID)), - isNonNullable() + readonly signature$ = this.capture$.pipe( + map(capture => capture.proofWithOld), + isNonNullable(), + concatMap(proofWithOld => getOldSignatures(proofWithOld.proof)), + map(oldSignatures => oldSignatures[0]) ); constructor( private readonly route: ActivatedRoute, - private readonly proofRepository: ProofRepository, - private readonly informationRepository: InformationRepository, - private readonly signatureRepository: SignatureRepository, - private readonly assetRepository: AssetRepository + private readonly assetRepository: AssetRepository, + private readonly proofRepository: ProofRepository ) { } } diff --git a/src/app/pages/home/asset/sending-post-capture/sending-post-capture.page.ts b/src/app/pages/home/asset/sending-post-capture/sending-post-capture.page.ts index c4d39e752..c9cd583cd 100644 --- a/src/app/pages/home/asset/sending-post-capture/sending-post-capture.page.ts +++ b/src/app/pages/home/asset/sending-post-capture/sending-post-capture.page.ts @@ -2,13 +2,13 @@ import { Component } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslocoService } from '@ngneat/transloco'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { defer, zip } from 'rxjs'; -import { concatMap, concatMapTo, first, map, switchMap, tap } from 'rxjs/operators'; +import { combineLatest, defer, zip } from 'rxjs'; +import { concatMap, concatMapTo, first, map, switchMap } from 'rxjs/operators'; import { BlockingActionService } from 'src/app/services/blocking-action/blocking-action.service'; import { ConfirmAlert } from 'src/app/services/confirm-alert/confirm-alert.service'; -import { AssetRepository } from 'src/app/services/publisher/numbers-storage/data/asset/asset-repository.service'; import { NumbersStorageApi } from 'src/app/services/publisher/numbers-storage/numbers-storage-api.service'; -import { CaptionRepository } from 'src/app/services/repositories/caption/caption-repository.service'; +import { AssetRepository } from 'src/app/services/publisher/numbers-storage/repositories/asset/asset-repository.service'; +import { getOldProof } from 'src/app/services/repositories/proof/old-proof-adapter'; import { ProofRepository } from 'src/app/services/repositories/proof/proof-repository.service'; import { isNonNullable } from 'src/app/utils/rx-operators'; @@ -26,13 +26,22 @@ export class SendingPostCapturePage { switchMap(id => this.assetRepository.getById$(id)), isNonNullable() ); - readonly proof$ = this.asset$.pipe( - switchMap(asset => this.proofRepository.getByHash$(asset.proof_hash)), + private readonly proofsWithOld$ = this.proofRepository.getAll$().pipe( + concatMap(proofs => Promise.all(proofs.map(async (proof) => + ({ proof, oldProof: await getOldProof(proof) }) + ))) + ); + readonly capture$ = combineLatest([this.asset$, this.proofsWithOld$]).pipe( + map(([asset, proofsWithThumbnailAndOld]) => ({ + asset, + proofWithThumbnailAndOld: proofsWithThumbnailAndOld.find(p => p.oldProof.hash === asset.proof_hash) + })), isNonNullable() ); - readonly base64Src$ = this.proof$.pipe( - switchMap(proof => this.proofRepository.getRawFile$(proof)), - map(rawBase64 => `data:image/png;base64,${rawBase64}`) + readonly base64Src$ = this.capture$.pipe( + map(capture => capture.proofWithThumbnailAndOld), + isNonNullable(), + map(p => `data:${Object.values(p.proof.assets)[0].mimeType};base64,${Object.keys(p.proof.assets)[0]}`) ); readonly contact$ = this.route.paramMap.pipe( map(params => params.get('contact')), @@ -49,7 +58,6 @@ export class SendingPostCapturePage { private readonly route: ActivatedRoute, private readonly assetRepository: AssetRepository, private readonly proofRepository: ProofRepository, - private readonly captionRepository: CaptionRepository, private readonly confirmAlert: ConfirmAlert, private readonly translocoService: TranslocoService, private readonly numbersStorageApi: NumbersStorageApi, @@ -57,14 +65,7 @@ export class SendingPostCapturePage { ) { } preview(captionText: string) { - this.proof$.pipe( - switchMap(proof => this.captionRepository.addOrEdit$({ - proofHash: proof.hash, - text: captionText - })), - tap(_ => this.isPreview = true), - untilDestroyed(this) - ).subscribe(); + this.isPreview = true; } send(captionText: string) { diff --git a/src/app/pages/home/home.page.html b/src/app/pages/home/home.page.html index e5438b4f3..867a0462d 100644 --- a/src/app/pages/home/home.page.html +++ b/src/app/pages/home/home.page.html @@ -40,12 +40,13 @@
- -
{{ assetsWithRaw[0].date }}
+ +
{{ captureByDate.key }}
- + +
diff --git a/src/app/pages/home/home.page.ts b/src/app/pages/home/home.page.ts index 62db4b405..cc6464dc2 100644 --- a/src/app/pages/home/home.page.ts +++ b/src/app/pages/home/home.page.ts @@ -2,16 +2,18 @@ import { formatDate } from '@angular/common'; import { Component } from '@angular/core'; import { MatTabChangeEvent } from '@angular/material/tabs'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { Observable, of, zip } from 'rxjs'; -import { concatMap, first, map } from 'rxjs/operators'; +import { groupBy } from 'lodash'; +import { combineLatest, of, zip } from 'rxjs'; +import { concatMap, map } from 'rxjs/operators'; import { CameraService } from 'src/app/services/camera/camera.service'; import { CollectorService } from 'src/app/services/collector/collector.service'; -import { Asset } from 'src/app/services/publisher/numbers-storage/data/asset/asset'; -import { AssetRepository } from 'src/app/services/publisher/numbers-storage/data/asset/asset-repository.service'; import { NumbersStorageApi } from 'src/app/services/publisher/numbers-storage/numbers-storage-api.service'; +import { AssetRepository } from 'src/app/services/publisher/numbers-storage/repositories/asset/asset-repository.service'; +import { PublishersAlert } from 'src/app/services/publisher/publishers-alert/publishers-alert.service'; +import { getOldProof } from 'src/app/services/repositories/proof/old-proof-adapter'; import { ProofRepository } from 'src/app/services/repositories/proof/proof-repository.service'; import { fromExtension } from 'src/app/utils/mime-type'; -import { forkJoinWithDefault, isNonNullable } from 'src/app/utils/rx-operators'; +import { forkJoinWithDefault } from 'src/app/utils/rx-operators'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -21,13 +23,10 @@ import { forkJoinWithDefault, isNonNullable } from 'src/app/utils/rx-operators'; }) export class HomePage { - private readonly assets$ = this.assetRepository.getAll$(); - private readonly captures$ = this.assets$.pipe( - map(assets => assets.filter(asset => asset.is_original_owner)) + readonly capturesByDate$ = this.getCaptures$().pipe( + map(captures => groupBy(captures, c => formatDate(c.asset.uploaded_at, 'mediumDate', 'en-US'))) ); - postCaptures$ = this.getPostCaptures(); - readonly capturesWithRawByDate$ = this.captures$.pipe(this.appendAssetsRawAndGroupedByDate$()); - + postCaptures$ = this.getPostCaptures$(); readonly username$ = this.numbersStorageApi.getUsername$(); captureButtonShow = true; @@ -36,10 +35,30 @@ export class HomePage { private readonly proofRepository: ProofRepository, private readonly cameraService: CameraService, private readonly collectorService: CollectorService, + private readonly publishersAlert: PublishersAlert, private readonly numbersStorageApi: NumbersStorageApi ) { } - private getPostCaptures() { + private getCaptures$() { + const originallyOwnedAssets$ = this.assetRepository.getAll$().pipe( + map(assets => assets.filter(asset => asset.is_original_owner)) + ); + + const proofsWithThumbnailAndOld$ = this.proofRepository.getAll$().pipe( + concatMap(proofs => Promise.all(proofs.map(async (proof) => + ({ proof, thumbnailDataUrl: await proof.getThumbnailDataUrl(), oldProof: await getOldProof(proof) }) + ))) + ); + + return combineLatest([originallyOwnedAssets$, proofsWithThumbnailAndOld$]).pipe( + map(([assets, proofsWithThumbnailAndOld]) => assets.map(asset => ({ + asset, + proofWithThumbnailAndOld: proofsWithThumbnailAndOld.find(p => p.oldProof.hash === asset.proof_hash) + }))) + ); + } + + private getPostCaptures$() { return zip(this.numbersStorageApi.listTransactions$(), this.numbersStorageApi.getEmail$()).pipe( map(([transactionListResponse, email]) => transactionListResponse.results.filter( transaction => transaction.sender !== email && !transaction.expired && transaction.fulfilled_at @@ -57,44 +76,16 @@ export class HomePage { capture() { this.cameraService.capture$().pipe( - map(cameraPhoto => this.collectorService.storeAndCollect( - cameraPhoto.base64String, - fromExtension(cameraPhoto.format) - )), + concatMap(cameraPhoto => this.collectorService.runAndStore({ + [cameraPhoto.base64String]: { mimeType: fromExtension(cameraPhoto.format) } + })), + concatMap(proof => this.publishersAlert.presentOrPublish(proof)), untilDestroyed(this) ).subscribe(); } - private appendAssetsRawAndGroupedByDate$() { - return (assets$: Observable) => assets$.pipe( - concatMap(assets => forkJoinWithDefault(assets.map(asset => this.proofRepository.getByHash$(asset.proof_hash).pipe( - isNonNullable(), - first() - )))), - concatMap(proofs => forkJoinWithDefault(proofs.map(proof => this.proofRepository.getThumbnail$(proof)))), - concatMap(base64Strings => zip(assets$, of(base64Strings))), - map(([assets, base64Strings]) => assets.map((asset, index) => ({ - asset, - rawBase64: base64Strings[index], - date: formatDate(asset.uploaded_at, 'mediumDate', 'en-US') - }))), - map(assetsWithRawAndDate => assetsWithRawAndDate.sort( - (a, b) => Date.parse(b.asset.uploaded_at) - Date.parse(a.asset.uploaded_at) - )), - map(assetsWithRawBase64 => assetsWithRawBase64.reduce((groupedAssetsWithRawBase64, assetWithRawBase64) => { - const index = groupedAssetsWithRawBase64.findIndex( - processingAssetsWithRawBase64 => processingAssetsWithRawBase64[0].date === assetWithRawBase64.date - ); - if (index === -1) { groupedAssetsWithRawBase64.push([assetWithRawBase64]); } - else { groupedAssetsWithRawBase64[index].push(assetWithRawBase64); } - return groupedAssetsWithRawBase64; - }, [] as { asset: Asset, rawBase64: string, date: string; }[][]) - ) - ); - } - onTapChanged(event: MatTabChangeEvent) { this.captureButtonShow = event.index === 0; - if (event.index === 1) { this.postCaptures$ = this.getPostCaptures(); } + if (event.index === 1) { this.postCaptures$ = this.getPostCaptures$(); } } } diff --git a/src/app/pages/home/inbox/inbox.page.ts b/src/app/pages/home/inbox/inbox.page.ts index 72d1a3b75..66001dce4 100644 --- a/src/app/pages/home/inbox/inbox.page.ts +++ b/src/app/pages/home/inbox/inbox.page.ts @@ -3,8 +3,8 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { of, zip } from 'rxjs'; import { concatMap, map, pluck, tap } from 'rxjs/operators'; import { BlockingActionService } from 'src/app/services/blocking-action/blocking-action.service'; -import { IgnoredTransactionRepository } from 'src/app/services/publisher/numbers-storage/data/ignored-transaction/ignored-transaction-repository.service'; import { NumbersStorageApi } from 'src/app/services/publisher/numbers-storage/numbers-storage-api.service'; +import { IgnoredTransactionRepository } from 'src/app/services/publisher/numbers-storage/repositories/ignored-transaction/ignored-transaction-repository.service'; @UntilDestroy({ checkProperties: true }) @Component({ diff --git a/src/app/pages/privacy/privacy.page.ts b/src/app/pages/privacy/privacy.page.ts index 0376e8309..45fd714d3 100644 --- a/src/app/pages/privacy/privacy.page.ts +++ b/src/app/pages/privacy/privacy.page.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { CapacitorProvider } from '../../services/collector/information/capacitor-provider/capacitor-provider'; +import { CapacitorProvider } from '../../services/collector/facts/capacitor-provider/capacitor-provider'; @UntilDestroy({ checkProperties: true }) @Component({ diff --git a/src/app/pages/profile/profile.page.ts b/src/app/pages/profile/profile.page.ts index 1b3e059ce..be793edf7 100644 --- a/src/app/pages/profile/profile.page.ts +++ b/src/app/pages/profile/profile.page.ts @@ -7,8 +7,8 @@ import { TranslocoService } from '@ngneat/transloco'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { defer } from 'rxjs'; import { catchError, concatMapTo } from 'rxjs/operators'; -import { AssetRepository } from 'src/app/services/publisher/numbers-storage/data/asset/asset-repository.service'; -import { IgnoredTransactionRepository } from 'src/app/services/publisher/numbers-storage/data/ignored-transaction/ignored-transaction-repository.service'; +import { AssetRepository } from 'src/app/services/publisher/numbers-storage/repositories/asset/asset-repository.service'; +import { IgnoredTransactionRepository } from 'src/app/services/publisher/numbers-storage/repositories/ignored-transaction/ignored-transaction-repository.service'; import { BlockingActionService } from '../../services/blocking-action/blocking-action.service'; import { WebCryptoApiProvider } from '../../services/collector/signature/web-crypto-api-provider/web-crypto-api-provider'; import { NumbersStorageApi } from '../../services/publisher/numbers-storage/numbers-storage-api.service'; diff --git a/src/app/services/collector/collector.service.spec.ts b/src/app/services/collector/collector.service.spec.ts index 788fbb786..11a900bfe 100644 --- a/src/app/services/collector/collector.service.spec.ts +++ b/src/app/services/collector/collector.service.spec.ts @@ -1,22 +1,112 @@ import { TestBed } from '@angular/core/testing'; import { SharedTestingModule } from 'src/app/shared/shared-testing.module'; import { getTranslocoModule } from 'src/app/transloco/transloco-root.module.spec'; +import { MimeType } from 'src/app/utils/mime-type'; +import { AssetMeta, Assets, DefaultFactId, Facts, Signature } from '../repositories/proof/proof'; +import { ProofRepository } from '../repositories/proof/proof-repository.service'; import { CollectorService } from './collector.service'; +import { FactsProvider } from './facts/facts-provider'; +import { SignatureProvider } from './signature/signature-provider'; describe('CollectorService', () => { let service: CollectorService; + let proofRepositorySpy: jasmine.SpyObj; beforeEach(() => { + const spy = jasmine.createSpyObj('ProofRepository', ['add']); TestBed.configureTestingModule({ imports: [ SharedTestingModule, getTranslocoModule() + ], + providers: [ + { provide: ProofRepository, useValue: spy } ] }); service = TestBed.inject(CollectorService); + proofRepositorySpy = TestBed.inject(ProofRepository) as jasmine.SpyObj; }); - it('should be created', () => { - expect(service).toBeTruthy(); + it('should be created', () => expect(service).toBeTruthy()); + + it('should get the stored proof after run', async () => { + const proof = await service.runAndStore(ASSETS); + expect(proof.assets).toEqual(ASSETS); + }); + + it('should remove added truth providers', async () => { + service.addFactsProvider(mockFactsProvider); + service.removeFactsProvider(mockFactsProvider); + + const proof = await service.runAndStore(ASSETS); + + expect(proof.truth.providers).toEqual({}); + }); + + it('should remove added signature providers', async () => { + service.addSignatureProvider(mockSignatureProvider); + service.removeSignatureProvider(mockSignatureProvider); + + const proof = await service.runAndStore(ASSETS); + + expect(proof.signatures).toEqual({}); + }); + + it('should get the stored proof with provided facts', async () => { + service.addFactsProvider(mockFactsProvider); + const proof = await service.runAndStore(ASSETS); + expect(proof.truth.providers).toEqual({ [mockFactsProvider.id]: FACTS }); + }); + + it('should get the stored proof with provided signature', async () => { + service.addSignatureProvider(mockSignatureProvider); + const proof = await service.runAndStore(ASSETS); + expect(proof.signatures).toEqual({ [mockSignatureProvider.id]: SIGNATURE }); + }); + + it('should store proof with ProofRepository', async () => { + const proof = await service.runAndStore(ASSETS); + + expect(proofRepositorySpy.add).toHaveBeenCalledWith(proof); }); }); + +const ASSET1_MIMETYPE: MimeType = 'image/png'; +const ASSET1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAYAAAADCAYAAACwAX77AAAABHNCSVQICAgIfAhkiAAAABl0RVh0U29mdHdhcmUAZ25vbWUtc2NyZWVuc2hvdO8Dvz4AAABAaVRYdENyZWF0aW9uIFRpbWUAAAAAADIwMjDlubTljYHkuIDmnIgxMOaXpSAo6YCx5LqMKSAyMOaZgjU55YiGMzfnp5JnJvHNAAAAFUlEQVQImWM0MTH5z4AFMGETxCsBAHRhAaHOZzVQAAAAAElFTkSuQmCC'; +const ASSET1: AssetMeta = { mimeType: ASSET1_MIMETYPE }; +const ASSET2_MIMETYPE: MimeType = 'image/png'; +const ASSET2_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAABHNCSVQICAgIfAhkiAAAABZJREFUCJlj/Pnz538GJMDEgAYICwAAAbkD8p660MIAAAAASUVORK5CYII='; +const ASSET2: AssetMeta = { mimeType: ASSET2_MIMETYPE }; +const ASSETS: Assets = { + [ASSET1_BASE64]: ASSET1, + [ASSET2_BASE64]: ASSET2 +}; + +const GEOLOCATION_LATITUDE = 22.917923; +const GEOLOCATION_LONGITUDE = 120.859958; +const DEVICE_NAME_VALUE = 'Sony Xperia 1'; +const FACTS: Facts = { + [DefaultFactId.GEOLOCATION_LATITUDE]: GEOLOCATION_LATITUDE, + [DefaultFactId.GEOLOCATION_LONGITUDE]: GEOLOCATION_LONGITUDE, + [DefaultFactId.DEVICE_NAME]: DEVICE_NAME_VALUE +}; + +class MockFactsProvider implements FactsProvider { + readonly id = name; + async provide(_: Assets) { return FACTS; } +} + +const mockFactsProvider = new MockFactsProvider(); + +const SIGNATURE_VALUE = '575cbd72438eec799ffc5d78b45d968b65fd4597744d2127cd21556ceb63dff4a94f409d87de8d1f554025efdf56b8445d8d18e661b79754a25f45d05f4e26ac'; +const PUBLIC_KEY = '3059301306072a8648ce3d020106082a8648ce3d03010703420004bc23d419027e59bf1eb94c18bfa4ab5fb6ca8ae83c94dbac5bfdfac39ac8ae16484e23b4d522906c4cd8c7cb1a34cd820fb8d065e1b32c8a28320a68fff243f8'; +const SIGNATURE: Signature = { + signature: SIGNATURE_VALUE, + publicKey: PUBLIC_KEY +}; +class MockSignatureProvider implements SignatureProvider { + readonly id = name; + async provide(_: string) { return SIGNATURE; } +} + +const mockSignatureProvider = new MockSignatureProvider(); diff --git a/src/app/services/collector/collector.service.ts b/src/app/services/collector/collector.service.ts index 96a438aad..0a2ea1746 100644 --- a/src/app/services/collector/collector.service.ts +++ b/src/app/services/collector/collector.service.ts @@ -1,15 +1,10 @@ import { Injectable } from '@angular/core'; import { TranslocoService } from '@ngneat/transloco'; -import { EMPTY } from 'rxjs'; -import { catchError, concatMap, map, mapTo, pluck, switchMap, switchMapTo, tap } from 'rxjs/operators'; -import { fileNameWithoutExtension } from 'src/app/utils/file/file'; -import { MimeType } from 'src/app/utils/mime-type'; -import { forkJoinWithDefault } from 'src/app/utils/rx-operators'; +import { sortObjectDeeplyByKey } from 'src/app/utils/immutable/immutable'; import { NotificationService } from '../notification/notification.service'; -import { PublishersAlert } from '../publisher/publishers-alert/publishers-alert.service'; -import { Proof } from '../repositories/proof/proof'; +import { Assets, Proof, Signatures, SignedTargets, Truth } from '../repositories/proof/proof'; import { ProofRepository } from '../repositories/proof/proof-repository.service'; -import { InformationProvider } from './information/information-provider'; +import { FactsProvider } from './facts/facts-provider'; import { SignatureProvider } from './signature/signature-provider'; @Injectable({ @@ -17,72 +12,54 @@ import { SignatureProvider } from './signature/signature-provider'; }) export class CollectorService { - private readonly informationProviders = new Set(); + private readonly factsProviders = new Set(); private readonly signatureProviders = new Set(); constructor( - private readonly proofRepository: ProofRepository, private readonly notificationService: NotificationService, private readonly translocoService: TranslocoService, - private readonly publishersAlert: PublishersAlert + private readonly proofRepository: ProofRepository ) { } - storeAndCollect(rawBase64: string, mimeType: MimeType) { - // Deliberately subscribe without untilDestroyed scope. Also, it is not feasible to use - // subsctibeInBackground() as it will move the execution out of ngZone, which will prevent - // the observables subscribed with async pipe from observing new values. - this.store$(rawBase64, mimeType).pipe( - concatMap(proof => this.collectAndSign$(proof)), - concatMap(proof => this.publishersAlert.presentOrPublish$(proof)) - ).subscribe(); - } - - private store$(rawBase64: string, mimeType: MimeType) { - return this.proofRepository.saveRawFile$(rawBase64, mimeType).pipe( - // Get the proof hash from the uri. - map(uri => fileNameWithoutExtension(uri)), - // Store the media file. - switchMap(hash => this.proofRepository.add$({ hash, mimeType, timestamp: Date.now() })), - pluck(0) - ); - } - - private collectAndSign$(proof: Proof) { + async runAndStore(assets: Assets) { const notificationId = this.notificationService.createNotificationId(); this.notificationService.notify( notificationId, this.translocoService.translate('collectingProof'), this.translocoService.translate('collectingInformation') ); - return forkJoinWithDefault([...this.informationProviders].map(provider => provider.collectAndStore$(proof))).pipe( - tap(_ => this.notificationService.notify( - notificationId, - this.translocoService.translate('collectingProof'), - this.translocoService.translate('signingProof') - )), - switchMapTo(forkJoinWithDefault([...this.signatureProviders].map(provider => provider.signAndStore$(proof)))), - tap(_ => this.notificationService.cancel(notificationId)), - catchError(error => { - this.notificationService.notifyError(notificationId, error); - return EMPTY; - }), - mapTo(proof) - ); + const truth = await this.collectTruth(assets); + const signatures = await this.signTargets({ assets, truth }); + const proof = new Proof(assets, truth, signatures); + await this.proofRepository.add(proof); + return proof; } - addInformationProvider(...providers: InformationProvider[]) { - providers.forEach(provider => this.informationProviders.add(provider)); + private async collectTruth(assets: Assets): Promise { + return { + timestamp: Date.now(), + providers: Object.fromEntries( + await Promise.all([...this.factsProviders].map( + async (provider) => [provider.id, await provider.provide(assets)] + )) + ) + }; } - removeInformationProvider(...providers: InformationProvider[]) { - providers.forEach(provider => this.informationProviders.delete(provider)); + private async signTargets(target: SignedTargets): Promise { + const serializedSortedSignTargets = JSON.stringify(sortObjectDeeplyByKey(target as any).toJSON()); + return Object.fromEntries( + await Promise.all([...this.signatureProviders].map( + async (provider) => [provider.id, await provider.provide(serializedSortedSignTargets)] + )) + ); } - addSignatureProvider(...providers: SignatureProvider[]) { - providers.forEach(provider => this.signatureProviders.add(provider)); - } + addFactsProvider(provider: FactsProvider) { this.factsProviders.add(provider); } - removeSignatureProvider(...providers: SignatureProvider[]) { - providers.forEach(provider => this.signatureProviders.delete(provider)); - } + removeFactsProvider(provider: FactsProvider) { this.factsProviders.delete(provider); } + + addSignatureProvider(provider: SignatureProvider) { this.signatureProviders.add(provider); } + + removeSignatureProvider(provider: SignatureProvider) { this.signatureProviders.delete(provider); } } diff --git a/src/app/services/collector/facts/capacitor-provider/capacitor-provider.ts b/src/app/services/collector/facts/capacitor-provider/capacitor-provider.ts new file mode 100644 index 000000000..199e71272 --- /dev/null +++ b/src/app/services/collector/facts/capacitor-provider/capacitor-provider.ts @@ -0,0 +1,58 @@ +import { Plugins } from '@capacitor/core'; +import { defer, of, zip } from 'rxjs'; +import { first, map, switchMap } from 'rxjs/operators'; +import { Assets, DefaultFactId, Facts } from 'src/app/services/repositories/proof/proof'; +import { PreferenceManager } from 'src/app/utils/preferences/preference-manager'; +import { FactsProvider } from '../facts-provider'; + +const { Device, Geolocation } = Plugins; +const preferences = PreferenceManager.CAPACITOR_PROVIDER_PREF; +const enum PrefKeys { + CollectDeviceInfo = 'collectDeviceInfo', + CollectLocationInfo = 'collectLocationInfo' +} + +export class CapacitorProvider implements FactsProvider { + readonly id = name; + + static isDeviceInfoCollectionEnabled$() { + return preferences.getBoolean$(PrefKeys.CollectDeviceInfo, true); + } + + static setDeviceInfoCollection$(enable: boolean) { + return preferences.setBoolean$(PrefKeys.CollectDeviceInfo, enable); + } + + static isLocationInfoCollectionEnabled$() { + return preferences.getBoolean$(PrefKeys.CollectLocationInfo, true); + } + + static setLocationInfoCollection$(enable: boolean) { + return preferences.setBoolean$(PrefKeys.CollectLocationInfo, enable); + } + + async provide(_: Assets) { + return zip( + CapacitorProvider.isDeviceInfoCollectionEnabled$(), + CapacitorProvider.isLocationInfoCollectionEnabled$() + ).pipe( + first(), + switchMap(([isDeviceInfoCollectionEnabled, isLocationInfoCollectionEnabled]) => zip( + isDeviceInfoCollectionEnabled ? defer(() => Device.getInfo()) : of(undefined), + isLocationInfoCollectionEnabled ? defer(() => Geolocation.getCurrentPosition({ + enableHighAccuracy: true, + maximumAge: 10 * 60 * 1000, + timeout: 10 * 1000 + })) : of(undefined))), + map(([deviceInfo, geolocationPosition]) => ({ + [DefaultFactId.DEVICE_NAME]: deviceInfo?.model, + [DefaultFactId.GEOLOCATION_LATITUDE]: geolocationPosition?.coords.latitude, + [DefaultFactId.GEOLOCATION_LONGITUDE]: geolocationPosition?.coords.longitude, + UUID: deviceInfo?.uuid, + PLATFORM: deviceInfo?.platform, + OPERATING_SYSTEM: deviceInfo?.operatingSystem, + OS_VERSION: deviceInfo?.osVersion + } as Facts)) + ).toPromise(); + } +} diff --git a/src/app/services/collector/facts/facts-provider.ts b/src/app/services/collector/facts/facts-provider.ts new file mode 100644 index 000000000..e4910ec16 --- /dev/null +++ b/src/app/services/collector/facts/facts-provider.ts @@ -0,0 +1,6 @@ +import { Assets, Facts } from '../../repositories/proof/proof'; + +export interface FactsProvider { + readonly id: string; + provide(assets: Assets): Promise; +} diff --git a/src/app/services/collector/information/capacitor-provider/capacitor-provider.spec.ts b/src/app/services/collector/information/capacitor-provider/capacitor-provider.spec.ts deleted file mode 100644 index 545515ca8..000000000 --- a/src/app/services/collector/information/capacitor-provider/capacitor-provider.spec.ts +++ /dev/null @@ -1 +0,0 @@ -describe('CapacitorProvider', () => { }); diff --git a/src/app/services/collector/information/capacitor-provider/capacitor-provider.ts b/src/app/services/collector/information/capacitor-provider/capacitor-provider.ts deleted file mode 100644 index 8b9d6c46d..000000000 --- a/src/app/services/collector/information/capacitor-provider/capacitor-provider.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Plugins } from '@capacitor/core'; -import { TranslocoService } from '@ngneat/transloco'; -import { defer, Observable, of, zip } from 'rxjs'; -import { first, map, switchMap } from 'rxjs/operators'; -import { Importance, Information, InformationType } from 'src/app/services/repositories/information/information'; -import { InformationRepository } from 'src/app/services/repositories/information/information-repository.service'; -import { Proof } from 'src/app/services/repositories/proof/proof'; -import { PreferenceManager } from 'src/app/utils/preferences/preference-manager'; -import { InformationProvider } from '../information-provider'; - -const { Device, Geolocation } = Plugins; - -const preferences = PreferenceManager.CAPACITOR_PROVIDER_PREF; -const enum PrefKeys { - CollectDeviceInfo = 'collectDeviceInfo', - CollectLocationInfo = 'collectLocationInfo' -} - -export class CapacitorProvider extends InformationProvider { - - static readonly ID = 'capacitor'; - readonly id = CapacitorProvider.ID; - - constructor( - informationRepository: InformationRepository, - private readonly translocoService: TranslocoService - ) { - super(informationRepository); - } - - static isDeviceInfoCollectionEnabled$() { - return preferences.getBoolean$(PrefKeys.CollectDeviceInfo, true); - } - - static setDeviceInfoCollection$(enable: boolean) { - return preferences.setBoolean$(PrefKeys.CollectDeviceInfo, enable); - } - - static isLocationInfoCollectionEnabled$() { - return preferences.getBoolean$(PrefKeys.CollectLocationInfo, true); - } - - static setLocationInfoCollection$(enable: boolean) { - return preferences.setBoolean$(PrefKeys.CollectLocationInfo, enable); - } - - protected provide$(proof: Proof): Observable { - return zip( - CapacitorProvider.isDeviceInfoCollectionEnabled$(), - CapacitorProvider.isLocationInfoCollectionEnabled$() - ).pipe( - first(), - switchMap(([isDeviceInfoCollectionEnabled, isLocationInfoCollectionEnabled]) => zip( - isDeviceInfoCollectionEnabled ? defer(() => Device.getInfo()) : of(undefined), - isDeviceInfoCollectionEnabled ? defer(() => Device.getBatteryInfo()) : of(undefined), - isDeviceInfoCollectionEnabled ? defer(() => Device.getLanguageCode()) : of(undefined), - isLocationInfoCollectionEnabled ? defer(() => Geolocation.getCurrentPosition({ - enableHighAccuracy: true, - maximumAge: 10 * 60 * 1000, - timeout: 10 * 1000 - })) : of(undefined))), - map(([deviceInfo, batteryInfo, languageCode, geolocationPosition]) => { - const informationList: Information[] = []; - if (deviceInfo !== undefined) { - informationList.push({ - proofHash: proof.hash, - provider: this.id, - name: 'UUID', - value: String(deviceInfo.uuid), - importance: Importance.High, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'Device Name', - value: String(deviceInfo.name), - importance: Importance.Low, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'Device Model', - value: String(deviceInfo.model), - importance: Importance.Low, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'Device Platform', - value: String(deviceInfo.platform), - importance: Importance.Low, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'App Version', - value: String(deviceInfo.appVersion), - importance: Importance.Low, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'App VersionCode', - value: String(deviceInfo.appBuild), - importance: Importance.Low, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'Operating System', - value: String(deviceInfo.operatingSystem), - importance: Importance.Low, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'OS Version', - value: String(deviceInfo.osVersion), - importance: Importance.Low, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'Device Manufacturer', - value: String(deviceInfo.manufacturer), - importance: Importance.Low, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'Rrunning on VM', - value: String(deviceInfo.isVirtual), - importance: Importance.Low, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'Used Memory', - value: String(deviceInfo.memUsed), - importance: Importance.Low, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'Free Disk Space', - value: String(deviceInfo.diskFree), - importance: Importance.Low, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'Total Disk Space', - value: String(deviceInfo.diskTotal), - importance: Importance.Low, - type: InformationType.Device - }); - } - if (batteryInfo !== undefined) { - informationList.push({ - proofHash: proof.hash, - provider: this.id, - name: 'Battery Level', - value: String(batteryInfo.batteryLevel), - importance: Importance.Low, - type: InformationType.Device - }, { - proofHash: proof.hash, - provider: this.id, - name: 'Battery Charging', - value: String(batteryInfo.isCharging), - importance: Importance.Low, - type: InformationType.Device - }); - } - if (languageCode !== undefined) { - informationList.push({ - proofHash: proof.hash, - provider: this.id, - name: 'Device Language Code', - value: String(languageCode.value), - importance: Importance.Low, - type: InformationType.Device - }); - } - if (geolocationPosition !== undefined) { - informationList.push({ - proofHash: proof.hash, - provider: this.id, - name: 'Current GPS Latitude', - value: `${geolocationPosition.coords.latitude}`, - importance: Importance.High, - type: InformationType.Location - }, { - proofHash: proof.hash, - provider: this.id, - name: 'Current GPS Longitude', - value: `${geolocationPosition.coords.longitude}`, - importance: Importance.High, - type: InformationType.Location - }); - } - return informationList; - }) - ); - } -} diff --git a/src/app/services/collector/information/information-provider.spec.ts b/src/app/services/collector/information/information-provider.spec.ts deleted file mode 100644 index 3cc39cd85..000000000 --- a/src/app/services/collector/information/information-provider.spec.ts +++ /dev/null @@ -1 +0,0 @@ -describe('InformationProvider', () => { }); diff --git a/src/app/services/collector/information/information-provider.ts b/src/app/services/collector/information/information-provider.ts deleted file mode 100644 index 8ca142e7b..000000000 --- a/src/app/services/collector/information/information-provider.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; -import { Information } from '../../repositories/information/information'; -import { InformationRepository } from '../../repositories/information/information-repository.service'; -import { Proof } from '../../repositories/proof/proof'; - -export abstract class InformationProvider { - - abstract readonly id: string; - - constructor( - private readonly informationRepository: InformationRepository - ) { } - - collectAndStore$(proof: Proof) { - return this.provide$(proof).pipe( - switchMap(information => this.informationRepository.add$(...information)) - ); - } - - protected abstract provide$(proof: Proof): Observable; -} diff --git a/src/app/services/collector/signature/signature-provider.spec.ts b/src/app/services/collector/signature/signature-provider.spec.ts deleted file mode 100644 index 8c8944139..000000000 --- a/src/app/services/collector/signature/signature-provider.spec.ts +++ /dev/null @@ -1 +0,0 @@ -describe('signature-provider', () => { }); diff --git a/src/app/services/collector/signature/signature-provider.ts b/src/app/services/collector/signature/signature-provider.ts index 148a3f3a5..43d77f1fb 100644 --- a/src/app/services/collector/signature/signature-provider.ts +++ b/src/app/services/collector/signature/signature-provider.ts @@ -1,26 +1,6 @@ -import { Observable } from 'rxjs'; -import { pluck, switchMap } from 'rxjs/operators'; -import { Proof } from '../../repositories/proof/proof'; -import { Signature } from '../../repositories/signature/signature'; -import { SignatureRepository } from '../../repositories/signature/signature-repository.service'; -import { SerializationService } from '../../serialization/serialization.service'; +import { Signature } from '../../repositories/proof/proof'; -export abstract class SignatureProvider { - - abstract readonly id: string; - - constructor( - private readonly signatureRepository: SignatureRepository, - private readonly serializationService: SerializationService - ) { } - - signAndStore$(proof: Proof) { - return this.serializationService.stringify$(proof).pipe( - switchMap(serialized => this.provide$(proof, serialized)), - switchMap(signature => this.signatureRepository.add$(signature)), - pluck(0) - ); - } - - protected abstract provide$(proof: Proof, serialized: string): Observable; +export interface SignatureProvider { + readonly id: string; + provide(serializedSortedSignTargets: string): Promise; } diff --git a/src/app/services/collector/signature/web-crypto-api-provider/web-crypto-api-provider.ts b/src/app/services/collector/signature/web-crypto-api-provider/web-crypto-api-provider.ts index 4f8f3ac46..669a8a874 100644 --- a/src/app/services/collector/signature/web-crypto-api-provider/web-crypto-api-provider.ts +++ b/src/app/services/collector/signature/web-crypto-api-provider/web-crypto-api-provider.ts @@ -1,7 +1,6 @@ -import { Observable, of, zip } from 'rxjs'; +import { of, zip } from 'rxjs'; import { filter, first, map, switchMap, switchMapTo } from 'rxjs/operators'; -import { Proof } from 'src/app/services/repositories/proof/proof'; -import { Signature } from 'src/app/services/repositories/signature/signature'; +import { Signature } from 'src/app/services/repositories/proof/proof'; import { createEcKeyPair$, signWithSha256AndEcdsa$ } from 'src/app/utils/crypto/crypto'; import { PreferenceManager } from 'src/app/utils/preferences/preference-manager'; import { SignatureProvider } from '../signature-provider'; @@ -12,10 +11,8 @@ const enum PrefKeys { PrivateKey = 'privateKey' } -export class WebCryptoApiProvider extends SignatureProvider { - - static readonly ID = 'web-crypto-api'; - readonly id = WebCryptoApiProvider.ID; +export class WebCryptoApiProvider implements SignatureProvider { + readonly id = name; static initialize$() { return zip( @@ -40,18 +37,16 @@ export class WebCryptoApiProvider extends SignatureProvider { return preferences.getString$(PrefKeys.PrivateKey); } - protected provide$(proof: Proof, serialized: string): Observable { + async provide(serializedSortedSignTargets: string) { return WebCryptoApiProvider.getPrivateKey$().pipe( first(), - switchMap(privateKeyHex => signWithSha256AndEcdsa$(serialized, privateKeyHex)), + switchMap(privateKeyHex => signWithSha256AndEcdsa$(serializedSortedSignTargets, privateKeyHex)), switchMap(signatureHex => zip(of(signatureHex), WebCryptoApiProvider.getPublicKey$())), first(), map(([signatureHex, publicKeyHex]) => ({ - proofHash: proof.hash, - provider: this.id, signature: signatureHex, publicKey: publicKeyHex - })) - ); + } as Signature)) + ).toPromise(); } } diff --git a/src/app/services/database/database.service.spec.ts b/src/app/services/database/database.service.spec.ts index 0aa24cdb0..eff9d0c69 100644 --- a/src/app/services/database/database.service.spec.ts +++ b/src/app/services/database/database.service.spec.ts @@ -1,6 +1,8 @@ import { TestBed } from '@angular/core/testing'; import { SharedTestingModule } from '../../shared/shared-testing.module'; import { Database } from './database.service'; +import { MemoryTableImpl } from './table/memory-table-impl/memory-table-impl'; +import { TABLE_IMPL } from './table/table'; describe('Database', () => { let service: Database; @@ -9,14 +11,15 @@ describe('Database', () => { TestBed.configureTestingModule({ imports: [ SharedTestingModule + ], + providers: [ + { provide: TABLE_IMPL, useValue: MemoryTableImpl } ] }); service = TestBed.inject(Database); }); - it('should be created', () => { - expect(service).toBeTruthy(); - }); + it('should be created', () => expect(service).toBeTruthy()); it('should get new table with new ID', () => { const id = 'newId'; diff --git a/src/app/services/database/table/capacitor-filesystem-table-impl/capacitor-filesystem-table-impl.ts b/src/app/services/database/table/capacitor-filesystem-table-impl/capacitor-filesystem-table-impl.ts index 73143d0c6..29c277601 100644 --- a/src/app/services/database/table/capacitor-filesystem-table-impl/capacitor-filesystem-table-impl.ts +++ b/src/app/services/database/table/capacitor-filesystem-table-impl/capacitor-filesystem-table-impl.ts @@ -1,6 +1,6 @@ import { FilesystemDirectory, FilesystemEncoding, Plugins } from '@capacitor/core'; import { Mutex } from 'async-mutex'; -import _ from 'lodash'; +import { equals } from 'lodash/fp'; import { BehaviorSubject, defer } from 'rxjs'; import { concatMapTo } from 'rxjs/operators'; import { Table, Tuple } from '../table'; @@ -84,7 +84,7 @@ export class CapacitorFilesystemTableImpl implements Table { const conflicted: T[] = []; tuples.forEach((a, index) => { for (let bIndex = index + 1; bIndex < tuples.length; bIndex++) { - if (_.isEqual(a, tuples[bIndex])) { conflicted.push(a); } + if (equals(a)(tuples[bIndex])) { conflicted.push(a); } } }); if (conflicted.length !== 0) { throw new Error(`Tuples duplicated: ${conflicted}`); } @@ -102,7 +102,7 @@ export class CapacitorFilesystemTableImpl implements Table { const afterDeletion = this.tuples$.value.filter( tuple => !tuples // tslint:disable-next-line: rxjs-no-unsafe-scope - .map(t => _.isEqual(tuple, t)) + .map(t => equals(tuple)(t)) .includes(true) ); this.tuples$.next(afterDeletion); @@ -113,7 +113,7 @@ export class CapacitorFilesystemTableImpl implements Table { private assertTuplesExist(tuples: T[]) { // tslint:disable-next-line: rxjs-no-unsafe-scope - const nonexistent = tuples.filter(tuple => !this.tuples$.value.find(t => _.isEqual(tuple, t))); + const nonexistent = tuples.filter(tuple => !this.tuples$.value.find(t => equals(tuple)(t))); if (nonexistent.length !== 0) { throw new Error(`Cannot delete nonexistent tuples: ${nonexistent}`); } } @@ -151,5 +151,5 @@ export class CapacitorFilesystemTableImpl implements Table { function intersaction(list1: T[], list2: T[]) { // tslint:disable-next-line: rxjs-no-unsafe-scope - return list1.filter(tuple1 => list2.find(tuple2 => _.isEqual(tuple1, tuple2))); + return list1.filter(tuple1 => list2.find(tuple2 => equals(tuple1)(tuple2))); } diff --git a/src/app/services/database/table/memory-table-impl/memory-table-impl.spec.ts b/src/app/services/database/table/memory-table-impl/memory-table-impl.spec.ts index e1d89d7bc..e5ef32f53 100644 --- a/src/app/services/database/table/memory-table-impl/memory-table-impl.spec.ts +++ b/src/app/services/database/table/memory-table-impl/memory-table-impl.spec.ts @@ -4,7 +4,7 @@ import { MemoryTableImpl } from './memory-table-impl'; describe('MemoryTableImpl', () => { let table: Table; const tableId = 'tableId'; - const testTuple1: TestTuple = { + const tuple1: TestTuple = { id: 1, name: 'Rick Sanchez', happy: false, @@ -20,7 +20,7 @@ describe('MemoryTableImpl', () => { city: 'Washington' } }; - const testTuple2: TestTuple = { + const tuple2: TestTuple = { id: 2, name: 'Butter Robot', happy: false, @@ -52,32 +52,32 @@ describe('MemoryTableImpl', () => { it('should emit new query on inserting tuple', async (done) => { - await table.insert([testTuple1]); - await table.insert([testTuple2]); + await table.insert([tuple1]); + await table.insert([tuple2]); table.queryAll$().subscribe(tuples => { - expect(tuples).toEqual([testTuple1, testTuple2]); + expect(tuples).toEqual([tuple1, tuple2]); done(); }); }); it('should throw on inserting same tuple', async () => { - const sameTuple: TestTuple = { ...testTuple1 }; + const sameTuple: TestTuple = { ...tuple1 }; - await expectAsync(table.insert([testTuple1, sameTuple])).toBeRejected(); + await expectAsync(table.insert([tuple1, sameTuple])).toBeRejected(); }); it('should throw on inserting existed tuple', async () => { - const sameTuple: TestTuple = { ...testTuple1 }; - await table.insert([testTuple1]); + const sameTuple: TestTuple = { ...tuple1 }; + await table.insert([tuple1]); await expectAsync(table.insert([sameTuple])).toBeRejected(); }); it('should remove by tuple contents not reference', async (done) => { - const sameTuple: TestTuple = { ...testTuple1 }; + const sameTuple: TestTuple = { ...tuple1 }; - await table.insert([testTuple1]); + await table.insert([tuple1]); await table.delete([sameTuple]); table.queryAll$().subscribe(tuples => { @@ -87,19 +87,19 @@ describe('MemoryTableImpl', () => { }); it('should not emit removed tuples', async (done) => { - const sameTuple1: TestTuple = { ...testTuple1 }; + const sameTuple1: TestTuple = { ...tuple1 }; - await table.insert([testTuple1, testTuple2]); + await table.insert([tuple1, tuple2]); await table.delete([sameTuple1]); table.queryAll$().subscribe(tuples => { - expect(tuples).toEqual([testTuple2]); + expect(tuples).toEqual([tuple2]); done(); }); }); it('should throw on deleting non-existent tuples', async () => { - await expectAsync(table.delete([testTuple1])).toBeRejected(); + await expectAsync(table.delete([tuple1])).toBeRejected(); }); it('inserts atomically', async (done) => { diff --git a/src/app/services/database/table/memory-table-impl/memory-table-impl.ts b/src/app/services/database/table/memory-table-impl/memory-table-impl.ts index 1f7577e17..1968ce762 100644 --- a/src/app/services/database/table/memory-table-impl/memory-table-impl.ts +++ b/src/app/services/database/table/memory-table-impl/memory-table-impl.ts @@ -1,4 +1,4 @@ -import _ from 'lodash'; +import { equals } from 'lodash/fp'; import { BehaviorSubject } from 'rxjs'; import { Table, Tuple } from '../table'; @@ -23,7 +23,7 @@ export class MemoryTableImpl implements Table { const conflicted: T[] = []; tuples.forEach((a, index) => { for (let bIndex = index + 1; bIndex < tuples.length; bIndex++) { - if (_.isEqual(a, tuples[bIndex])) { conflicted.push(a); } + if (equals(a)(tuples[bIndex])) { conflicted.push(a); } } }); if (conflicted.length !== 0) { throw new Error(`Tuples duplicated: ${conflicted}`); } @@ -39,7 +39,7 @@ export class MemoryTableImpl implements Table { const afterDeletion = this.tuples$.value.filter( tuple => !tuples // tslint:disable-next-line: rxjs-no-unsafe-scope - .map(t => _.isEqual(tuple, t)) + .map(t => equals(tuple)(t)) .includes(true) ); this.tuples$.next(afterDeletion); @@ -48,7 +48,7 @@ export class MemoryTableImpl implements Table { private assertTuplesExist(tuples: T[]) { // tslint:disable-next-line: rxjs-no-unsafe-scope - const nonexistent = tuples.filter(tuple => !this.tuples$.value.find(t => _.isEqual(tuple, t))); + const nonexistent = tuples.filter(tuple => !this.tuples$.value.find(t => equals(tuple)(t))); if (nonexistent.length !== 0) { throw new Error(`Cannot delete nonexistent tuples: ${nonexistent}`); } } @@ -57,5 +57,5 @@ export class MemoryTableImpl implements Table { function intersaction(list1: T[], list2: T[]) { // tslint:disable-next-line: rxjs-no-unsafe-scope - return list1.filter(tuple1 => list2.find(tuple2 => _.isEqual(tuple1, tuple2))); + return list1.filter(tuple1 => list2.find(tuple2 => equals(tuple1)(tuple2))); } diff --git a/src/app/services/database/table/table.ts b/src/app/services/database/table/table.ts index 9058f8166..ee30d63f9 100644 --- a/src/app/services/database/table/table.ts +++ b/src/app/services/database/table/table.ts @@ -8,6 +8,6 @@ export interface Table { drop(): Promise; } -export type Tuple = { [key: string]: boolean | number | string | Tuple | Tuple[] | undefined; }; +export type Tuple = { [key: string]: boolean | number | string | Tuple | Tuple[]; }; export const TABLE_IMPL = new InjectionToken>>('TABLE_IMPL'); diff --git a/src/app/services/publisher/numbers-storage/data/asset/asset-repository.service.ts b/src/app/services/publisher/numbers-storage/data/asset/asset-repository.service.ts deleted file mode 100644 index 4f206e653..000000000 --- a/src/app/services/publisher/numbers-storage/data/asset/asset-repository.service.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Injectable } from '@angular/core'; -import { defer } from 'rxjs'; -import { concatMap, concatMapTo, first, map } from 'rxjs/operators'; -import { Database } from 'src/app/services/database/database.service'; -import { CaptionRepository } from 'src/app/services/repositories/caption/caption-repository.service'; -import { Information } from 'src/app/services/repositories/information/information'; -import { InformationRepository } from 'src/app/services/repositories/information/information-repository.service'; -import { Proof } from 'src/app/services/repositories/proof/proof'; -import { ProofRepository } from 'src/app/services/repositories/proof/proof-repository.service'; -import { SignatureRepository } from 'src/app/services/repositories/signature/signature-repository.service'; -import { SerializationService } from 'src/app/services/serialization/serialization.service'; -import { blobToDataUrlWithBase64$ } from 'src/app/utils/encoding/encoding'; -import { forkJoinWithDefault } from 'src/app/utils/rx-operators'; -import { NumbersStorageApi } from '../../numbers-storage-api.service'; -import { NumbersStoragePublisher } from '../../numbers-storage-publisher'; -import { Asset } from './asset'; - -@Injectable({ - providedIn: 'root' -}) -export class AssetRepository { - - private readonly id = `${NumbersStoragePublisher.ID}_asset`; - private readonly table = this.database.getTable(this.id); - - constructor( - private readonly database: Database, - private readonly numbersStorageApi: NumbersStorageApi, - private readonly proofRepository: ProofRepository, - private readonly informationRepository: InformationRepository, - private readonly signatureRepository: SignatureRepository, - private readonly captionRepository: CaptionRepository, - private readonly serializationService: SerializationService - ) { } - - getAll$() { return this.table.queryAll$(); } - - getById$(id: string) { - return this.getAll$().pipe( - map(assets => assets.find(asset => asset.id === id)) - ); - } - - add$(...assets: Asset[]) { return defer(() => this.table.insert(assets)); } - - addFromNumbersStorage$(asset: Asset) { - return this.add$(asset).pipe( - concatMapTo(this.storeProofMedia$(asset)), - concatMapTo(this.addProofAndInformationFromParsedInformation$(this.serializationService.parse(asset.information))), - concatMapTo(this.signatureRepository.add$(...asset.signature)), - concatMapTo(this.captionRepository.addOrEdit$({ proofHash: asset.proof_hash, text: asset.caption })) - ); - } - - private storeProofMedia$(asset: Asset) { - return this.numbersStorageApi.getImage$(asset.asset_file).pipe( - concatMap(blob => blobToDataUrlWithBase64$(blob)), - map(dataUrlWithBase64 => dataUrlWithBase64.split(',')[1]), - concatMap(base64 => this.proofRepository.saveRawFile$(base64, asset.information.proof.mimeType)) - ); - } - - private addProofAndInformationFromParsedInformation$(parsed: { proof: Proof, information: Information[]; }) { - return this.proofRepository.add$(parsed.proof).pipe( - concatMapTo(this.informationRepository.add$(...parsed.information)) - ); - } - - remove$(asset: Asset) { - return defer(() => this.table.delete([asset])).pipe( - concatMapTo(this.proofRepository.removeByHash$(asset.proof_hash)) - ); - } - - removeAll$() { - return this.table.queryAll$().pipe( - concatMap(assets => forkJoinWithDefault(assets.map(asset => this.remove$(asset)))), - first() - ); - } -} diff --git a/src/app/services/publisher/numbers-storage/numbers-storage-api.service.ts b/src/app/services/publisher/numbers-storage/numbers-storage-api.service.ts index 964b1f91b..552d2576d 100644 --- a/src/app/services/publisher/numbers-storage/numbers-storage-api.service.ts +++ b/src/app/services/publisher/numbers-storage/numbers-storage-api.service.ts @@ -5,10 +5,9 @@ import { concatMap, concatMapTo, first, map, pluck } from 'rxjs/operators'; import { dataUrlWithBase64ToBlob$ } from 'src/app/utils/encoding/encoding'; import { PreferenceManager } from 'src/app/utils/preferences/preference-manager'; import { secret } from '../../../../environments/secret'; -import { Proof } from '../../repositories/proof/proof'; -import { Signature } from '../../repositories/signature/signature'; -import { SerializationService } from '../../serialization/serialization.service'; -import { Asset } from './data/asset/asset'; +import { getSortedProofInformation, OldDefaultInformationName, OldSignature, SortedProofInformation } from '../../repositories/proof/old-proof-adapter'; +import { DefaultFactId, Proof } from '../../repositories/proof/proof'; +import { Asset } from './repositories/asset/asset'; export const enum TargetProvider { Numbers = 'Numbers' @@ -29,8 +28,7 @@ const enum PrefKeys { export class NumbersStorageApi { constructor( - private readonly httpClient: HttpClient, - private readonly serializationService: SerializationService + private readonly httpClient: HttpClient ) { } isEnabled$() { @@ -105,20 +103,21 @@ export class NumbersStorageApi { proof: Proof, targetProvider: TargetProvider, caption: string, - signatures: Signature[], + signatures: OldSignature[], tag: string ) { return this.getHttpHeadersWithAuthToken$().pipe( concatMap(headers => zip( dataUrlWithBase64ToBlob$(rawFileBase64), - this.serializationService.stringify$(proof), + getSortedProofInformation(proof), of(headers) )), - concatMap(([rawFile, information, headers]) => { + concatMap(([rawFile, sortedProofInformation, headers]) => { + const oldSortedProofInformation = this.replaceDefaultFactIdWithOldDefaultInformationName(sortedProofInformation); const formData = new FormData(); formData.append('asset_file', rawFile); - formData.append('asset_file_mime_type', proof.mimeType); - formData.append('meta', information); + formData.append('asset_file_mime_type', Object.values(proof.assets)[0].mimeType); + formData.append('meta', JSON.stringify(oldSortedProofInformation)); formData.append('target_provider', targetProvider); formData.append('caption', caption); formData.append('signature', JSON.stringify(signatures)); @@ -128,6 +127,24 @@ export class NumbersStorageApi { ); } + private replaceDefaultFactIdWithOldDefaultInformationName(sortedProofInformation: SortedProofInformation): SortedProofInformation { + return { + proof: sortedProofInformation.proof, + information: sortedProofInformation.information.map(info => { + if (info.name === DefaultFactId.DEVICE_NAME) { + return { provider: info.provider, value: info.value, name: OldDefaultInformationName.DEVICE_NAME }; + } + if (info.name === DefaultFactId.GEOLOCATION_LATITUDE) { + return { provider: info.provider, value: info.value, name: OldDefaultInformationName.GEOLOCATION_LATITUDE }; + } + if (info.name === DefaultFactId.GEOLOCATION_LONGITUDE) { + return { provider: info.provider, value: info.value, name: OldDefaultInformationName.GEOLOCATION_LONGITUDE }; + } + return info; + }) + }; + } + listTransactions$() { return this.getHttpHeadersWithAuthToken$().pipe( concatMap(headers => this.httpClient.get(`${baseUrl}/api/v2/transactions/`, { headers })) @@ -152,8 +169,7 @@ export class NumbersStorageApi { acceptTransaction$(id: string) { return this.getHttpHeadersWithAuthToken$().pipe( - concatMap(headers => this.httpClient.post(`${baseUrl}/api/v2/transactions/${id}/accept/`, {}, { headers })), - concatMap(transaction => this.readAsset$(transaction.asset.id)) + concatMap(headers => this.httpClient.post(`${baseUrl}/api/v2/transactions/${id}/accept/`, {}, { headers })) ); } diff --git a/src/app/services/publisher/numbers-storage/numbers-storage-publisher.ts b/src/app/services/publisher/numbers-storage/numbers-storage-publisher.ts index dafd9de95..b7901973a 100644 --- a/src/app/services/publisher/numbers-storage/numbers-storage-publisher.ts +++ b/src/app/services/publisher/numbers-storage/numbers-storage-publisher.ts @@ -1,14 +1,10 @@ import { TranslocoService } from '@ngneat/transloco'; -import { Observable, zip } from 'rxjs'; -import { concatMap, first, mapTo } from 'rxjs/operators'; import { NotificationService } from '../../notification/notification.service'; -import { CaptionRepository } from '../../repositories/caption/caption-repository.service'; +import { getOldSignatures } from '../../repositories/proof/old-proof-adapter'; import { Proof } from '../../repositories/proof/proof'; -import { ProofRepository } from '../../repositories/proof/proof-repository.service'; -import { SignatureRepository } from '../../repositories/signature/signature-repository.service'; import { Publisher } from '../publisher'; -import { AssetRepository } from './data/asset/asset-repository.service'; import { NumbersStorageApi, TargetProvider } from './numbers-storage-api.service'; +import { AssetRepository } from './repositories/asset/asset-repository.service'; export class NumbersStoragePublisher extends Publisher { @@ -19,9 +15,6 @@ export class NumbersStoragePublisher extends Publisher { constructor( translocoService: TranslocoService, notificationService: NotificationService, - private readonly proofRepository: ProofRepository, - private readonly signatureRepository: SignatureRepository, - private readonly captionRepository: CaptionRepository, private readonly numbersStorageApi: NumbersStorageApi, private readonly assetRepository: AssetRepository ) { @@ -32,23 +25,17 @@ export class NumbersStoragePublisher extends Publisher { return this.numbersStorageApi.isEnabled$(); } - run$(proof: Proof): Observable { - return zip( - this.proofRepository.getRawFile$(proof), - this.signatureRepository.getByProof$(proof), - this.captionRepository.getByProof$(proof), - ).pipe( - first(), - concatMap(([rawFileBase64, signatures, caption]) => this.numbersStorageApi.createAsset$( - `data:${proof.mimeType};base64,${rawFileBase64}`, - proof, - TargetProvider.Numbers, - JSON.stringify(caption ? caption : ''), - signatures, - 'capture-lite' - )), - concatMap(asset => this.assetRepository.add$(asset)), - mapTo(void 0) - ); + async run(proof: Proof) { + const oldSignatures = await getOldSignatures(proof); + const assetResponse = await this.numbersStorageApi.createAsset$( + `data:${Object.values(proof.assets)[0].mimeType};base64,${Object.keys(proof.assets)[0]}`, + proof, + TargetProvider.Numbers, + '', + oldSignatures, + 'capture-lite' + ).toPromise(); + await this.assetRepository.add(assetResponse); + return proof; } } diff --git a/src/app/services/publisher/numbers-storage/data/asset/asset-repository.service.spec.ts b/src/app/services/publisher/numbers-storage/repositories/asset/asset-repository.service.spec.ts similarity index 100% rename from src/app/services/publisher/numbers-storage/data/asset/asset-repository.service.spec.ts rename to src/app/services/publisher/numbers-storage/repositories/asset/asset-repository.service.spec.ts diff --git a/src/app/services/publisher/numbers-storage/repositories/asset/asset-repository.service.ts b/src/app/services/publisher/numbers-storage/repositories/asset/asset-repository.service.ts new file mode 100644 index 000000000..d78b14614 --- /dev/null +++ b/src/app/services/publisher/numbers-storage/repositories/asset/asset-repository.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@angular/core'; +import { defer } from 'rxjs'; +import { concatMap, first, map } from 'rxjs/operators'; +import { Database } from 'src/app/services/database/database.service'; +import { forkJoinWithDefault } from 'src/app/utils/rx-operators'; +import { NumbersStoragePublisher } from '../../numbers-storage-publisher'; +import { Asset } from './asset'; + +@Injectable({ + providedIn: 'root' +}) +export class AssetRepository { + + private readonly id = `${NumbersStoragePublisher.ID}_asset`; + private readonly table = this.database.getTable(this.id); + + constructor( + private readonly database: Database + ) { } + + getAll$() { return this.table.queryAll$(); } + + getById$(id: string) { + return this.getAll$().pipe( + map(assets => assets.find(asset => asset.id === id)) + ); + } + + async add(asset: Asset) { return this.table.insert([asset]); } + + remove$(asset: Asset) { + return defer(() => this.table.delete([asset])); + } + + removeAll$() { + return this.table.queryAll$().pipe( + concatMap(assets => forkJoinWithDefault(assets.map(asset => this.remove$(asset)))), + first() + ); + } +} diff --git a/src/app/services/publisher/numbers-storage/data/asset/asset.ts b/src/app/services/publisher/numbers-storage/repositories/asset/asset.ts similarity index 59% rename from src/app/services/publisher/numbers-storage/data/asset/asset.ts rename to src/app/services/publisher/numbers-storage/repositories/asset/asset.ts index d139b51af..1dd212163 100644 --- a/src/app/services/publisher/numbers-storage/data/asset/asset.ts +++ b/src/app/services/publisher/numbers-storage/repositories/asset/asset.ts @@ -1,15 +1,13 @@ import { Tuple } from 'src/app/services/database/table/table'; -import { Signature } from 'src/app/services/repositories/signature/signature'; -import { SortedProofInformation } from 'src/app/services/serialization/serialization.service'; +import { OldSignature, SortedProofInformation } from 'src/app/services/repositories/proof/old-proof-adapter'; export interface Asset extends Tuple { readonly id: string; readonly proof_hash: string; readonly owner: string; readonly asset_file: string; - readonly asset_file_thumbnail: string; readonly information: SortedProofInformation; - readonly signature: Signature[]; + readonly signature: OldSignature[]; readonly caption: string; readonly uploaded_at: string; readonly is_original_owner: boolean; diff --git a/src/app/services/publisher/numbers-storage/data/ignored-transaction/ignored-transaction-repository.service.spec.ts b/src/app/services/publisher/numbers-storage/repositories/ignored-transaction/ignored-transaction-repository.service.spec.ts similarity index 100% rename from src/app/services/publisher/numbers-storage/data/ignored-transaction/ignored-transaction-repository.service.spec.ts rename to src/app/services/publisher/numbers-storage/repositories/ignored-transaction/ignored-transaction-repository.service.spec.ts diff --git a/src/app/services/publisher/numbers-storage/data/ignored-transaction/ignored-transaction-repository.service.ts b/src/app/services/publisher/numbers-storage/repositories/ignored-transaction/ignored-transaction-repository.service.ts similarity index 100% rename from src/app/services/publisher/numbers-storage/data/ignored-transaction/ignored-transaction-repository.service.ts rename to src/app/services/publisher/numbers-storage/repositories/ignored-transaction/ignored-transaction-repository.service.ts diff --git a/src/app/services/publisher/numbers-storage/data/ignored-transaction/ignored-transaction.ts b/src/app/services/publisher/numbers-storage/repositories/ignored-transaction/ignored-transaction.ts similarity index 100% rename from src/app/services/publisher/numbers-storage/data/ignored-transaction/ignored-transaction.ts rename to src/app/services/publisher/numbers-storage/repositories/ignored-transaction/ignored-transaction.ts diff --git a/src/app/services/publisher/publisher.spec.ts b/src/app/services/publisher/publisher.spec.ts deleted file mode 100644 index b4185b4fc..000000000 --- a/src/app/services/publisher/publisher.spec.ts +++ /dev/null @@ -1 +0,0 @@ -describe('Publisher', () => { }); diff --git a/src/app/services/publisher/publisher.ts b/src/app/services/publisher/publisher.ts index 64b215a71..f277b8712 100644 --- a/src/app/services/publisher/publisher.ts +++ b/src/app/services/publisher/publisher.ts @@ -1,6 +1,5 @@ import { TranslocoService } from '@ngneat/transloco'; -import { Observable, of } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { Observable } from 'rxjs'; import { NotificationService } from '../notification/notification.service'; import { Proof } from '../repositories/proof/proof'; @@ -13,28 +12,30 @@ export abstract class Publisher { private readonly notificationService: NotificationService ) { } - publish(proof: Proof) { + abstract isEnabled$(): Observable; + + async publish(proof: Proof) { const notificationId = this.notificationService.createNotificationId(); - this.notificationService.notify( - notificationId, - this.translocoService.translate('registeringAsset'), - this.translocoService.translate('message.registeringAsset', { hash: proof.hash, publisherName: this.id }) - ); - - // Deliberately subscribe without untilDestroyed scope. Also, it is not feasible to use - // subsctibeInBackground() as it will move the execution out of ngZone, which will prevent - // the observables subscribed with async pipe from observing new values. - this.run$(proof).pipe( - tap(_ => this.notificationService.notify( + try { + this.notificationService.notify( + notificationId, + this.translocoService.translate('registeringAsset'), + this.translocoService.translate('message.registeringAsset', { hash: await proof.getId(), publisherName: this.id }) + ); + + await this.run(proof); + + this.notificationService.notify( notificationId, this.translocoService.translate('assetRegistered'), - this.translocoService.translate('message.assetRegistered', { hash: proof.hash, publisherName: this.id }) - )), - catchError((err: Error) => of(this.notificationService.notifyError(notificationId, err))) - ).subscribe(); - } + this.translocoService.translate('message.assetRegistered', { hash: await proof.getId(), publisherName: this.id }) + ); - abstract isEnabled$(): Observable; + return proof; + } catch (e) { + this.notificationService.notifyError(notificationId, e); + } + } - protected abstract run$(proof: Proof): Observable; + protected abstract async run(proof: Proof): Promise; } diff --git a/src/app/services/publisher/publishers-alert/publishers-alert.service.ts b/src/app/services/publisher/publishers-alert/publishers-alert.service.ts index 3166a1dda..2c00d00a3 100644 --- a/src/app/services/publisher/publishers-alert/publishers-alert.service.ts +++ b/src/app/services/publisher/publishers-alert/publishers-alert.service.ts @@ -22,34 +22,32 @@ export class PublishersAlert { this.publishers.push(publisher); } - presentOrPublish$(proof: Proof) { - return this.getEnabledPublishers$().pipe( - switchMap(publishers => { - if (publishers.length > 1) { - return this.alertController.create({ - header: this.translocoService.translate('selectAPublisher'), - inputs: publishers.map((publisher, index) => ({ - name: publisher.id, - type: 'radio', - label: publisher.id, - value: publisher.id, - checked: index === 0 - })), - buttons: [{ - text: this.translocoService.translate('cancel'), - role: 'cancel' - }, { - text: this.translocoService.translate('ok'), - handler: (name) => this.getPublisherByName(name)?.publish(proof) - }], - mode: 'md' - }).then(alertElement => alertElement.present()); - } else { - publishers[0].publish(proof); - return of(void 0); - } - }) - ); + async presentOrPublish(proof: Proof) { + const publishers = await this.getEnabledPublishers$().toPromise(); + + if (publishers.length > 1) { + const alert = await this.alertController.create({ + header: this.translocoService.translate('selectAPublisher'), + inputs: publishers.map((publisher, index) => ({ + name: publisher.id, + type: 'radio', + label: publisher.id, + value: publisher.id, + checked: index === 0 + })), + buttons: [{ + text: this.translocoService.translate('cancel'), + role: 'cancel' + }, { + text: this.translocoService.translate('ok'), + handler: name => this.getPublisherByName(name)?.publish(proof) + }], + mode: 'md' + }); + alert.present(); + } else { + return publishers[0].publish(proof); + } } private getEnabledPublishers$() { diff --git a/src/app/services/repositories/caption/caption-repository.service.spec.ts b/src/app/services/repositories/caption/caption-repository.service.spec.ts deleted file mode 100644 index 6095524fc..000000000 --- a/src/app/services/repositories/caption/caption-repository.service.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { SharedTestingModule } from 'src/app/shared/shared-testing.module'; -import { CaptionRepository } from './caption-repository.service'; - -describe('CaptionRepository', () => { - let service: CaptionRepository; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - SharedTestingModule - ] - }); - service = TestBed.inject(CaptionRepository); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/src/app/services/repositories/caption/caption-repository.service.ts b/src/app/services/repositories/caption/caption-repository.service.ts deleted file mode 100644 index 123c670db..000000000 --- a/src/app/services/repositories/caption/caption-repository.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Injectable } from '@angular/core'; -import { defer, of } from 'rxjs'; -import { concatMap, concatMapTo, first, map, switchMap } from 'rxjs/operators'; -import { isNonNullable } from 'src/app/utils/rx-operators'; -import { Database } from '../../database/database.service'; -import { Proof } from '../proof/proof'; -import { Caption } from './caption'; - -@Injectable({ - providedIn: 'root' -}) -export class CaptionRepository { - - private readonly id = 'caption'; - private readonly table = this.database.getTable(this.id); - - constructor( - private readonly database: Database - ) { } - - getByProof$(proof: Proof) { - return this.table.queryAll$().pipe( - map(captions => captions.find(caption => caption.proofHash === proof.hash)) - ); - } - - addOrEdit$(value: Caption) { - return this.table.queryAll$().pipe( - first(), - map(captions => captions.find(caption => caption.proofHash === value.proofHash)), - concatMap(found => { - if (found) { return this.remove$(found); } - return of(found); - }), - concatMapTo(defer(() => this.table.insert([value]))) - ); - } - - removeByProof$(proof: Proof) { - return this.getByProof$(proof).pipe( - first(), - isNonNullable(), - switchMap(caption => this.remove$(caption)) - ); - } - - remove$(...captions: Caption[]) { - return defer(() => this.table.delete(captions)); - } -} diff --git a/src/app/services/repositories/caption/caption.ts b/src/app/services/repositories/caption/caption.ts deleted file mode 100644 index a4f2fcb4c..000000000 --- a/src/app/services/repositories/caption/caption.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Tuple } from '../../database/table/table'; - -export interface Caption extends Tuple { - readonly proofHash: string; - readonly text: string; -} diff --git a/src/app/services/repositories/information/information-repository.service.spec.ts b/src/app/services/repositories/information/information-repository.service.spec.ts deleted file mode 100644 index 043839457..000000000 --- a/src/app/services/repositories/information/information-repository.service.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { SharedTestingModule } from 'src/app/shared/shared-testing.module'; -import { InformationRepository } from './information-repository.service'; - -describe('InformationRepository', () => { - let service: InformationRepository; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - SharedTestingModule - ] - }); - service = TestBed.inject(InformationRepository); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/src/app/services/repositories/information/information-repository.service.ts b/src/app/services/repositories/information/information-repository.service.ts deleted file mode 100644 index 837782c4f..000000000 --- a/src/app/services/repositories/information/information-repository.service.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Injectable } from '@angular/core'; -import { defer } from 'rxjs'; -import { first, map, switchMap } from 'rxjs/operators'; -import { Database } from '../../database/database.service'; -import { Proof } from '../proof/proof'; -import { Information } from './information'; - -@Injectable({ - providedIn: 'root' -}) -export class InformationRepository { - - private readonly id = 'information'; - private readonly table = this.database.getTable(this.id); - - constructor( - private readonly database: Database - ) { } - - getByProof$(proof: Proof) { - return this.table.queryAll$().pipe( - map(informationList => informationList.filter(info => info.proofHash === proof.hash)) - ); - } - - add$(...information: Information[]) { return defer(() => this.table.insert(information)); } - - removeByProof$(proof: Proof) { - return this.getByProof$(proof).pipe( - first(), - switchMap(informationList => this.remove$(...informationList)) - ); - } - - remove$(...information: Information[]) { - return defer(() => this.table.delete(information)); - } -} diff --git a/src/app/services/repositories/information/information.ts b/src/app/services/repositories/information/information.ts deleted file mode 100644 index ee6673e0d..000000000 --- a/src/app/services/repositories/information/information.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Tuple } from '../../database/table/table'; - -export const enum Importance { - Low = 'low', - High = 'high' -} - -export const enum InformationType { - Device = 'device', - Location = 'location', - Other = 'other' -} - -export interface Information extends Tuple { - readonly proofHash: string; - readonly provider: string; - readonly name: string; - readonly value: string; - readonly importance: Importance; - readonly type: InformationType; -} diff --git a/src/app/services/repositories/proof/old-proof-adapter.spec.ts b/src/app/services/repositories/proof/old-proof-adapter.spec.ts new file mode 100644 index 000000000..6db878003 --- /dev/null +++ b/src/app/services/repositories/proof/old-proof-adapter.spec.ts @@ -0,0 +1,158 @@ +import { dataUrlWithBase64ToBlob$ } from 'src/app/utils/encoding/encoding'; +import { MimeType } from '../../../utils/mime-type'; +import { AssetMeta, Assets, DefaultFactId, Proof, Signatures, Truth } from '../proof/proof'; +import { getOldProof, getOldSignatures, getProof, getSortedProofInformation, OldSignature, SortedProofInformation } from './old-proof-adapter'; + +describe('old-proof-adapter', () => { + let proof: Proof; + + beforeEach(() => proof = new Proof(ASSETS, TRUTH, SIGNATURES)); + + it('should convert Proof to OldProof', async () => { + const oldProof = await getOldProof(proof); + + expect(oldProof.hash).toEqual(ASSET1_SHA256); + expect(oldProof.mimeType).toEqual(ASSET1_MIMETYPE); + expect(oldProof.timestamp).toEqual(TRUTH.timestamp); + }); + + it('should convert Proof SortedProofInformation', async () => { + const sortedProofInformation = await getSortedProofInformation(proof); + + expect(sortedProofInformation.proof.hash).toEqual(ASSET1_SHA256); + expect(sortedProofInformation.proof.mimeType).toEqual(ASSET1_MIMETYPE); + expect(sortedProofInformation.proof.timestamp).toEqual(TRUTH.timestamp); + sortedProofInformation.information.forEach(({ provider, name }) => { + expect(Object.keys(proof.truth.providers).includes(provider)).toBeTrue(); + expect( + Object.values(proof.truth.providers) + .flatMap(facts => Object.keys(facts)) + .includes(name) + ).toBeTrue(); + }); + }); + + it('should convert Proof to OldSignatures', async () => { + const oldSignatures = await getOldSignatures(proof); + + expect(oldSignatures.length).toEqual(1); + expect(oldSignatures[0].proofHash).toEqual(ASSET1_SHA256); + expect(oldSignatures[0].provider).toEqual(SIGNATURE_PROVIDER_ID); + expect(oldSignatures[0].publicKey).toEqual(PUBLIC_KEY); + expect(oldSignatures[0].signature).toEqual(VALID_SIGNATURE); + }); + + it('should convert SortedProofInformation with raw Blob to Proof', async () => { + const blob = await dataUrlWithBase64ToBlob$(`data:${ASSET1_MIMETYPE};base64,${ASSET1_BASE64}`).toPromise(); + const convertedProof = await getProof(blob, SORTED_PROOF_INFORMATION, OLD_SIGNATURES); + + const assetEntries = Object.entries(convertedProof.assets); + expect(assetEntries.length).toEqual(1); + expect(assetEntries[0][0]).toEqual(ASSET1_BASE64); + expect(assetEntries[0][1].mimeType).toEqual(ASSET1_MIMETYPE); + expect(convertedProof.timestamp).toEqual(TRUTH.timestamp); + expect(convertedProof.deviceName).toEqual(DEVICE_NAME_VALUE1); + expect(convertedProof.geolocationLatitude).toEqual(GEOLOCATION_LATITUDE1); + expect(convertedProof.geolocationLongitude).toEqual(GEOLOCATION_LONGITUDE1); + expect(convertedProof.truth.providers[HUMIDITY]).toBeUndefined(); + expect(convertedProof.signatures).toEqual(SIGNATURES); + }); +}); + +const ASSET1_MIMETYPE: MimeType = 'image/png'; +const ASSET1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAYAAAADCAYAAACwAX77AAAABHNCSVQICAgIfAhkiAAAABl0RVh0U29mdHdhcmUAZ25vbWUtc2NyZWVuc2hvdO8Dvz4AAABAaVRYdENyZWF0aW9uIFRpbWUAAAAAADIwMjDlubTljYHkuIDmnIgxMOaXpSAo6YCx5LqMKSAyMOaZgjU55YiGMzfnp5JnJvHNAAAAFUlEQVQImWM0MTH5z4AFMGETxCsBAHRhAaHOZzVQAAAAAElFTkSuQmCC'; +const ASSET1_SHA256 = '0e87c3cdb045ae9c4a10f63cc615ee4bbf0f2ff9dca6201f045a4cb276cf3122'; +const ASSET1_META: AssetMeta = { mimeType: ASSET1_MIMETYPE }; +const ASSET2_MIMETYPE: MimeType = 'image/png'; +const ASSET2_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAABHNCSVQICAgIfAhkiAAAABZJREFUCJlj/Pnz538GJMDEgAYICwAAAbkD8p660MIAAAAASUVORK5CYII='; +const ASSET2_META: AssetMeta = { mimeType: ASSET2_MIMETYPE }; +const ASSETS: Assets = { + [ASSET1_BASE64]: ASSET1_META, + [ASSET2_BASE64]: ASSET2_META +}; +const INFO_SNAPSHOT = 'INFO_SNAPSHOT'; +const CAPACITOR = 'CAPACITOR'; +const GEOLOCATION_LATITUDE1 = 22.917923; +const GEOLOCATION_LATITUDE2 = 23.000213; +const GEOLOCATION_LONGITUDE1 = 120.859958; +const GEOLOCATION_LONGITUDE2 = 120.868472; +const DEVICE_NAME_VALUE1 = 'Sony Xperia 1'; +const DEVICE_NAME_VALUE2 = 'xperia1'; +const HUMIDITY = 'HUMIDITY'; +const HUMIDITY_VALUE = 0.8; +const TIMESTAMP = 1605013013193; +const TRUTH: Truth = { + timestamp: TIMESTAMP, + providers: { + [INFO_SNAPSHOT]: { + [DefaultFactId.GEOLOCATION_LATITUDE]: GEOLOCATION_LATITUDE1, + [DefaultFactId.GEOLOCATION_LONGITUDE]: GEOLOCATION_LONGITUDE1, + [DefaultFactId.DEVICE_NAME]: DEVICE_NAME_VALUE1 + }, + [CAPACITOR]: { + [DefaultFactId.GEOLOCATION_LATITUDE]: GEOLOCATION_LATITUDE2, + [DefaultFactId.GEOLOCATION_LONGITUDE]: GEOLOCATION_LONGITUDE2, + [DefaultFactId.DEVICE_NAME]: DEVICE_NAME_VALUE2, + [HUMIDITY]: HUMIDITY_VALUE + } + } +}; +const SIGNATURE_PROVIDER_ID = 'CAPTURE'; +const VALID_SIGNATURE = '575cbd72438eec799ffc5d78b45d968b65fd4597744d2127cd21556ceb63dff4a94f409d87de8d1f554025efdf56b8445d8d18e661b79754a25f45d05f4e26ac'; +const PUBLIC_KEY = '3059301306072a8648ce3d020106082a8648ce3d03010703420004bc23d419027e59bf1eb94c18bfa4ab5fb6ca8ae83c94dbac5bfdfac39ac8ae16484e23b4d522906c4cd8c7cb1a34cd820fb8d065e1b32c8a28320a68fff243f8'; +const SIGNATURES: Signatures = { + [SIGNATURE_PROVIDER_ID]: { + signature: VALID_SIGNATURE, + publicKey: PUBLIC_KEY + } +}; +const SORTED_PROOF_INFORMATION: SortedProofInformation = { + proof: { + hash: ASSET1_SHA256, + mimeType: ASSET1_MIMETYPE, + timestamp: TRUTH.timestamp + }, + information: [ + { + provider: INFO_SNAPSHOT, + name: DefaultFactId.GEOLOCATION_LATITUDE, + value: `${GEOLOCATION_LATITUDE1}` + }, + { + provider: INFO_SNAPSHOT, + name: DefaultFactId.GEOLOCATION_LONGITUDE, + value: `${GEOLOCATION_LONGITUDE1}` + }, + { + provider: INFO_SNAPSHOT, + name: DefaultFactId.DEVICE_NAME, + value: `${DEVICE_NAME_VALUE1}` + }, + { + provider: CAPACITOR, + name: DefaultFactId.GEOLOCATION_LATITUDE, + value: `${GEOLOCATION_LATITUDE2}` + }, + { + provider: CAPACITOR, + name: DefaultFactId.GEOLOCATION_LONGITUDE, + value: `${GEOLOCATION_LATITUDE2}` + }, + { + provider: CAPACITOR, + name: DefaultFactId.DEVICE_NAME, + value: `${DEVICE_NAME_VALUE2}` + }, + { + provider: CAPACITOR, + name: HUMIDITY, + value: `${HUMIDITY_VALUE}` + } + ] +}; +const OLD_SIGNATURES: OldSignature[] = [{ + provider: SIGNATURE_PROVIDER_ID, + signature: VALID_SIGNATURE, + publicKey: PUBLIC_KEY, + proofHash: ASSET1_SHA256 +}]; diff --git a/src/app/services/repositories/proof/old-proof-adapter.ts b/src/app/services/repositories/proof/old-proof-adapter.ts new file mode 100644 index 000000000..ff8cadd21 --- /dev/null +++ b/src/app/services/repositories/proof/old-proof-adapter.ts @@ -0,0 +1,145 @@ +import { flow, groupBy, mapValues } from 'lodash/fp'; +import { sha256WithBase64$ } from 'src/app/utils/crypto/crypto'; +import { blobToDataUrlWithBase64$ } from 'src/app/utils/encoding/encoding'; +import { MimeType } from 'src/app/utils/mime-type'; +import { Tuple } from '../../database/table/table'; +import { Proof, Signature } from './proof'; + +/** + * Only for migration and connection to backend. Subject to change. + * Proof = OldProof + Information + Signatures + * Proof can generate: + * 1. OldProof + * 2. Information List + * 3. Signatures + * 4. SortedProofInformation + * 5. EssentialInformation List + */ + +export async function getOldProof(proof: Proof): Promise { + const oldProofData = Object.entries(proof.assets)[0]; + return { + mimeType: oldProofData[1].mimeType, + timestamp: proof.timestamp, + hash: await sha256WithBase64$(oldProofData[0]).toPromise() + }; +} + +export async function getSortedProofInformation(proof: Proof): Promise { + return { + proof: await getOldProof(proof), + information: createSortedProofInformation(proof) + }; +} + +function createSortedProofInformation(proof: Proof) { + return Object.entries(proof.truth.providers) + .flatMap(([providerId, facts]) => Object.entries(facts).map(([id, value]) => ({ + provider: providerId, + name: id, + value: `${value}` + } as OldEssentialInformation))).sort((a, b) => { + const providerCompared = a.provider.localeCompare(b.provider); + const nameCompared = a.name.localeCompare(b.name); + const valueCompared = a.value.localeCompare(b.value); + + if (providerCompared !== 0) { return providerCompared; } + if (nameCompared !== 0) { return nameCompared; } + return valueCompared; + }); +} + +export async function getOldSignatures(proof: Proof): Promise { + const assetHash = await sha256WithBase64$(Object.entries(proof.assets)[0][0]).toPromise(); + return Object.entries(proof.signatures) + .map(([provider, { signature, publicKey }]) => ({ provider, proofHash: assetHash, signature, publicKey })); +} + +export async function getProof( + raw: Blob, + sortedProofInformation: SortedProofInformation, + oldSignatures: OldSignature[]): Promise { + const base64 = (await blobToDataUrlWithBase64$(raw).toPromise()).split(',')[1]; + const groupedByProvider = groupObjectsBy(sortedProofInformation.information, 'provider'); + const providers = flow( + mapValues((value: Record[]) => groupObjectsBy(value, 'name')), + mapValues((value: Record) => + mapValues((arr: { value: string; }[]) => toNumberOrBoolean(arr[0].value))(value) + ), + )(groupedByProvider); + const signatures = flow( + mapValues((values: Signature[]) => ({ signature: values[0].signature, publicKey: values[0].publicKey })) + )(groupObjectsBy(oldSignatures, 'provider')); + + return new Proof( + { [base64]: { mimeType: raw.type as MimeType } }, + { timestamp: sortedProofInformation.proof.timestamp, providers }, + signatures + ); +} + +/** + * Group by the key. The returned collection does not have the original key property. + */ +function groupObjectsBy>(objects: T[], key: string): Record[]> { + return flow( + groupBy(key), + mapValues((values: T[]) => values.map(value => { + delete value[key]; + return value; + })) + )(objects); +} + +function toNumberOrBoolean(str: string) { + if (str === 'true') { return true; } + if (str === 'false') { return false; } + if (!Number.isNaN(Number(str))) { return Number(str); } + return str; +} + +export interface OldProof extends Tuple { + readonly hash: string; + readonly mimeType: MimeType; + readonly timestamp: number; +} + +export type OldEssentialInformation = Pick; + +export interface SortedProofInformation extends Tuple { + readonly proof: OldProof; + readonly information: OldEssentialInformation[]; +} + +export const enum OldInformationImportance { + Low = 'low', + High = 'high' +} + +export const enum OldInformationType { + Device = 'device', + Location = 'location', + Other = 'other' +} + +export interface OldInformation extends Tuple { + readonly proofHash: string; + readonly provider: string; + readonly name: string; + readonly value: string; + readonly importance: OldInformationImportance; + readonly type: OldInformationType; +} + +export const enum OldDefaultInformationName { + DEVICE_NAME = 'Device Name', + GEOLOCATION_LATITUDE = 'Current GPS Latitude', + GEOLOCATION_LONGITUDE = 'Current GPS Longitude' +} + +export interface OldSignature extends Tuple { + readonly proofHash: string; + readonly provider: string; + readonly signature: string; + readonly publicKey: string; +} diff --git a/src/app/services/repositories/proof/proof-repository.service.spec.ts b/src/app/services/repositories/proof/proof-repository.service.spec.ts index 1dbb9e9ca..fc6b24645 100644 --- a/src/app/services/repositories/proof/proof-repository.service.spec.ts +++ b/src/app/services/repositories/proof/proof-repository.service.spec.ts @@ -1,9 +1,13 @@ import { TestBed } from '@angular/core/testing'; import { SharedTestingModule } from 'src/app/shared/shared-testing.module'; +import { MimeType } from 'src/app/utils/mime-type'; +import { AssetMeta, Assets, DefaultFactId, Proof, Signatures, Truth } from './proof'; import { ProofRepository } from './proof-repository.service'; describe('ProofRepository', () => { - let service: ProofRepository; + let repo: ProofRepository; + let proof1: Proof; + let proof2: Proof; beforeEach(() => { TestBed.configureTestingModule({ @@ -11,10 +15,95 @@ describe('ProofRepository', () => { SharedTestingModule ] }); - service = TestBed.inject(ProofRepository); + repo = TestBed.inject(ProofRepository); + proof1 = new Proof(PROOF1_ASSETS, PROOF1_TRUTH, PROOF1_SIGNATURES_VALID); + proof2 = new Proof(PROOF2_ASSETS, PROOF2_TRUTH, PROOF2_SIGNATURES_INVALID); }); - it('should be created', () => { - expect(service).toBeTruthy(); + it('should be created', () => expect(repo).toBeTruthy()); + + it('should get empty array when query on initial status', done => { + repo.getAll$().subscribe(proofs => { + expect(proofs).toEqual([]); + done(); + }); + }); + + it('should emit new query on adding proof', async (done) => { + await repo.add(proof1); + + repo.getAll$().subscribe(proofs => { + expect(proofs).toEqual([proof1]); + done(); + }); + }); + + it('should not emit removed proofs', async (done) => { + await repo.add(proof1); + await repo.add(proof2); + const sameProof1 = new Proof(PROOF1_ASSETS, PROOF1_TRUTH, PROOF1_SIGNATURES_VALID); + + await repo.remove(sameProof1); + + repo.getAll$().subscribe(proofs => { + expect(proofs).toEqual([proof2]); + done(); + }); }); }); + +const ASSET1_MIMETYPE: MimeType = 'image/png'; +const ASSET1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAYAAAADCAYAAACwAX77AAAABHNCSVQICAgIfAhkiAAAABl0RVh0U29mdHdhcmUAZ25vbWUtc2NyZWVuc2hvdO8Dvz4AAABAaVRYdENyZWF0aW9uIFRpbWUAAAAAADIwMjDlubTljYHkuIDmnIgxMOaXpSAo6YCx5LqMKSAyMOaZgjU55YiGMzfnp5JnJvHNAAAAFUlEQVQImWM0MTH5z4AFMGETxCsBAHRhAaHOZzVQAAAAAElFTkSuQmCC'; +const ASSET1: AssetMeta = { mimeType: ASSET1_MIMETYPE }; +const ASSET2_MIMETYPE: MimeType = 'image/png'; +const ASSET2_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAABHNCSVQICAgIfAhkiAAAABZJREFUCJlj/Pnz538GJMDEgAYICwAAAbkD8p660MIAAAAASUVORK5CYII='; +const ASSET2: AssetMeta = { mimeType: ASSET2_MIMETYPE }; +const PROOF1_ASSETS: Assets = { [ASSET1_BASE64]: ASSET1 }; +const PROOF2_ASSETS: Assets = { [ASSET2_BASE64]: ASSET2 }; +const INFO_SNAPSHOT = 'INFO_SNAPSHOT'; +const CAPACITOR = 'CAPACITOR'; +const GEOLOCATION_LATITUDE1 = 22.917923; +const GEOLOCATION_LATITUDE2 = 23.000213; +const GEOLOCATION_LONGITUDE1 = 120.859958; +const GEOLOCATION_LONGITUDE2 = 120.868472; +const DEVICE_NAME_VALUE1 = 'Sony Xperia 1'; +const DEVICE_NAME_VALUE2 = 'xperia1'; +const HUMIDITY = 'HUMIDITY'; +const HUMIDITY_VALUE = 0.8; +const TIMESTAMP = 1605013013193; +const PROOF1_TRUTH: Truth = { + timestamp: TIMESTAMP, + providers: { + [INFO_SNAPSHOT]: { + [DefaultFactId.GEOLOCATION_LATITUDE]: GEOLOCATION_LATITUDE1, + [DefaultFactId.GEOLOCATION_LONGITUDE]: GEOLOCATION_LONGITUDE1, + [DefaultFactId.DEVICE_NAME]: DEVICE_NAME_VALUE1 + }, + [CAPACITOR]: { + [DefaultFactId.GEOLOCATION_LATITUDE]: GEOLOCATION_LATITUDE2, + [DefaultFactId.GEOLOCATION_LONGITUDE]: GEOLOCATION_LONGITUDE2, + [DefaultFactId.DEVICE_NAME]: DEVICE_NAME_VALUE2, + [HUMIDITY]: HUMIDITY_VALUE + } + } +}; +const PROOF2_TRUTH: Truth = { + timestamp: TIMESTAMP, + providers: {} +}; +const SIGNATURE_PROVIDER_ID = 'CAPTURE'; +const VALID_SIGNATURE = '7163c668f0a0210b2406045eb42c5e4c9cdc2bb5904dd852813fcb3aebeb6fafa1e3af6213724764b819f0240587f5fccfadc90b537f6c4b4948801c63331c6d'; +const PUBLIC_KEY = '3059301306072a8648ce3d020106082a8648ce3d0301070342000456103d481de5f8dfc854adfc4b6441d03a83f3689ac9ac85cd570293a69c321a6c11c3481db320a186c546dbc3aae62ee7783a13a7fde3e0d1f55fa0d1d79981'; +const PROOF1_SIGNATURES_VALID: Signatures = { + [SIGNATURE_PROVIDER_ID]: { + signature: VALID_SIGNATURE, + publicKey: PUBLIC_KEY + } +}; +const INVALID_SIGNATURE = '5d9192a66e2e2b4d22ce69dae407618eb6e052a86bb236bec11a7c154ffe20c0604e392378288340317d169219dfe063c504ed27ea2f47d9ec3868206b1d7f73'; +const PROOF2_SIGNATURES_INVALID: Signatures = { + [SIGNATURE_PROVIDER_ID]: { + signature: INVALID_SIGNATURE, + publicKey: PUBLIC_KEY + } +}; diff --git a/src/app/services/repositories/proof/proof-repository.service.ts b/src/app/services/repositories/proof/proof-repository.service.ts index e07bced73..c19687988 100644 --- a/src/app/services/repositories/proof/proof-repository.service.ts +++ b/src/app/services/repositories/proof/proof-repository.service.ts @@ -1,133 +1,39 @@ import { Injectable } from '@angular/core'; -import { FilesystemDirectory, Plugins } from '@capacitor/core'; -import { defer, zip } from 'rxjs'; -import { concatMap, map, pluck, switchMap, switchMapTo } from 'rxjs/operators'; -import { sha256WithBase64$ } from 'src/app/utils/crypto/crypto'; -import { blobToDataUrlWithBase64$, dataUrlWithBase64ToBlob$ } from 'src/app/utils/encoding/encoding'; -import { getExtension, MimeType } from 'src/app/utils/mime-type'; -import { forkJoinWithDefault, isNonNullable } from 'src/app/utils/rx-operators'; +import { map } from 'rxjs/operators'; import { Database } from '../../database/database.service'; -import { CaptionRepository } from '../caption/caption-repository.service'; -import { InformationRepository } from '../information/information-repository.service'; -import { SignatureRepository } from '../signature/signature-repository.service'; +import { Tuple } from '../../database/table/table'; import { Proof } from './proof'; -const { Filesystem } = Plugins; -// @ts-ignore -const ImageBlobReduce = require('image-blob-reduce')(); - @Injectable({ providedIn: 'root' }) export class ProofRepository { private readonly id = 'proof'; - private readonly table = this.database.getTable(this.id); - private readonly rawFileDir = FilesystemDirectory.Data; - private readonly rawFileFolderName = 'raw'; - private readonly thumbnailFileDir = FilesystemDirectory.Data; - private readonly thumbnailFileFolderName = 'thumb'; - private readonly thumbnailSize = 200; + private readonly table = this.database.getTable(this.id); constructor( - private readonly database: Database, - private readonly captionRepository: CaptionRepository, - private readonly informationRepository: InformationRepository, - private readonly signatureRepository: SignatureRepository + private readonly database: Database ) { } - getAll$() { return this.table.queryAll$(); } - - getByHash$(hash: string) { - return this.getAll$().pipe( - map(proofList => proofList.find(proof => proof.hash === hash)) - ); - } - - add$(...proofs: Proof[]) { return defer(() => this.table.insert(proofs)); } - - remove$(...proofs: Proof[]) { - return defer(() => this.table.delete(proofs)).pipe( - switchMapTo(forkJoinWithDefault(proofs.map(proof => this.deleteRawFile$(proof)))), - switchMapTo(forkJoinWithDefault(proofs.map(proof => this.deleteThumbnail$(proof)))), - switchMapTo(forkJoinWithDefault(proofs.map(proof => this.captionRepository.removeByProof$(proof)))), - switchMapTo(forkJoinWithDefault(proofs.map(proof => this.informationRepository.removeByProof$(proof)))), - switchMapTo(forkJoinWithDefault(proofs.map(proof => this.signatureRepository.removeByProof$(proof)))) - ); - } - - removeByHash$(hash: string) { - return this.getByHash$(hash).pipe( - isNonNullable(), - concatMap(proof => this.remove$(proof)) - ); - } - - getRawFile$(proof: Proof) { - return defer(() => Filesystem.readFile({ - path: `${this.rawFileFolderName}/${proof.hash}.${getExtension(proof.mimeType)}`, - directory: this.rawFileDir - })).pipe(pluck('data')); - } - - /** - * Copy [rawBase64] to add raw file to internal storage. - * @param rawBase64 The original source of raw file which will be copied. - * @param mimeType The file added in the internal storage. The name of the returned file will be its hash with original extension. - */ - saveRawFile$(rawBase64: string, mimeType: MimeType) { - return zip( - this._saveRawFile$(rawBase64, mimeType), - this.generateAndSaveThumbnailFile$(rawBase64, mimeType) - ).pipe( - map(([rawUri, _]) => rawUri) - ); - } - - private _saveRawFile$(rawBase64: string, mimeType: MimeType) { - return sha256WithBase64$(rawBase64).pipe( - switchMap(hash => Filesystem.writeFile({ - path: `${this.rawFileFolderName}/${hash}.${getExtension(mimeType)}`, - data: rawBase64, - directory: this.rawFileDir, - recursive: true - })), - pluck('uri') + getAll$() { + return this.table.queryAll$().pipe( + map(stringifiedProofs => stringifiedProofs.map(({ stringified }) => stringified)), + map(stringifieds => stringifieds.map(stringified => Proof.parse(stringified))) ); } - private deleteRawFile$(proof: Proof) { - return defer(() => Filesystem.deleteFile({ - path: `${this.rawFileFolderName}/${proof.hash}.${getExtension(proof.mimeType)}`, - directory: this.rawFileDir - })); + async add(proof: Proof) { + await this.table.insert([{ stringified: proof.stringify() }]); + return proof; } - getThumbnail$(proof: Proof) { - return defer(() => Filesystem.readFile({ - path: `${this.thumbnailFileFolderName}/${proof.hash}.${getExtension(proof.mimeType)}`, - directory: this.thumbnailFileDir - })).pipe(pluck('data')); - } - - private generateAndSaveThumbnailFile$(rawBase64: string, mimeType: MimeType) { - return dataUrlWithBase64ToBlob$(`data:${mimeType};base64,${rawBase64}`).pipe( - switchMap(rawImageBlob => ImageBlobReduce.toBlob(rawImageBlob, { max: this.thumbnailSize })), - switchMap(thumbnailBlob => zip(blobToDataUrlWithBase64$(thumbnailBlob as Blob), sha256WithBase64$(rawBase64))), - switchMap(([thumbnailBase64, hash]) => Filesystem.writeFile({ - path: `${this.thumbnailFileFolderName}/${hash}.${getExtension(mimeType)}`, - data: thumbnailBase64, - directory: this.thumbnailFileDir, - recursive: true - })), - pluck('uri') - ); + async remove(proof: Proof) { + await this.table.delete([{ stringified: proof.stringify() }]); + return proof; } +} - private deleteThumbnail$(proof: Proof) { - return defer(() => Filesystem.deleteFile({ - path: `${this.thumbnailFileFolderName}/${proof.hash}.${getExtension(proof.mimeType)}`, - directory: this.thumbnailFileDir - })); - } +interface StringifiedProof extends Tuple { + stringified: string; } diff --git a/src/app/services/repositories/proof/proof.spec.ts b/src/app/services/repositories/proof/proof.spec.ts new file mode 100644 index 000000000..61382dbbe --- /dev/null +++ b/src/app/services/repositories/proof/proof.spec.ts @@ -0,0 +1,212 @@ +import { verifyWithSha256AndEcdsa$ } from 'src/app/utils/crypto/crypto'; +import { MimeType } from 'src/app/utils/mime-type'; +import { AssetMeta, Assets, DefaultFactId, Proof, Signatures, Truth } from './proof'; + +describe('Proof', () => { + let proof: Proof; + + beforeAll(() => Proof.registerSignatureProvider( + SIGNATURE_PROVIDER_ID, + { verify: (message, signature, publicKey) => verifyWithSha256AndEcdsa$(message, signature, publicKey).toPromise() } + )); + + afterAll(() => Proof.unregisterSignatureProvider(SIGNATURE_PROVIDER_ID)); + + it('should get the same assets with the parameter of factory method', () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + expect(proof.assets).toEqual(ASSETS); + }); + + it('should get the same truth with the parameter of factory method', () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + expect(proof.truth).toEqual(TRUTH); + }); + + it('should get the same signatures with the parameter of factory method', () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + expect(proof.signatures).toEqual(SIGNATURES_VALID); + }); + + it('should get the same timestamp with the truth in the parameter of factory method', () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + expect(proof.timestamp).toEqual(TRUTH.timestamp); + }); + + it('should get same ID with same properties', async () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + const another = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + expect(await proof.getId()).toEqual(await another.getId()); + }); + + it('should have thumbnail when its assets have images', async () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + expect(await proof.getThumbnailDataUrl()).toBeTruthy(); + }); + + it('should not have thumbnail when its assets do not have image', async () => { + proof = new Proof( + { aGVsbG8K: { mimeType: 'application/octet-stream' } }, + TRUTH, + SIGNATURES_VALID + ); + expect(await proof.getThumbnailDataUrl()).toBeUndefined(); + }); + + it('should get any device name when exists', () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + expect( + proof.deviceName === DEVICE_NAME_VALUE1 + || proof.deviceName === DEVICE_NAME_VALUE2 + ).toBeTrue(); + }); + + it('should get undefined when device name not exists', () => { + proof = new Proof(ASSETS, TRUTH_EMPTY, SIGNATURES_VALID); + expect(proof.deviceName).toBeUndefined(); + }); + + it('should get any geolocation latitude when exists', () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + expect( + proof.geolocationLatitude === GEOLOCATION_LATITUDE1 + || proof.geolocationLatitude === GEOLOCATION_LATITUDE2 + ).toBeTrue(); + }); + + it('should get undefined when geolocation latitude not exists', () => { + proof = new Proof(ASSETS, TRUTH_EMPTY, SIGNATURES_VALID); + expect(proof.geolocationLatitude).toBeUndefined(); + }); + + it('should get any geolocation longitude name when exists', () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + expect( + proof.geolocationLongitude === GEOLOCATION_LONGITUDE1 + || proof.geolocationLongitude === GEOLOCATION_LONGITUDE2 + ).toBeTrue(); + }); + + it('should get undefined when geolocation longitude not exists', () => { + proof = new Proof(ASSETS, TRUTH_EMPTY, SIGNATURES_VALID); + expect(proof.geolocationLongitude).toBeUndefined(); + }); + + it('should get existed fact with ID', () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + expect(proof.getFactValue(HUMIDITY)).toEqual(HUMIDITY_VALUE); + }); + + it('should get undefined with nonexistent fact ID', () => { + const NONEXISTENT = 'NONEXISTENT'; + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + expect(proof.getFactValue(NONEXISTENT)).toBeUndefined(); + }); + + it('should stringify to ordered JSON string', () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + const ASSETS_DIFFERENT_ORDER: Assets = { + [ASSET2_BASE64]: ASSET2_META, + [ASSET1_BASE64]: { mimeType: ASSET1_MIMETYPE } + }; + const TRUTH_DIFFERENT_ORDER: Truth = { + providers: { + [CAPACITOR]: { + [HUMIDITY]: HUMIDITY_VALUE, + [DefaultFactId.GEOLOCATION_LONGITUDE]: GEOLOCATION_LONGITUDE2, + [DefaultFactId.GEOLOCATION_LATITUDE]: GEOLOCATION_LATITUDE2, + [DefaultFactId.DEVICE_NAME]: DEVICE_NAME_VALUE2 + }, + [INFO_SNAPSHOT]: { + [DefaultFactId.GEOLOCATION_LONGITUDE]: GEOLOCATION_LONGITUDE1, + [DefaultFactId.DEVICE_NAME]: DEVICE_NAME_VALUE1, + [DefaultFactId.GEOLOCATION_LATITUDE]: GEOLOCATION_LATITUDE1 + } + }, + timestamp: TIMESTAMP + }; + const proofWithDifferentContentsOrder = new Proof( + ASSETS_DIFFERENT_ORDER, + TRUTH_DIFFERENT_ORDER, + SIGNATURES_VALID + ); + expect(proof.stringify()).toEqual(proofWithDifferentContentsOrder.stringify()); + }); + + it('should parse from stringified JSON string', () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + + const parsed = Proof.parse(proof.stringify()); + + expect(parsed.assets).toEqual(ASSETS); + expect(parsed.truth).toEqual(TRUTH); + expect(parsed.signatures).toEqual(SIGNATURES_VALID); + }); + + it('should be verified with valid signatures', async () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_VALID); + expect(await proof.isVerified()).toBeTrue(); + }); + + it('should not be verified with invalid signatures', async () => { + proof = new Proof(ASSETS, TRUTH, SIGNATURES_INVALID); + expect(await proof.isVerified()).toBeFalse(); + }); +}); + +const ASSET1_MIMETYPE: MimeType = 'image/png'; +const ASSET1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAYAAAADCAYAAACwAX77AAAABHNCSVQICAgIfAhkiAAAABl0RVh0U29mdHdhcmUAZ25vbWUtc2NyZWVuc2hvdO8Dvz4AAABAaVRYdENyZWF0aW9uIFRpbWUAAAAAADIwMjDlubTljYHkuIDmnIgxMOaXpSAo6YCx5LqMKSAyMOaZgjU55YiGMzfnp5JnJvHNAAAAFUlEQVQImWM0MTH5z4AFMGETxCsBAHRhAaHOZzVQAAAAAElFTkSuQmCC'; +const ASSET1_META: AssetMeta = { mimeType: ASSET1_MIMETYPE }; +const ASSET2_MIMETYPE: MimeType = 'image/png'; +const ASSET2_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAABHNCSVQICAgIfAhkiAAAABZJREFUCJlj/Pnz538GJMDEgAYICwAAAbkD8p660MIAAAAASUVORK5CYII='; +const ASSET2_META: AssetMeta = { mimeType: ASSET2_MIMETYPE }; +const ASSETS: Assets = { + [ASSET1_BASE64]: ASSET1_META, + [ASSET2_BASE64]: ASSET2_META +}; +const INFO_SNAPSHOT = 'INFO_SNAPSHOT'; +const CAPACITOR = 'CAPACITOR'; +const GEOLOCATION_LATITUDE1 = 22.917923; +const GEOLOCATION_LATITUDE2 = 23.000213; +const GEOLOCATION_LONGITUDE1 = 120.859958; +const GEOLOCATION_LONGITUDE2 = 120.868472; +const DEVICE_NAME_VALUE1 = 'Sony Xperia 1'; +const DEVICE_NAME_VALUE2 = 'xperia1'; +const HUMIDITY = 'HUMIDITY'; +const HUMIDITY_VALUE = 0.8; +const TIMESTAMP = 1605013013193; +const TRUTH: Truth = { + timestamp: TIMESTAMP, + providers: { + [INFO_SNAPSHOT]: { + [DefaultFactId.GEOLOCATION_LATITUDE]: GEOLOCATION_LATITUDE1, + [DefaultFactId.GEOLOCATION_LONGITUDE]: GEOLOCATION_LONGITUDE1, + [DefaultFactId.DEVICE_NAME]: DEVICE_NAME_VALUE1 + }, + [CAPACITOR]: { + [DefaultFactId.GEOLOCATION_LATITUDE]: GEOLOCATION_LATITUDE2, + [DefaultFactId.GEOLOCATION_LONGITUDE]: GEOLOCATION_LONGITUDE2, + [DefaultFactId.DEVICE_NAME]: DEVICE_NAME_VALUE2, + [HUMIDITY]: HUMIDITY_VALUE + } + } +}; +const TRUTH_EMPTY: Truth = { + timestamp: TIMESTAMP, + providers: {} +}; +const SIGNATURE_PROVIDER_ID = 'CAPTURE'; +const VALID_SIGNATURE = '575cbd72438eec799ffc5d78b45d968b65fd4597744d2127cd21556ceb63dff4a94f409d87de8d1f554025efdf56b8445d8d18e661b79754a25f45d05f4e26ac'; +const PUBLIC_KEY = '3059301306072a8648ce3d020106082a8648ce3d03010703420004bc23d419027e59bf1eb94c18bfa4ab5fb6ca8ae83c94dbac5bfdfac39ac8ae16484e23b4d522906c4cd8c7cb1a34cd820fb8d065e1b32c8a28320a68fff243f8'; +const SIGNATURES_VALID: Signatures = { + [SIGNATURE_PROVIDER_ID]: { + signature: VALID_SIGNATURE, + publicKey: PUBLIC_KEY + } +}; +const INVALID_SIGNATURE = '5d9192a66e2e2b4d22ce69dae407618eb6e052a86bb236bec11a7c154ffe20c0604e392378288340317d169219dfe063c504ed27ea2f47d9ec3868206b1d7f73'; +const SIGNATURES_INVALID: Signatures = { + [SIGNATURE_PROVIDER_ID]: { + signature: INVALID_SIGNATURE, + publicKey: PUBLIC_KEY + } +}; diff --git a/src/app/services/repositories/proof/proof.ts b/src/app/services/repositories/proof/proof.ts index d7c31f648..e890e6d51 100644 --- a/src/app/services/repositories/proof/proof.ts +++ b/src/app/services/repositories/proof/proof.ts @@ -1,8 +1,125 @@ +import ImageBlobReduce from 'image-blob-reduce'; +import { blobToDataUrlWithBase64$, dataUrlWithBase64ToBlob$ } from 'src/app/utils/encoding/encoding'; +import { sortObjectDeeplyByKey } from 'src/app/utils/immutable/immutable'; +import { sha256WithString$ } from '../../../utils/crypto/crypto'; import { MimeType } from '../../../utils/mime-type'; -import { Tuple } from '../../database/table/table'; -export interface Proof extends Tuple { - readonly hash: string; +const imageBlobReduce = new ImageBlobReduce(); + +/** + * - A box containing self-verifiable data. + * - Easy to serialize and deserialize for data persistence and interchange. + * - Bundle all immutable information. + * - (TODO) GETTERs might NOT good idea as it might trigger infinite loop with Angular change detection + * - Check if proof.assets has image. If true, generate single thumb. (Should We Cache?) + * - Generate ID from hash of stringified. (Should We Cache?) + */ +export class Proof { + + constructor( + readonly assets: Assets, + readonly truth: Truth, + readonly signatures: Signatures + ) { } + + get timestamp() { return this.truth.timestamp; } + + get deviceName() { return this.getFactValue(DefaultFactId.DEVICE_NAME); } + + get geolocationLatitude() { return this.getFactValue(DefaultFactId.GEOLOCATION_LATITUDE); } + + get geolocationLongitude() { return this.getFactValue(DefaultFactId.GEOLOCATION_LONGITUDE); } + + static signatureProviders = new Map(); + + static registerSignatureProvider(id: string, provider: SignatureVerifier) { + this.signatureProviders.set(id, provider); + } + + static unregisterSignatureProvider(id: string) { + this.signatureProviders.delete(id); + } + + static parse(json: string) { + const parsed = JSON.parse(json) as SerializedProof; + return new Proof(parsed.assets, parsed.truth, parsed.signatures); + } + + async getId() { return sha256WithString$(this.stringify()).toPromise(); } + + async getThumbnailDataUrl() { + const thumbnailSize = 200; + const imageAsset = Object.keys(this.assets).find(asset => this.assets[asset].mimeType.startsWith('image')); + if (imageAsset === undefined) { return undefined; } + const blob = await dataUrlWithBase64ToBlob$(`data:${this.assets[imageAsset].mimeType};base64,${imageAsset}`).toPromise(); + const thumbnailBlob = await imageBlobReduce.toBlob(blob, { max: thumbnailSize }); + return blobToDataUrlWithBase64$(thumbnailBlob).toPromise(); + } + + getFactValue(id: string) { return Object.values(this.truth.providers).find(fact => fact[id])?.[id]; } + + stringify() { + const proofProperties: SerializedProof = { + assets: this.assets, + truth: this.truth, + signatures: this.signatures + }; + return JSON.stringify(sortObjectDeeplyByKey(proofProperties as any).toJSON()); + } + + async isVerified() { + const signedTarget: SignedTargets = { + assets: this.assets, + truth: this.truth + }; + const serializedSortedSignedTargets = JSON.stringify(sortObjectDeeplyByKey(signedTarget as any).toJSON()); + const results = await Promise.all(Object.entries(this.signatures) + .map(([id, signature]) => Proof.signatureProviders.get(id)?.verify( + serializedSortedSignedTargets, + signature.signature, + signature.publicKey + )) + ); + return results.every(result => result); + } +} + +export interface Assets { [base64: string]: AssetMeta; } + +export interface AssetMeta { readonly mimeType: MimeType; +} + +export interface Truth { readonly timestamp: number; + readonly providers: TruthProviders; +} + +interface TruthProviders { [id: string]: Facts; } + +export type Facts = { [id in DefaultFactId | string]: string | number | boolean; }; + +export const enum DefaultFactId { + DEVICE_NAME = 'DEVICE_NAME', + GEOLOCATION_LATITUDE = 'GEOLOCATION_LATITUDE', + GEOLOCATION_LONGITUDE = 'GEOLOCATION_LONGITUDE' +} + +export interface Signatures { [id: string]: Signature; } + +export interface Signature { + readonly signature: string; + readonly publicKey: string; +} + +interface SerializedProof { + assets: Assets; + truth: Truth; + signatures: Signatures; +} + +export type SignedTargets = Pick; + +interface SignatureVerifier { + verify(message: string, signature: string, publicKey: string): boolean | Promise; } diff --git a/src/app/services/repositories/signature/signature-repository.service.spec.ts b/src/app/services/repositories/signature/signature-repository.service.spec.ts deleted file mode 100644 index 88c4f2bc9..000000000 --- a/src/app/services/repositories/signature/signature-repository.service.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { SharedTestingModule } from 'src/app/shared/shared-testing.module'; -import { SignatureRepository } from './signature-repository.service'; - -describe('SignatureRepository', () => { - let service: SignatureRepository; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - SharedTestingModule - ] - }); - service = TestBed.inject(SignatureRepository); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/src/app/services/repositories/signature/signature-repository.service.ts b/src/app/services/repositories/signature/signature-repository.service.ts deleted file mode 100644 index 35d0c4070..000000000 --- a/src/app/services/repositories/signature/signature-repository.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable } from '@angular/core'; -import { defer } from 'rxjs'; -import { first, map, switchMap } from 'rxjs/operators'; -import { Database } from '../../database/database.service'; -import { Proof } from '../proof/proof'; -import { Signature } from './signature'; - -@Injectable({ - providedIn: 'root' -}) -export class SignatureRepository { - - private readonly id = 'signature'; - private readonly table = this.database.getTable(this.id); - - constructor( - private readonly database: Database - ) { } - - getByProof$(proof: Proof) { - return this.table.queryAll$().pipe( - map(signatures => signatures.filter(info => info.proofHash === proof.hash)) - ); - } - - add$(...signatures: Signature[]) { - return defer(() => this.table.insert(signatures)); - } - - removeByProof$(proof: Proof) { - return this.getByProof$(proof).pipe( - first(), - switchMap(signatures => this.remove$(...signatures)) - ); - } - - remove$(...signatures: Signature[]) { - return defer(() => this.table.delete(signatures)); - } -} diff --git a/src/app/services/repositories/signature/signature.ts b/src/app/services/repositories/signature/signature.ts deleted file mode 100644 index 67e4d3aef..000000000 --- a/src/app/services/repositories/signature/signature.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Tuple } from '../../database/table/table'; - -export interface Signature extends Tuple { - readonly proofHash: string; - readonly provider: string; - readonly signature: string; - readonly publicKey: string; -} diff --git a/src/app/services/serialization/serialization.service.spec.ts b/src/app/services/serialization/serialization.service.spec.ts deleted file mode 100644 index 011433e36..000000000 --- a/src/app/services/serialization/serialization.service.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { SharedTestingModule } from 'src/app/shared/shared-testing.module'; -import { SerializationService } from './serialization.service'; - -describe('SerializationService', () => { - let service: SerializationService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - SharedTestingModule - ] - }); - service = TestBed.inject(SerializationService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/src/app/services/serialization/serialization.service.ts b/src/app/services/serialization/serialization.service.ts deleted file mode 100644 index 530289b0a..000000000 --- a/src/app/services/serialization/serialization.service.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Injectable } from '@angular/core'; -import { first, map } from 'rxjs/operators'; -import { Tuple } from '../database/table/table'; -import { Importance, Information, InformationType } from '../repositories/information/information'; -import { InformationRepository } from '../repositories/information/information-repository.service'; -import { Proof } from '../repositories/proof/proof'; - -export type EssentialInformation = Pick; - -export interface SortedProofInformation extends Tuple { - readonly proof: Proof; - readonly information: EssentialInformation[]; -} - -@Injectable({ - providedIn: 'root' -}) -export class SerializationService { - - constructor( - private readonly informationRepository: InformationRepository - ) { } - - stringify$(proof: Proof) { - return this.createSortedProofInformation$(proof).pipe( - map(sortedProofInformation => JSON.stringify(sortedProofInformation)) - ); - } - - private createSortedProofInformation$(proof: Proof) { - return this.informationRepository.getByProof$(proof).pipe( - first(), - map(informationList => { - const sortedInformation = informationList.sort((a: Information, b: Information) => { - const providerCompared = a.provider.localeCompare(b.provider); - const nameCompared = a.name.localeCompare(b.name); - const valueCompared = a.value.localeCompare(b.value); - if (providerCompared !== 0) { return providerCompared; } - if (nameCompared !== 0) { return nameCompared; } - return valueCompared; - }).map(({ provider, name, value }) => ({ provider, name, value } as EssentialInformation)); - return ({ proof, information: sortedInformation } as SortedProofInformation); - }) - ); - } - - parse(sortedProofInformation: SortedProofInformation) { - return { - proof: sortedProofInformation.proof, - information: sortedProofInformation.information.map(info => ({ - proofHash: sortedProofInformation.proof.hash, - importance: Importance.Low, - type: InformationType.Other, - ...info - } as Information)) - }; - } -} diff --git a/src/app/shared/post-capture-card/post-capture-card.component.spec.ts b/src/app/shared/post-capture-card/post-capture-card.component.spec.ts index 199c9b829..ec7f5b523 100644 --- a/src/app/shared/post-capture-card/post-capture-card.component.spec.ts +++ b/src/app/shared/post-capture-card/post-capture-card.component.spec.ts @@ -6,8 +6,8 @@ import { MatIconModule } from '@angular/material/icon'; import { MatIconTestingModule } from '@angular/material/icon/testing'; import { MatListModule } from '@angular/material/list'; import { IonicModule } from '@ionic/angular'; -import { Asset } from 'src/app/services/publisher/numbers-storage/data/asset/asset'; import { Transaction } from 'src/app/services/publisher/numbers-storage/numbers-storage-api.service'; +import { Asset } from 'src/app/services/publisher/numbers-storage/repositories/asset/asset'; import { getTranslocoModule } from 'src/app/transloco/transloco-root.module.spec'; import { PostCaptureCardComponent } from './post-capture-card.component'; @@ -19,7 +19,6 @@ describe('PostCaptureCardComponent', () => { proof_hash: 'abcdef1234567890', owner: 'me', asset_file: 'https://picsum.photos/200/300', - asset_file_thumbnail: 'https://picsum.photos/200/300', information: { proof: { hash: '', timestamp: 0, mimeType: 'image/jpeg' }, information: [] }, signature: [], caption: '', @@ -29,7 +28,11 @@ describe('PostCaptureCardComponent', () => { const expectedTranasction: Transaction = { id: '', sender: '', - asset: expectedAsset, + asset: { + asset_file_thumbnail: 'https://picsum.photos/200/300', + caption: '', + id: 'abcd-efgh-ijkl' + }, created_at: '', expired: false, fulfilled_at: '' diff --git a/src/app/shared/post-capture-card/post-capture-card.component.ts b/src/app/shared/post-capture-card/post-capture-card.component.ts index 598d1620f..f070c3e62 100644 --- a/src/app/shared/post-capture-card/post-capture-card.component.ts +++ b/src/app/shared/post-capture-card/post-capture-card.component.ts @@ -1,6 +1,7 @@ import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; -import { Asset } from 'src/app/services/publisher/numbers-storage/data/asset/asset'; import { Transaction } from 'src/app/services/publisher/numbers-storage/numbers-storage-api.service'; +import { Asset } from 'src/app/services/publisher/numbers-storage/repositories/asset/asset'; +import { OldDefaultInformationName } from 'src/app/services/repositories/proof/old-proof-adapter'; @Component({ selector: 'app-post-capture-card', templateUrl: './post-capture-card.component.html', @@ -17,7 +18,9 @@ export class PostCaptureCardComponent implements OnInit { openMore = false; ngOnInit() { - this.latitude = this.asset.information.information.find(info => info.name === 'Current GPS Latitude')?.value || 'unknown'; - this.longitude = this.asset.information.information.find(info => info.name === 'Current GPS Longitude')?.value || 'unknown'; + this.latitude = this.asset.information.information + .find(info => info.name === OldDefaultInformationName.GEOLOCATION_LATITUDE)?.value || 'unknown'; + this.longitude = this.asset.information.information + .find(info => info.name === OldDefaultInformationName.GEOLOCATION_LONGITUDE)?.value || 'unknown'; } } diff --git a/src/app/typings/image-blob-reduce.d.ts b/src/app/typings/image-blob-reduce.d.ts new file mode 100644 index 000000000..fa662494e --- /dev/null +++ b/src/app/typings/image-blob-reduce.d.ts @@ -0,0 +1,12 @@ +declare module "image-blob-reduce" { + export default class ImageBlobReduce { + constructor(options?: { + pica: any; + }); + + toBlob(blob: Blob, options: { + max: number; + picaResizeOptions?: any; + }): Promise; + } +} \ No newline at end of file diff --git a/src/app/utils/immutable/immutable.spec.ts b/src/app/utils/immutable/immutable.spec.ts new file mode 100644 index 000000000..fccf5e56d --- /dev/null +++ b/src/app/utils/immutable/immutable.spec.ts @@ -0,0 +1,29 @@ +import { sortObjectDeeplyByKey } from './immutable'; + +describe('sortObjectDeeplyByKey', () => { + + it('should return the same JSON serialized string with different ordered objects', () => { + const obj1 = { + a: { + a: { + a: { b: 'hello' }, + b: 'hello' + }, + b: 'hello' + }, + b: 'hello' + }; + const obj2 = { + b: 'hello', + a: { + b: 'hello', + a: { + b: 'hello', + a: { b: 'hello' } + } + } + }; + + expect(JSON.stringify(sortObjectDeeplyByKey(obj1))).toEqual(JSON.stringify(sortObjectDeeplyByKey(obj2))); + }); +}); diff --git a/src/app/utils/immutable/immutable.ts b/src/app/utils/immutable/immutable.ts new file mode 100644 index 000000000..f1a760939 --- /dev/null +++ b/src/app/utils/immutable/immutable.ts @@ -0,0 +1,9 @@ +import { OrderedMap } from 'immutable'; + +export function sortObjectDeeplyByKey(map: SortableMap): OrderedMap { + return OrderedMap(map) + .sortBy((_, key) => key) + .map(value => value instanceof Object ? sortObjectDeeplyByKey(value) : value); +} + +type SortableMap = { [key: string]: boolean | number | string | SortableMap; }; diff --git a/src/app/utils/mime-type.ts b/src/app/utils/mime-type.ts index 89cfa9247..08036b87c 100644 --- a/src/app/utils/mime-type.ts +++ b/src/app/utils/mime-type.ts @@ -1,6 +1,6 @@ // MimeType should be the subset of string type so `JSON.stringify` can generate meaningful text to // other platform. -export type MimeType = 'image/jpeg' | 'image/png'; +export type MimeType = 'image/jpeg' | 'image/png' | 'application/octet-stream'; export function getExtension(mimeType: MimeType) { switch (mimeType) { diff --git a/tsconfig.base.json b/tsconfig.base.json index affec4635..2e2e61f4c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -12,7 +12,7 @@ "importHelpers": true, "target": "es2015", "lib": [ - "es2018", + "es2019", "dom" ], "strict": true, diff --git a/typings/cordova-typings.d.ts b/typings/cordova-typings.d.ts deleted file mode 100644 index 108537a0e..000000000 --- a/typings/cordova-typings.d.ts +++ /dev/null @@ -1,3 +0,0 @@ - -/// -/// \ No newline at end of file