diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 5a5e01ee7..7cd91c60a 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -10,8 +10,11 @@ import { CollectorService } from './services/collector/collector.service'; import { CapacitorFactsProvider } from './services/collector/facts/capacitor-facts-provider/capacitor-facts-provider.service'; import { WebCryptoApiSignatureProvider } from './services/collector/signature/web-crypto-api-signature-provider/web-crypto-api-signature-provider.service'; import { DiaBackendAssetRepository } from './services/dia-backend/asset/dia-backend-asset-repository.service'; +import { DiaBackendAuthService } from './services/dia-backend/auth/dia-backend-auth.service'; +import { DiaBackendNotificationService } from './services/dia-backend/notification/dia-backend-notification.service'; import { LanguageService } from './services/language/language.service'; import { NotificationService } from './services/notification/notification.service'; +import { PushNotificationService } from './services/push-notification/push-notification.service'; import { restoreKilledCapture } from './utils/camera'; const { SplashScreen } = Plugins; @@ -32,16 +35,27 @@ export class AppComponent { private readonly webCryptoApiSignatureProvider: WebCryptoApiSignatureProvider, private readonly diaBackendAssetRepository: DiaBackendAssetRepository, notificationService: NotificationService, - langaugeService: LanguageService + pushNotificationService: PushNotificationService, + langaugeService: LanguageService, + diaBackendAuthService: DiaBackendAuthService, + diaBackendNotificationService: DiaBackendNotificationService ) { notificationService.requestPermission(); + pushNotificationService.register(); langaugeService.initialize(); + diaBackendAuthService.initialize$().pipe(untilDestroyed(this)).subscribe(); + diaBackendNotificationService + .initialize$() + .pipe(untilDestroyed(this)) + .subscribe(); this.restoreAppStatus(); this.initializeApp(); this.initializeCollector(); this.registerIcon(); } + // TODO: Error if user logout during app killed. Use BehaviorSubject instead. + // Extract this to a standalone CameraService. restoreAppStatus() { return defer(restoreKilledCapture) .pipe( diff --git a/src/app/pages/home/activity/activity.module.ts b/src/app/pages/home/activity/activity.module.ts deleted file mode 100644 index dbf103c8b..000000000 --- a/src/app/pages/home/activity/activity.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NgModule } from '@angular/core'; -import { SharedModule } from '../../../shared/shared.module'; -import { ActivityPageRoutingModule } from './activity-routing.module'; -import { ActivityPage } from './activity.page'; - -@NgModule({ - imports: [SharedModule, ActivityPageRoutingModule], - declarations: [ActivityPage], -}) -export class ActivityPageModule {} diff --git a/src/app/pages/home/activity/activity.page.html b/src/app/pages/home/activity/activity.page.html deleted file mode 100644 index 7c740c366..000000000 --- a/src/app/pages/home/activity/activity.page.html +++ /dev/null @@ -1,24 +0,0 @@ - - - {{ t('activity') }} - - -
- - - - -
{{ activity.asset.id }}
-
{{ activity.created_at | date: 'short' }}
- -
- -
-
-
diff --git a/src/app/pages/home/activity/activity.page.ts b/src/app/pages/home/activity/activity.page.ts deleted file mode 100644 index 17df72b80..000000000 --- a/src/app/pages/home/activity/activity.page.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Component } from '@angular/core'; -import { UntilDestroy } from '@ngneat/until-destroy'; -import { concatMap, pluck } from 'rxjs/operators'; -import { DiaBackendAuthService } from '../../../services/dia-backend/auth/dia-backend-auth.service'; -import { - DiaBackendTransaction, - DiaBackendTransactionRepository, -} from '../../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; - -@UntilDestroy({ checkProperties: true }) -@Component({ - selector: 'app-activity', - templateUrl: './activity.page.html', - styleUrls: ['./activity.page.scss'], -}) -export class ActivityPage { - readonly status = Status; - readonly activitiesWithStatus$ = this.diaBackendTransactionRepository - .getAll$() - .pipe( - pluck('results'), - concatMap(activities => - Promise.all( - activities.map(async activity => ({ - ...activity, - status: await this.getStatus(activity), - })) - ) - ) - ); - - constructor( - private readonly diaBackendAuthService: DiaBackendAuthService, - private readonly diaBackendTransactionRepository: DiaBackendTransactionRepository - ) {} - - private async getStatus(activity: DiaBackendTransaction) { - const email = await this.diaBackendAuthService.getEmail(); - if (activity.expired) { - return Status.Returned; - } - if (!activity.fulfilled_at) { - if (activity.receiver_email === email) { - return Status.InProgress; - } - return Status.waitingToBeAccepted; - } - if (activity.sender === email) { - return Status.Delivered; - } - return Status.Accepted; - } -} - -export enum Status { - waitingToBeAccepted = 'waitingToBeAccepted', - InProgress = 'inProgress', - Returned = 'returned', - Delivered = 'delivered', - Accepted = 'accepted', -} diff --git a/src/app/pages/home/activity/transaction-details/transaction-details.page.ts b/src/app/pages/home/activity/transaction-details/transaction-details.page.ts deleted file mode 100644 index 5f5108210..000000000 --- a/src/app/pages/home/activity/transaction-details/transaction-details.page.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Component } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { UntilDestroy } from '@ngneat/until-destroy'; -import { map } from 'rxjs/operators'; -@UntilDestroy({ checkProperties: true }) -@Component({ - selector: 'app-transaction-details', - templateUrl: './transaction-details.page.html', - styleUrls: ['./transaction-details.page.scss'], -}) -export class TransactionDetailsPage { - details$ = this.route.paramMap.pipe(map(() => history.state)); - - constructor(private readonly route: ActivatedRoute) {} -} diff --git a/src/app/pages/home/asset/asset.page.ts b/src/app/pages/home/asset/asset.page.ts index 1675eb95b..9617202b6 100644 --- a/src/app/pages/home/asset/asset.page.ts +++ b/src/app/pages/home/asset/asset.page.ts @@ -19,7 +19,7 @@ import { ConfirmAlert } from '../../../services/confirm-alert/confirm-alert.serv import { DiaBackendAssetRepository } from '../../../services/dia-backend/asset/dia-backend-asset-repository.service'; import { getOldProof } from '../../../services/repositories/proof/old-proof-adapter'; import { ProofRepository } from '../../../services/repositories/proof/proof-repository.service'; -import { isNonNullable } from '../../../utils/rx-operators'; +import { isNonNullable } from '../../../utils/rx-operators/rx-operators'; import { ContactSelectionDialogComponent } from './contact-selection-dialog/contact-selection-dialog.component'; import { Option, diff --git a/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.ts b/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.ts index 3f51a6c14..beafead16 100644 --- a/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.ts +++ b/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { DiaBackendContactRepository } from '../../../../services/dia-backend/contact/dia-backend-contact-repository.service'; -import { isNonNullable } from '../../../../utils/rx-operators'; +import { isNonNullable } from '../../../../utils/rx-operators/rx-operators'; import { FriendInvitationDialogComponent } from './friend-invitation-dialog/friend-invitation-dialog.component'; @Component({ diff --git a/src/app/pages/home/asset/information/information.page.ts b/src/app/pages/home/asset/information/information.page.ts index dce9e8d6a..85f36876d 100644 --- a/src/app/pages/home/asset/information/information.page.ts +++ b/src/app/pages/home/asset/information/information.page.ts @@ -9,7 +9,7 @@ import { getOldSignatures, } from '../../../../services/repositories/proof/old-proof-adapter'; import { ProofRepository } from '../../../../services/repositories/proof/proof-repository.service'; -import { isNonNullable } from '../../../../utils/rx-operators'; +import { isNonNullable } from '../../../../utils/rx-operators/rx-operators'; @UntilDestroy({ checkProperties: true }) @Component({ 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 d43a8a4c5..5eeb70cdd 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 @@ -10,7 +10,7 @@ import { DiaBackendAssetRepository } from '../../../../services/dia-backend/asse import { DiaBackendTransactionRepository } from '../../../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; import { getOldProof } from '../../../../services/repositories/proof/old-proof-adapter'; import { ProofRepository } from '../../../../services/repositories/proof/proof-repository.service'; -import { isNonNullable } from '../../../../utils/rx-operators'; +import { isNonNullable } from '../../../../utils/rx-operators/rx-operators'; @UntilDestroy({ checkProperties: true }) @Component({ diff --git a/src/app/pages/home/home-routing.module.ts b/src/app/pages/home/home-routing.module.ts index 0bb553ddf..75e072034 100644 --- a/src/app/pages/home/home-routing.module.ts +++ b/src/app/pages/home/home-routing.module.ts @@ -18,9 +18,11 @@ const routes: Routes = [ import('./inbox/inbox.module').then(m => m.InboxPageModule), }, { - path: 'activity', + path: 'transaction', loadChildren: () => - import('./activity/activity.module').then(m => m.ActivityPageModule), + import('./transaction/transaction.module').then( + m => m.TransactionPageModule + ), }, ]; diff --git a/src/app/pages/home/home.page.html b/src/app/pages/home/home.page.html index 8882c6881..4422c3f49 100644 --- a/src/app/pages/home/home.page.html +++ b/src/app/pages/home/home.page.html @@ -37,13 +37,14 @@ menu - - + {{ t('sentFrom') }} : {{ (details$ | async)?.sender }}{{ t('sentFrom') }} : {{ (transaction$ | async)?.sender }} {{ t('receiver') }} : - {{ (details$ | async)?.receiver_email }} diff --git a/src/app/pages/home/activity/transaction-details/transaction-details.page.scss b/src/app/pages/home/transaction/transaction-details/transaction-details.page.scss similarity index 100% rename from src/app/pages/home/activity/transaction-details/transaction-details.page.scss rename to src/app/pages/home/transaction/transaction-details/transaction-details.page.scss diff --git a/src/app/pages/home/activity/transaction-details/transaction-details.page.spec.ts b/src/app/pages/home/transaction/transaction-details/transaction-details.page.spec.ts similarity index 100% rename from src/app/pages/home/activity/transaction-details/transaction-details.page.spec.ts rename to src/app/pages/home/transaction/transaction-details/transaction-details.page.spec.ts diff --git a/src/app/pages/home/transaction/transaction-details/transaction-details.page.ts b/src/app/pages/home/transaction/transaction-details/transaction-details.page.ts new file mode 100644 index 000000000..9f35e5c4d --- /dev/null +++ b/src/app/pages/home/transaction/transaction-details/transaction-details.page.ts @@ -0,0 +1,63 @@ +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { map, switchMap } from 'rxjs/operators'; +import { DiaBackendAuthService } from '../../../../services/dia-backend/auth/dia-backend-auth.service'; +import { + DiaBackendTransaction, + DiaBackendTransactionRepository, +} from '../../../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; +import { isNonNullable } from '../../../../utils/rx-operators/rx-operators'; +@UntilDestroy({ checkProperties: true }) +@Component({ + selector: 'app-transaction-details', + templateUrl: './transaction-details.page.html', + styleUrls: ['./transaction-details.page.scss'], +}) +export class TransactionDetailsPage { + readonly transaction$ = this.route.paramMap.pipe( + map(params => params.get('id')), + isNonNullable(), + switchMap(id => this.diaBackendTransactionRepository.getById$(id)), + isNonNullable() + ); + readonly status$ = this.transaction$.pipe( + switchMap(transaction => + getStatus(transaction, this.diaBackendAuthService.getEmail()) + ) + ); + + constructor( + private readonly route: ActivatedRoute, + private readonly diaBackendTransactionRepository: DiaBackendTransactionRepository, + private readonly diaBackendAuthService: DiaBackendAuthService + ) {} +} + +export async function getStatus( + transaction: DiaBackendTransaction, + email: string | Promise +) { + const resolvedEmail = await email; + if (transaction.expired) { + return Status.Returned; + } + if (!transaction.fulfilled_at) { + if (transaction.receiver_email === resolvedEmail) { + return Status.InProgress; + } + return Status.waitingToBeAccepted; + } + if (transaction.sender === resolvedEmail) { + return Status.Delivered; + } + return Status.Accepted; +} + +enum Status { + waitingToBeAccepted = 'waitingToBeAccepted', + InProgress = 'inProgress', + Returned = 'returned', + Delivered = 'delivered', + Accepted = 'accepted', +} diff --git a/src/app/pages/home/activity/activity-routing.module.ts b/src/app/pages/home/transaction/transaction-routing.module.ts similarity index 76% rename from src/app/pages/home/activity/activity-routing.module.ts rename to src/app/pages/home/transaction/transaction-routing.module.ts index aa64348a2..6d56d80bc 100644 --- a/src/app/pages/home/activity/activity-routing.module.ts +++ b/src/app/pages/home/transaction/transaction-routing.module.ts @@ -1,11 +1,11 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import { ActivityPage } from './activity.page'; +import { TransactionPage } from './transaction.page'; const routes: Routes = [ { path: '', - component: ActivityPage, + component: TransactionPage, }, { path: 'transaction-details', @@ -20,4 +20,4 @@ const routes: Routes = [ imports: [RouterModule.forChild(routes)], exports: [RouterModule], }) -export class ActivityPageRoutingModule {} +export class TransactionPageRoutingModule {} diff --git a/src/app/pages/home/transaction/transaction.module.ts b/src/app/pages/home/transaction/transaction.module.ts new file mode 100644 index 000000000..ca0b8d4f8 --- /dev/null +++ b/src/app/pages/home/transaction/transaction.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; +import { SharedModule } from '../../../shared/shared.module'; +import { TransactionPageRoutingModule } from './transaction-routing.module'; +import { TransactionPage } from './transaction.page'; + +@NgModule({ + imports: [SharedModule, TransactionPageRoutingModule], + declarations: [TransactionPage], +}) +export class TransactionPageModule {} diff --git a/src/app/pages/home/transaction/transaction.page.html b/src/app/pages/home/transaction/transaction.page.html new file mode 100644 index 000000000..8a8f74d1c --- /dev/null +++ b/src/app/pages/home/transaction/transaction.page.html @@ -0,0 +1,39 @@ + + + {{ t('transaction') }} + + +
+ + + + + +
{{ transaction.asset.id }}
+
{{ transaction.created_at | date: 'short' }}
+ +
+ +
+
+
diff --git a/src/app/pages/home/activity/activity.page.scss b/src/app/pages/home/transaction/transaction.page.scss similarity index 95% rename from src/app/pages/home/activity/activity.page.scss rename to src/app/pages/home/transaction/transaction.page.scss index dcfee7d88..9286331e9 100644 --- a/src/app/pages/home/activity/activity.page.scss +++ b/src/app/pages/home/transaction/transaction.page.scss @@ -12,6 +12,10 @@ mat-toolbar { } .page-content { + mat-spinner { + margin: 36px auto 0; + } + mat-list-item { height: initial; padding-top: 16px; diff --git a/src/app/pages/home/activity/activity.page.spec.ts b/src/app/pages/home/transaction/transaction.page.spec.ts similarity index 52% rename from src/app/pages/home/activity/activity.page.spec.ts rename to src/app/pages/home/transaction/transaction.page.spec.ts index 0df6fb218..b9d41fcdf 100644 --- a/src/app/pages/home/activity/activity.page.spec.ts +++ b/src/app/pages/home/transaction/transaction.page.spec.ts @@ -1,23 +1,21 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { SharedTestingModule } from '../../../shared/shared-testing.module'; -import { ActivityPage } from './activity.page'; +import { TransactionPage } from './transaction.page'; -describe('ActivityPage', () => { - let component: ActivityPage; - let fixture: ComponentFixture; +describe('TransactionPage', () => { + let component: TransactionPage; + let fixture: ComponentFixture; beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ActivityPage], + declarations: [TransactionPage], imports: [SharedTestingModule], }).compileComponents(); - fixture = TestBed.createComponent(ActivityPage); + fixture = TestBed.createComponent(TransactionPage); component = fixture.componentInstance; fixture.detectChanges(); })); - it('should create', () => { - expect(component).toBeTruthy(); - }); + it('should create', () => expect(component).toBeTruthy()); }); diff --git a/src/app/pages/home/transaction/transaction.page.ts b/src/app/pages/home/transaction/transaction.page.ts new file mode 100644 index 000000000..04d75d867 --- /dev/null +++ b/src/app/pages/home/transaction/transaction.page.ts @@ -0,0 +1,31 @@ +import { Component } from '@angular/core'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { map } from 'rxjs/operators'; +import { DiaBackendAuthService } from '../../../services/dia-backend/auth/dia-backend-auth.service'; +import { DiaBackendTransactionRepository } from '../../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; +import { getStatus } from './transaction-details/transaction-details.page'; + +@UntilDestroy({ checkProperties: true }) +@Component({ + selector: 'app-transaction', + templateUrl: './transaction.page.html', + styleUrls: ['./transaction.page.scss'], +}) +export class TransactionPage { + readonly transactionsWithStatus$ = this.diaBackendTransactionRepository + .getAll$() + .pipe( + map(transactions => + transactions.map(transaction => ({ + ...transaction, + status: getStatus(transaction, this.diaBackendAuthService.getEmail()), + })) + ) + ); + readonly isFetching$ = this.diaBackendTransactionRepository.isFetching$(); + + constructor( + private readonly diaBackendAuthService: DiaBackendAuthService, + private readonly diaBackendTransactionRepository: DiaBackendTransactionRepository + ) {} +} diff --git a/src/app/services/dia-backend/asset/dia-backend-asset-repository.service.ts b/src/app/services/dia-backend/asset/dia-backend-asset-repository.service.ts index aaf89970f..5ed2c0012 100644 --- a/src/app/services/dia-backend/asset/dia-backend-asset-repository.service.ts +++ b/src/app/services/dia-backend/asset/dia-backend-asset-repository.service.ts @@ -5,16 +5,15 @@ import { defer, forkJoin } from 'rxjs'; import { concatMap, single } from 'rxjs/operators'; import { base64ToBlob } from '../../../utils/encoding/encoding'; import { Database } from '../../database/database.service'; -import { Tuple } from '../../database/table/table'; +import { OnConflictStrategy, Tuple } from '../../database/table/table'; import { NotificationService } from '../../notification/notification.service'; import { getOldSignatures, getSortedProofInformation, - OldDefaultInformationName, OldSignature, SortedProofInformation, } from '../../repositories/proof/old-proof-adapter'; -import { DefaultFactId, Proof } from '../../repositories/proof/proof'; +import { Proof } from '../../repositories/proof/proof'; import { DiaBackendAuthService } from '../auth/dia-backend-auth.service'; import { BASE_URL } from '../secret'; @@ -84,8 +83,12 @@ export class DiaBackendAssetRepository { } // TODO: use repository to remove this method. - async addAssetDirectly(asset: DiaBackendAsset) { - return this.table.insert([asset]); + async addAssetDirectly( + assets: DiaBackendAsset[], + onConflict = OnConflictStrategy.ABORT, + comparator = (x: DiaBackendAsset, y: DiaBackendAsset) => x.id === y.id + ) { + return this.table.insert(assets, onConflict, comparator); } // TODO: use repository to remove this method. @@ -112,8 +115,7 @@ async function buildFormDataToCreateAsset(proof: Proof) { const formData = new FormData(); const info = await getSortedProofInformation(proof); - const oldInfo = replaceDefaultFactIdWithOldDefaultInformationName(info); - formData.set('meta', JSON.stringify(oldInfo)); + formData.set('meta', JSON.stringify(info)); formData.set('signature', JSON.stringify(getOldSignatures(proof))); @@ -125,35 +127,3 @@ async function buildFormDataToCreateAsset(proof: Proof) { return formData; } - -function 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; - }), - }; -} diff --git a/src/app/services/dia-backend/auth/dia-backend-auth.service.ts b/src/app/services/dia-backend/auth/dia-backend-auth.service.ts index 1cf3b2bad..edabab97f 100644 --- a/src/app/services/dia-backend/auth/dia-backend-auth.service.ts +++ b/src/app/services/dia-backend/auth/dia-backend-auth.service.ts @@ -1,9 +1,11 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Plugins } from '@capacitor/core'; -import { defer, forkJoin, Observable } from 'rxjs'; -import { concatMap, concatMapTo, map } from 'rxjs/operators'; +import { reject } from 'lodash'; +import { combineLatest, defer, forkJoin, Observable } from 'rxjs'; +import { concatMap, concatMapTo, filter, map } from 'rxjs/operators'; import { PreferenceManager } from '../../preference-manager/preference-manager.service'; +import { PushNotificationService } from '../../push-notification/push-notification.service'; import { BASE_URL } from '../secret'; const { Device } = Plugins; @@ -18,9 +20,14 @@ export class DiaBackendAuthService { constructor( private readonly httpClient: HttpClient, - private readonly preferenceManager: PreferenceManager + private readonly preferenceManager: PreferenceManager, + private readonly pushNotificationService: PushNotificationService ) {} + initialize$() { + return this.updateDevice$(); + } + login$(email: string, password: string): Observable { return this.httpClient .post(`${BASE_URL}/auth/token/login/`, { @@ -75,12 +82,13 @@ export class DiaBackendAuthService { }); } - // TODO: Internally depend on PushNotificationService and remove parameters. - createDevice$(fcmToken: string) { - return defer(() => - forkJoin([this.getAuthHeaders(), Device.getInfo()]) - ).pipe( - concatMap(([headers, deviceInfo]) => + private updateDevice$() { + return combineLatest([ + this.pushNotificationService.getToken$(), + this.getAuthHeaders$(), + defer(() => Device.getInfo()), + ]).pipe( + concatMap(([fcmToken, headers, deviceInfo]) => this.httpClient.post( `${BASE_URL}/auth/devices/`, { @@ -133,8 +141,28 @@ export class DiaBackendAuthService { return { authorization: `token ${await this.getToken()}` }; } + getAuthHeaders$() { + return this.getToken$().pipe( + map(token => ({ authorization: `token ${token}` })) + ); + } + private async getToken() { - return this.preferences.getString(PrefKeys.TOKEN); + return new Promise(resolve => { + this.preferences.getString(PrefKeys.TOKEN).then(token => { + if (token.length !== 0) { + resolve(token); + } else { + reject(new Error('Cannot get DIA backend token which is empty.')); + } + }); + }); + } + + private getToken$() { + return this.preferences + .getString$(PrefKeys.TOKEN) + .pipe(filter(token => token.length !== 0)); } private async setToken(value: string) { diff --git a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts index 7b9da020e..88e4cf800 100644 --- a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts +++ b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { isEqual } from 'lodash'; -import { BehaviorSubject, defer, merge, of } from 'rxjs'; +import { BehaviorSubject, defer, merge, Observable, of } from 'rxjs'; import { concatMap, concatMapTo, @@ -18,10 +18,10 @@ import { BASE_URL } from '../secret'; providedIn: 'root', }) export class DiaBackendContactRepository { - private readonly _isFetching$ = new BehaviorSubject(false); private readonly table = this.database.getTable( DiaBackendContactRepository.name ); + private readonly _isFetching$ = new BehaviorSubject(false); constructor( private readonly httpClient: HttpClient, @@ -29,7 +29,7 @@ export class DiaBackendContactRepository { private readonly authService: DiaBackendAuthService ) {} - getAll$() { + getAll$(): Observable { return merge(this.fetchAll$(), this.table.queryAll$()).pipe( distinctUntilChanged(isEqual) ); diff --git a/src/app/services/dia-backend/notification/dia-backend-notification.service.spec.ts b/src/app/services/dia-backend/notification/dia-backend-notification.service.spec.ts new file mode 100644 index 000000000..ae0fdffd8 --- /dev/null +++ b/src/app/services/dia-backend/notification/dia-backend-notification.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; +import { SharedTestingModule } from '../../../shared/shared-testing.module'; +import { DiaBackendNotificationService } from './dia-backend-notification.service'; + +describe('DiaBackendNotificationService', () => { + let service: DiaBackendNotificationService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SharedTestingModule], + }); + service = TestBed.inject(DiaBackendNotificationService); + }); + + it('should be created', () => expect(service).toBeTruthy()); +}); diff --git a/src/app/services/dia-backend/notification/dia-backend-notification.service.ts b/src/app/services/dia-backend/notification/dia-backend-notification.service.ts new file mode 100644 index 000000000..80229013c --- /dev/null +++ b/src/app/services/dia-backend/notification/dia-backend-notification.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@angular/core'; +import { TranslocoService } from '@ngneat/transloco'; +import { defer, EMPTY, Observable } from 'rxjs'; +import { concatMap, filter } from 'rxjs/operators'; +import { switchTapTo } from '../../../utils/rx-operators/rx-operators'; +import { NotificationService } from '../../notification/notification.service'; +import { PushNotificationService } from '../../push-notification/push-notification.service'; +import { DiaBackendTransactionRepository } from '../transaction/dia-backend-transaction-repository.service'; + +@Injectable({ + providedIn: 'root', +}) +export class DiaBackendNotificationService { + constructor( + private readonly pushNotificationService: PushNotificationService, + private readonly transactionRepository: DiaBackendTransactionRepository, + private readonly notificationService: NotificationService, + private readonly translocoService: TranslocoService + ) {} + + initialize$() { + return this.pushNotificationService.getPushData$().pipe( + isDiaBackendPushNotificationData(), + concatMap(data => { + if (data.app_message_type === 'transaction_received') { + return defer(() => + this.notificationService.notify( + this.translocoService.translate('transactionReceived'), + this.translocoService.translate('message.transactionReceived') + ) + ).pipe(switchTapTo(this.transactionRepository.getAll$())); + } + if (data.app_message_type === 'transaction_expired') { + return defer(() => + this.notificationService.notify( + this.translocoService.translate('transactionExpired'), + this.translocoService.translate('message.transactionExpired') + ) + ).pipe(switchTapTo(this.transactionRepository.getAll$())); + } + return EMPTY; + }) + ); + } +} + +interface PushNotificationData { + readonly app_message_type: + | 'transaction_received' + | 'transaction_accepted' + | 'transaction_expired'; + readonly sender: 'string'; +} + +function isDiaBackendPushNotificationData() { + return (source$: Observable) => + source$.pipe( + filter( + (v): v is PushNotificationData => + v.app_message_type !== undefined && v.sender !== undefined + ) + ); +} diff --git a/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts b/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts index 5fa876367..6eaf0a730 100644 --- a/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts +++ b/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts @@ -1,27 +1,89 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { defer } from 'rxjs'; -import { concatMap } from 'rxjs/operators'; +import { isEqual, omit } from 'lodash'; +import { + BehaviorSubject, + combineLatest, + defer, + merge, + Observable, + of, +} from 'rxjs'; +import { + concatMap, + concatMapTo, + distinctUntilChanged, + map, + pluck, + tap, +} from 'rxjs/operators'; +import { + switchTap, + switchTapTo, +} from '../../../utils/rx-operators/rx-operators'; +import { Database } from '../../database/database.service'; +import { OnConflictStrategy, Tuple } from '../../database/table/table'; import { DiaBackendAuthService } from '../auth/dia-backend-auth.service'; import { BASE_URL } from '../secret'; +import { IgnoredTransactionRepository } from './ignored-transaction-repository.service'; @Injectable({ providedIn: 'root', }) export class DiaBackendTransactionRepository { + private readonly table = this.database.getTable( + DiaBackendTransactionRepository.name + ); + private readonly _isFetching$ = new BehaviorSubject(false); + constructor( private readonly httpClient: HttpClient, - private readonly authService: DiaBackendAuthService + private readonly authService: DiaBackendAuthService, + private readonly database: Database, + private readonly ignoredTransactionRepository: IgnoredTransactionRepository ) {} - getAll$() { - return defer(() => this.authService.getAuthHeaders()).pipe( + getAll$(): Observable { + return merge(this.fetchAll$(), this.table.queryAll$()).pipe( + distinctUntilChanged((transactionsX, transactionsY) => + isEqual( + transactionsX.map(x => omit(x, 'asset.asset_file_thumbnail')), + transactionsY.map(y => omit(y, 'asset.asset_file_thumbnail')) + ) + ) + ); + } + + getById$(id: string) { + return this.getAll$().pipe( + map(transactions => + transactions.find(transaction => transaction.id === id) + ) + ); + } + + isFetching$() { + return this._isFetching$.asObservable(); + } + + private fetchAll$() { + return of(this._isFetching$.next(true)).pipe( + concatMapTo(defer(() => this.authService.getAuthHeaders())), concatMap(headers => this.httpClient.get( `${BASE_URL}/api/v2/transactions/`, { headers } ) - ) + ), + pluck('results'), + concatMap(transactions => + this.table.insert( + transactions, + OnConflictStrategy.REPLACE, + (x, y) => x.id === y.id + ) + ), + tap(() => this._isFetching$.next(false)) ); } @@ -33,7 +95,8 @@ export class DiaBackendTransactionRepository { { asset_id: assetId, email: targetEmail, caption }, { headers } ) - ) + ), + switchTap(response => defer(() => this.table.insert([response]))) ); } @@ -45,12 +108,31 @@ export class DiaBackendTransactionRepository { {}, { headers } ) + ), + switchTapTo(this.fetchAll$()) + ); + } + + getInbox$() { + return combineLatest([ + this.getAll$(), + this.ignoredTransactionRepository.getAll$(), + this.authService.getEmail$(), + ]).pipe( + map(([transactions, ignoredTransactions, email]) => + transactions.filter( + transaction => + transaction.receiver_email === email && + !transaction.fulfilled_at && + !transaction.expired && + !ignoredTransactions.includes(transaction.id) + ) ) ); } } -export interface DiaBackendTransaction { +export interface DiaBackendTransaction extends Tuple { readonly id: string; readonly asset: { readonly id: string; @@ -60,7 +142,7 @@ export interface DiaBackendTransaction { readonly sender: string; readonly receiver_email: string; readonly created_at: string; - readonly fulfilled_at: string; + readonly fulfilled_at: string | null; readonly expired: boolean; } @@ -69,7 +151,7 @@ interface ListTransactionResponse { } // tslint:disable-next-line: no-empty-interface -interface CreateTransactionResponse {} +interface CreateTransactionResponse extends DiaBackendTransaction {} // tslint:disable-next-line: no-empty-interface interface AcceptTransactionResponse {} diff --git a/src/app/services/dia-backend/transaction/ignored-transaction-repository.service.ts b/src/app/services/dia-backend/transaction/ignored-transaction-repository.service.ts index 0c0a81112..d43105bbd 100644 --- a/src/app/services/dia-backend/transaction/ignored-transaction-repository.service.ts +++ b/src/app/services/dia-backend/transaction/ignored-transaction-repository.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; +import { isEqual } from 'lodash'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; import { Database } from '../../database/database.service'; import { Tuple } from '../../database/table/table'; @@ -13,10 +15,11 @@ export class IgnoredTransactionRepository { constructor(private readonly database: Database) {} - getAll$() { - return this.table - .queryAll$() - .pipe(map(tuples => tuples.map(tuple => tuple.id))); + getAll$(): Observable { + return this.table.queryAll$().pipe( + map(tuples => tuples.map(tuple => tuple.id)), + distinctUntilChanged(isEqual) + ); } async add(id: string) { diff --git a/src/app/services/push-notification/push-notification.service.spec.ts b/src/app/services/push-notification/push-notification.service.spec.ts index cf3ea20f2..2df350076 100644 --- a/src/app/services/push-notification/push-notification.service.spec.ts +++ b/src/app/services/push-notification/push-notification.service.spec.ts @@ -12,7 +12,5 @@ describe('PushNotificationService', () => { service = TestBed.inject(PushNotificationService); }); - it('should be created', () => { - expect(service).toBeTruthy(); - }); + it('should be created', () => expect(service).toBeTruthy()); }); diff --git a/src/app/services/push-notification/push-notification.service.ts b/src/app/services/push-notification/push-notification.service.ts index 84e499477..7e1461573 100644 --- a/src/app/services/push-notification/push-notification.service.ts +++ b/src/app/services/push-notification/push-notification.service.ts @@ -1,125 +1,55 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { - Capacitor, - Plugins, - PushNotification, - PushNotificationToken, -} from '@capacitor/core'; -import { tap } from 'rxjs/operators'; -import { DiaBackendAssetRepository } from '../dia-backend/asset/dia-backend-asset-repository.service'; -import { DiaBackendAuthService } from '../dia-backend/auth/dia-backend-auth.service'; -import { ImageStore } from '../image-store/image-store.service'; -import { getProof } from '../repositories/proof/old-proof-adapter'; -import { ProofRepository } from '../repositories/proof/proof-repository.service'; - -const { Device, PushNotifications } = Plugins; - -/** - * TODO: Refactor this class. Decouple from DIA Backend. - */ +import { Inject, Injectable } from '@angular/core'; +import { Capacitor, PushNotificationsPlugin } from '@capacitor/core'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { PUSH_NOTIFICATIONS_PLUGIN } from '../../shared/capacitor-plugins/capacitor-plugins.module'; +import { isNonNullable } from '../../utils/rx-operators/rx-operators'; @Injectable({ providedIn: 'root', }) export class PushNotificationService { - constructor( - private readonly diaBackendAuthService: DiaBackendAuthService, - private readonly diaBackendAssetRepository: DiaBackendAssetRepository, - private readonly proofRepository: ProofRepository, - private readonly httpClient: HttpClient, - private readonly imageStore: ImageStore - ) {} + // tslint:disable-next-line: rxjs-no-explicit-generics + private readonly token$ = new BehaviorSubject(undefined); + private readonly pushData$ = new Subject<{ [key: string]: string }>(); - configure(): void { - if (Capacitor.platform === 'web') { - return; + constructor( + @Inject(PUSH_NOTIFICATIONS_PLUGIN) + private readonly pushNotificationsPlugin: PushNotificationsPlugin + ) { + if (Capacitor.isPluginAvailable('PushNotifications')) { + this.pushNotificationsPlugin.addListener( + 'pushNotificationReceived', + notification => { + this.pushData$.next(notification.data); + } + ); } - this.requestPermission(); - this.addRegisterListener( - token => { - this.uploadToken$(token.value).subscribe(() => - // tslint:disable-next-line: no-console - console.log(`token ${token.value} uploaded`) - ); - const message = `Push registration success, token: ${token.value}`; - // tslint:disable-next-line: no-console - console.log(message); - }, - error => { - throw error; - } - ); - this.addReceivedListener(notification => - this.storeExpiredPostCapture(notification) - ); - } - - private uploadToken$(token: string) { - return this.diaBackendAuthService.createDevice$(token).pipe( - // tslint:disable-next-line: no-console - tap(() => console.log('Token Uploaded!')) - ); } - // tslint:disable-next-line: prefer-function-over-method - private requestPermission(): void { - // Request permission to use push notifications - // iOS will prompt user and return if they granted permission or not - // Android will just grant without prompting - PushNotifications.requestPermission().then(result => { - if (result.granted) { - // Register with Apple / Google to receive push via APNS/FCM - PushNotifications.register(); - } else { - throw Error('Failed to request permission'); - } - }); - } - - // tslint:disable-next-line: prefer-function-over-method - private addRegisterListener( - onSuccess: (token: PushNotificationToken) => void, - onError: (error: any) => void - ): void { - PushNotifications.addListener('registration', onSuccess); - PushNotifications.addListener('registrationError', onError); + getToken$() { + return this.token$.asObservable().pipe(isNonNullable()); } - // tslint:disable-next-line: prefer-function-over-method - private addReceivedListener( - callback: (notification: PushNotification) => void - ): void { - PushNotifications.addListener('pushNotificationReceived', callback); + getPushData$() { + return this.pushData$.asObservable(); } - private async storeExpiredPostCapture(pushNotification: PushNotification) { - const data: NumbersStorageNotification = pushNotification.data; - if (data.app_message_type !== 'transaction_expired') { + async register() { + if (!Capacitor.isPluginAvailable('PushNotifications')) { return; } + const result = await this.pushNotificationsPlugin.requestPermission(); - const asset = await this.diaBackendAssetRepository - .getById$(data.id) - .toPromise(); - await this.diaBackendAssetRepository.addAssetDirectly(asset); - const rawImage = await this.httpClient - .get(asset.asset_file, { responseType: 'blob' }) - .toPromise(); - const proof = await getProof( - this.imageStore, - rawImage, - asset.information, - asset.signature - ); - await this.proofRepository.add(proof); + return new Promise((resolve, reject) => { + if (!result.granted) { + reject(new Error('Push notification permission denied.')); + } + this.pushNotificationsPlugin.addListener('registration', token => { + this.token$.next(token.value); + resolve(token.value); + }); + this.pushNotificationsPlugin.addListener('registrationError', reject); + this.pushNotificationsPlugin.register(); + }); } } - -interface NumbersStorageNotification { - readonly app_message_type: - | 'transaction_received' - | 'transaction_accepted' - | 'transaction_expired'; - readonly id: string; -} diff --git a/src/app/services/repositories/proof/old-proof-adapter.spec.ts b/src/app/services/repositories/proof/old-proof-adapter.spec.ts index 764edaa22..092a59bf5 100644 --- a/src/app/services/repositories/proof/old-proof-adapter.spec.ts +++ b/src/app/services/repositories/proof/old-proof-adapter.spec.ts @@ -17,6 +17,7 @@ import { getProof, getSortedProofInformation, OldSignature, + replaceOldDefaultInformationNameWithDefaultFactId, SortedProofInformation, } from './old-proof-adapter'; @@ -40,7 +41,7 @@ describe('old-proof-adapter', () => { expect(oldProof.timestamp).toEqual(TRUTH.timestamp); }); - it('should convert Proof SortedProofInformation', async () => { + it('should convert Proof to SortedProofInformation', async () => { const sortedProofInformation = await getSortedProofInformation(proof); expect(sortedProofInformation.proof.hash).toEqual(ASSET1_SHA256); @@ -51,7 +52,7 @@ describe('old-proof-adapter', () => { expect( Object.values(proof.truth.providers) .flatMap(facts => Object.keys(facts)) - .includes(name) + .includes(replaceOldDefaultInformationNameWithDefaultFactId(name)) ).toBeTrue(); }); }); diff --git a/src/app/services/repositories/proof/old-proof-adapter.ts b/src/app/services/repositories/proof/old-proof-adapter.ts index 7ca159501..dd72c1937 100644 --- a/src/app/services/repositories/proof/old-proof-adapter.ts +++ b/src/app/services/repositories/proof/old-proof-adapter.ts @@ -3,7 +3,7 @@ import { blobToBase64 } from '../../../utils/encoding/encoding'; import { MimeType } from '../../../utils/mime-type'; import { Tuple } from '../../database/table/table'; import { ImageStore } from '../../image-store/image-store.service'; -import { Proof, Signature } from './proof'; +import { DefaultFactId, Proof, Signature } from './proof'; /** * Only for migration and connection to backend. Subject to change. @@ -29,10 +29,29 @@ export async function getSortedProofInformation( ): Promise { return { proof: getOldProof(proof), - information: createSortedEssentialInformation(proof), + information: createSortedEssentialInformation(proof).map(info => ({ + provider: info.provider, + name: replaceDefaultFactIdWithOldDefaultInformationName(info.name), + value: info.value, + })), }; } +export function replaceDefaultFactIdWithOldDefaultInformationName( + name: string +): string { + if (name === DefaultFactId.DEVICE_NAME) { + return OldDefaultInformationName.DEVICE_NAME; + } + if (name === DefaultFactId.GEOLOCATION_LATITUDE) { + return OldDefaultInformationName.GEOLOCATION_LATITUDE; + } + if (name === DefaultFactId.GEOLOCATION_LONGITUDE) { + return OldDefaultInformationName.GEOLOCATION_LONGITUDE; + } + return name; +} + function createSortedEssentialInformation( proof: Proof ): OldEssentialInformation[] { @@ -79,7 +98,11 @@ export async function getProof( ): Promise { const base64 = await blobToBase64(raw); const groupedByProvider = groupObjectsBy( - sortedProofInformation.information, + sortedProofInformation.information.map(info => ({ + provider: info.provider, + name: replaceOldDefaultInformationNameWithDefaultFactId(info.name), + value: info.value, + })), 'provider' ); const providers = flow( @@ -107,6 +130,21 @@ export async function getProof( ); } +export function replaceOldDefaultInformationNameWithDefaultFactId( + name: string +): string { + if (name === OldDefaultInformationName.DEVICE_NAME) { + return DefaultFactId.DEVICE_NAME; + } + if (name === OldDefaultInformationName.GEOLOCATION_LATITUDE) { + return DefaultFactId.GEOLOCATION_LATITUDE; + } + if (name === OldDefaultInformationName.GEOLOCATION_LONGITUDE) { + return DefaultFactId.GEOLOCATION_LONGITUDE; + } + return name; +} + /** * Group by the key. The returned collection does not have the original key property. */ diff --git a/src/app/services/repositories/proof/proof-repository.service.ts b/src/app/services/repositories/proof/proof-repository.service.ts index 7fbd4072f..ff992ed56 100644 --- a/src/app/services/repositories/proof/proof-repository.service.ts +++ b/src/app/services/repositories/proof/proof-repository.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { map } from 'rxjs/operators'; import { Database } from '../../database/database.service'; +import { OnConflictStrategy } from '../../database/table/table'; import { ImageStore } from '../../image-store/image-store.service'; import { IndexedProofView, Proof } from './proof'; @@ -27,8 +28,8 @@ export class ProofRepository { ); } - async add(proof: Proof) { - await this.table.insert([proof.getIndexedProofView()]); + async add(proof: Proof, onConflict = OnConflictStrategy.ABORT) { + await this.table.insert([proof.getIndexedProofView()], onConflict); return proof; } diff --git a/src/app/shared/capacitor-plugins/capacitor-plugins-testing.module.ts b/src/app/shared/capacitor-plugins/capacitor-plugins-testing.module.ts index 40e37e915..b85e4d2c2 100644 --- a/src/app/shared/capacitor-plugins/capacitor-plugins-testing.module.ts +++ b/src/app/shared/capacitor-plugins/capacitor-plugins-testing.module.ts @@ -3,11 +3,13 @@ import { FILESYSTEM_PLUGIN, GEOLOCATION_PLUGIN, LOCAL_NOTIFICATIONS_PLUGIN, + PUSH_NOTIFICATIONS_PLUGIN, STORAGE_PLUGIN, } from './capacitor-plugins.module'; import { MockFilesystemPlugin } from './mock-filesystem-plugin'; import { MockGeolocationPlugin } from './mock-geolocation-plugin'; import { MockLocalNotificationsPlugin } from './mock-local-notifications-plugin'; +import { MockPushNotificationsPlugin } from './mock-push-notifications-plugin'; import { MockStoragePlugin } from './mock-storage-plugin'; @NgModule({ @@ -28,6 +30,10 @@ import { MockStoragePlugin } from './mock-storage-plugin'; provide: STORAGE_PLUGIN, useClass: MockStoragePlugin, }, + { + provide: PUSH_NOTIFICATIONS_PLUGIN, + useClass: MockPushNotificationsPlugin, + }, ], }) export class CapacitorPluginsTestingModule {} diff --git a/src/app/shared/capacitor-plugins/capacitor-plugins.module.ts b/src/app/shared/capacitor-plugins/capacitor-plugins.module.ts index ca6f479a1..05b50de7f 100644 --- a/src/app/shared/capacitor-plugins/capacitor-plugins.module.ts +++ b/src/app/shared/capacitor-plugins/capacitor-plugins.module.ts @@ -4,10 +4,17 @@ import { GeolocationPlugin, LocalNotificationsPlugin, Plugins, + PushNotificationsPlugin, StoragePlugin, } from '@capacitor/core'; -const { Filesystem, Geolocation, LocalNotifications, Storage } = Plugins; +const { + Filesystem, + Geolocation, + LocalNotifications, + Storage, + PushNotifications, +} = Plugins; export const GEOLOCATION_PLUGIN = new InjectionToken( 'GEOLOCATION_PLUGIN' @@ -21,6 +28,9 @@ export const LOCAL_NOTIFICATIONS_PLUGIN = new InjectionToken( 'STORAGE_PLUGIN' ); +export const PUSH_NOTIFICATIONS_PLUGIN = new InjectionToken( + 'PUSH_NOTIFICATIONS_PLUGIN' +); @NgModule({ providers: [ @@ -28,6 +38,7 @@ export const STORAGE_PLUGIN = new InjectionToken( { provide: FILESYSTEM_PLUGIN, useValue: Filesystem }, { provide: LOCAL_NOTIFICATIONS_PLUGIN, useValue: LocalNotifications }, { provide: STORAGE_PLUGIN, useValue: Storage }, + { provide: PUSH_NOTIFICATIONS_PLUGIN, useValue: PushNotifications }, ], }) export class CapacitorPluginsModule {} diff --git a/src/app/shared/capacitor-plugins/mock-push-notifications-plugin.ts b/src/app/shared/capacitor-plugins/mock-push-notifications-plugin.ts new file mode 100644 index 000000000..c43f5ae48 --- /dev/null +++ b/src/app/shared/capacitor-plugins/mock-push-notifications-plugin.ts @@ -0,0 +1,84 @@ +// tslint:disable: prefer-function-over-method no-async-without-await +import { + NotificationChannel, + NotificationChannelList, + NotificationPermissionResponse, + PluginListenerHandle, + PushNotification, + PushNotificationActionPerformed, + PushNotificationDeliveredList, + PushNotificationsPlugin, + PushNotificationToken, +} from '@capacitor/core'; + +export class MockPushNotificationsPlugin implements PushNotificationsPlugin { + async register(): Promise { + throw new Error('Method not implemented.'); + } + + async requestPermission(): Promise { + throw new Error('Method not implemented.'); + } + + async getDeliveredNotifications(): Promise { + throw new Error('Method not implemented.'); + } + + async removeDeliveredNotifications( + delivered: PushNotificationDeliveredList + ): Promise { + throw new Error('Method not implemented.'); + } + + async removeAllDeliveredNotifications(): Promise { + throw new Error('Method not implemented.'); + } + + async createChannel(channel: NotificationChannel): Promise { + throw new Error('Method not implemented.'); + } + + async deleteChannel(channel: NotificationChannel): Promise { + throw new Error('Method not implemented.'); + } + + async listChannels(): Promise { + throw new Error('Method not implemented.'); + } + + addListener( + eventName: 'registration', + listenerFunc: (token: PushNotificationToken) => void + ): PluginListenerHandle; + addListener( + eventName: 'registrationError', + listenerFunc: (error: any) => void + ): PluginListenerHandle; + addListener( + eventName: 'pushNotificationReceived', + listenerFunc: (notification: PushNotification) => void + ): PluginListenerHandle; + addListener( + eventName: 'pushNotificationActionPerformed', + listenerFunc: (notification: PushNotificationActionPerformed) => void + ): PluginListenerHandle; + addListener( + eventName: + | 'registration' + | 'registrationError' + | 'pushNotificationReceived' + | 'pushNotificationActionPerformed', + listenerFunc: + | ((token: PushNotificationToken) => void) + | ((error: any) => void) + | ((notification: PushNotification) => void) + | ((notification: PushNotificationActionPerformed) => void) + ): PluginListenerHandle { + // tslint:disable-next-line: no-empty + return { remove: () => {} }; + } + + removeAllListeners(): void { + throw new Error('Method not implemented.'); + } +} diff --git a/src/app/utils/camera.ts b/src/app/utils/camera.ts index 9000ef040..5ce38674d 100644 --- a/src/app/utils/camera.ts +++ b/src/app/utils/camera.ts @@ -22,6 +22,7 @@ export async function capture(): Promise { }; } +// TODO: This promise may never resolve. Use BehaviorSubject instead. export async function restoreKilledCapture() { return new Promise(resolve => { App.addListener('appRestoredResult', appState => { diff --git a/src/app/utils/rx-operators.ts b/src/app/utils/rx-operators.ts deleted file mode 100644 index ca5886694..000000000 --- a/src/app/utils/rx-operators.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Observable } from 'rxjs'; -import { filter } from 'rxjs/operators'; - -export function isNonNullable() { - return (source$: Observable) => - source$.pipe( - filter((v): v is NonNullable => v !== null && v !== undefined) - ); -} diff --git a/src/app/utils/rx-operators/rx-operators.spec.ts b/src/app/utils/rx-operators/rx-operators.spec.ts new file mode 100644 index 000000000..d646f5b87 --- /dev/null +++ b/src/app/utils/rx-operators/rx-operators.spec.ts @@ -0,0 +1,36 @@ +// tslint:disable: no-null-keyword +import { of } from 'rxjs'; +import { toArray } from 'rxjs/operators'; +import { isNonNullable, switchTap, switchTapTo } from './rx-operators'; + +describe('rx-operators', () => { + it('should filter null and undefined values with type guard', done => { + const expected = [0, 1, 2]; + of(expected[0], expected[1], null, undefined, expected[2]) + .pipe(isNonNullable(), toArray()) + .subscribe(result => { + expect(result).toEqual(expected); + done(); + }); + }); + + it('should switchTap behaving the same with switchMapTo but ignore the result', done => { + const expected = 1; + of(expected) + .pipe(switchTap(v => of(v + v))) + .subscribe(result => { + expect(result).toEqual(expected); + done(); + }); + }); + + it('should switchTapTo behaving the same with switchMapTo but ignore the result', done => { + const expected = 1; + of(expected) + .pipe(switchTapTo(of(2))) + .subscribe(result => { + expect(result).toEqual(expected); + done(); + }); + }); +}); diff --git a/src/app/utils/rx-operators/rx-operators.ts b/src/app/utils/rx-operators/rx-operators.ts new file mode 100644 index 000000000..9daeecfdf --- /dev/null +++ b/src/app/utils/rx-operators/rx-operators.ts @@ -0,0 +1,19 @@ +import { Observable } from 'rxjs'; +import { filter, mapTo, switchMap } from 'rxjs/operators'; + +export function isNonNullable() { + return (source$: Observable) => + source$.pipe( + filter((v): v is NonNullable => v !== null && v !== undefined) + ); +} + +export function switchTap(func: (value: T) => Observable) { + return (source$: Observable) => + source$.pipe(switchMap(value => func(value).pipe(mapTo(value)))); +} + +export function switchTapTo(observable$: Observable) { + return (source$: Observable) => + source$.pipe(switchMap(value => observable$.pipe(mapTo(value)))); +} diff --git a/src/assets/i18n/en-us.json b/src/assets/i18n/en-us.json index 2d0bc314c..ca328aafc 100644 --- a/src/assets/i18n/en-us.json +++ b/src/assets/i18n/en-us.json @@ -67,12 +67,14 @@ "inbox": "Inbox", "ignore": "Ignore", "accept": "Accept", - "activity": "Activity", + "transaction": "Activity", "transactionDetails": "Activity Details", "sentFrom": "Sent from", "receiver": "Receiver", "location": "Location", "locationNotProvided": "Location Not Provided", + "transactionReceived": "New PostCapture Received", + "transactionExpired": "PostCapture Returned", "dismiss": "Dismiss", ".message": "Message", "message": { @@ -96,6 +98,8 @@ "invitationEmail": "Enter friend's email to send the invitation.", "clickingSignupToAgreePolicy": "By clicking Sign Up, you agree to our Terms and Privacy Policy.", "sendPostCaptureAlert": "After a PostCapture is sent, the ownership will be transferred to the selected friend. Are you sure?", + "transactionReceived": "A new PostCapture has received.", + "transactionExpired": "A PostCapture you delivered has been returned.", "viewAll": "open more..." }, "error": { diff --git a/src/assets/i18n/zh-tw.json b/src/assets/i18n/zh-tw.json index 077d03733..60eb33dba 100644 --- a/src/assets/i18n/zh-tw.json +++ b/src/assets/i18n/zh-tw.json @@ -67,12 +67,14 @@ "inbox": "收件匣", "ignore": "忽略", "accept": "接收", - "activity": "活動紀錄", + "transaction": "活動紀錄", "transactionDetails": "活動資訊", "sentFrom": "發送自", "receiver": "收件人", "location": "位置", "locationNotProvided": "沒有位置資訊", + "transactionReceived": "收到新 PostCapture", + "transactionExpired": "PostCapture 被退回", "dismiss": "關閉", ".message": "訊息", "message": { @@ -96,6 +98,8 @@ "invitationEmail": "輸入好友電子信箱發送邀請。", "clickingSignupToAgreePolicy": "點擊「註冊」即表示你同意我們的《服務條款》和《資料政策》。", "sendPostCaptureAlert": "PostCapture 送出後,它的所有權將轉移至選定的朋友。您確定嗎?", + "transactionReceived": "收到了新的 PostCapture。", + "transactionExpired": "您寄出的 PostCapture 已被退回。", "viewAll": "檢視全部" }, "error": {