From fedc706a53f8f1ecefae715f2a4b92a63f423947 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:44:26 +0000 Subject: [PATCH 1/7] Initial plan From 001a9733574873e155a2527a64fcec1a0d046de2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:52:28 +0000 Subject: [PATCH 2/7] Implement performance and error-handling improvements across shared services Agent-Logs-Url: https://github.com/numbersprotocol/capture-cam/sessions/1e4cc97a-7f48-42f9-b535-d2b5c78e7168 Co-authored-by: numbers-official <181934381+numbers-official@users.noreply.github.com> --- .../home/capture-tab/capture-tab.component.ts | 8 ++++++- .../auth/dia-backend-auth.service.ts | 14 ++++------- .../media/media-store/media-store.service.ts | 8 +++++-- src/app/shared/network/network.service.ts | 11 ++++++++- .../capacitor-storage-preferences.ts | 24 ++++++++++--------- src/app/shared/repositories/proof/proof.ts | 23 ++++++++++++------ 6 files changed, 56 insertions(+), 32 deletions(-) diff --git a/src/app/features/home/capture-tab/capture-tab.component.ts b/src/app/features/home/capture-tab/capture-tab.component.ts index 347418c66..227f800ab 100644 --- a/src/app/features/home/capture-tab/capture-tab.component.ts +++ b/src/app/features/home/capture-tab/capture-tab.component.ts @@ -1,5 +1,10 @@ import { formatDate, KeyValue } from '@angular/common'; -import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnInit, +} from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; import { Browser } from '@capacitor/browser'; @@ -44,6 +49,7 @@ import { PrefetchingDialogComponent } from '../onboarding/prefetching-dialog/pre selector: 'app-capture-tab', templateUrl: './capture-tab.component.html', styleUrls: ['./capture-tab.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class CaptureTabComponent implements OnInit { /** diff --git a/src/app/shared/dia-backend/auth/dia-backend-auth.service.ts b/src/app/shared/dia-backend/auth/dia-backend-auth.service.ts index 6a6f2f067..295455920 100644 --- a/src/app/shared/dia-backend/auth/dia-backend-auth.service.ts +++ b/src/app/shared/dia-backend/auth/dia-backend-auth.service.ts @@ -2,7 +2,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Device } from '@capacitor/device'; import { Preferences } from '@capacitor/preferences'; -import { isEqual, reject } from 'lodash-es'; +import { isEqual } from 'lodash-es'; import { Observable, ReplaySubject, @@ -432,15 +432,9 @@ export class DiaBackendAuthService { } private async getToken() { - 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.')); - } - }); - }); + const token = await this.preferences.getString(PrefKeys.TOKEN); + if (!token) throw new Error('Cannot get DIA backend token which is empty.'); + return token; } private async setToken(value: string) { diff --git a/src/app/shared/media/media-store/media-store.service.ts b/src/app/shared/media/media-store/media-store.service.ts index 5de121c52..5a1662182 100644 --- a/src/app/shared/media/media-store/media-store.service.ts +++ b/src/app/shared/media/media-store/media-store.service.ts @@ -100,10 +100,14 @@ export class MediaStore { private async _write(index: string, base64: string, mimeType: MimeType) { await this.initialize(); + // Perform expensive blob conversion before acquiring the lock so concurrent + // reads are not blocked during the conversion of large files. + const blob = Capacitor.isNativePlatform() + ? await base64ToBlob(base64, mimeType) + : undefined; return this.mutex.runExclusive(async () => { const mediaExtension = await this.setMediaExtension(index, mimeType); - if (Capacitor.isNativePlatform()) { - const blob = await base64ToBlob(base64, mimeType); + if (blob !== undefined) { await write_blob({ directory: this.directory, path: `${this.rootDir}/${index}.${mediaExtension.extension}`, diff --git a/src/app/shared/network/network.service.ts b/src/app/shared/network/network.service.ts index 867af69e1..a5a0b10bb 100644 --- a/src/app/shared/network/network.service.ts +++ b/src/app/shared/network/network.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, NgZone } from '@angular/core'; import { ConnectionStatus, NetworkPlugin } from '@capacitor/network'; import { defer, merge, ReplaySubject } from 'rxjs'; -import { distinctUntilChanged, pluck } from 'rxjs/operators'; +import { distinctUntilChanged, filter, pairwise, pluck } from 'rxjs/operators'; import { NETOWRK_PLUGIN } from '../capacitor-plugins/capacitor-plugins.module'; @Injectable({ @@ -15,6 +15,15 @@ export class NetworkService { this.status$ ).pipe(pluck('connected'), distinctUntilChanged()); + /** + * Emits when connectivity is restored (transitions from disconnected to connected). + * Can be used to trigger automatic upload recovery after a network outage. + */ + readonly reconnected$ = this.connected$.pipe( + pairwise(), + filter(([prev, curr]) => !prev && curr) + ); + constructor( @Inject(NETOWRK_PLUGIN) private readonly networkPlugin: NetworkPlugin, diff --git a/src/app/shared/preference-manager/preferences/capacitor-storage-preferences/capacitor-storage-preferences.ts b/src/app/shared/preference-manager/preferences/capacitor-storage-preferences/capacitor-storage-preferences.ts index a7373bb41..91c0a0b8a 100644 --- a/src/app/shared/preference-manager/preferences/capacitor-storage-preferences/capacitor-storage-preferences.ts +++ b/src/app/shared/preference-manager/preferences/capacitor-storage-preferences/capacitor-storage-preferences.ts @@ -61,18 +61,20 @@ export class CapacitorStoragePreferences { } private async initializeValue(key: string, defaultValue: SupportedTypes) { - if (this.subjects.has(key)) { - const subject$ = this.subjects.get(key); - if (subject$?.value === undefined) { - subject$?.next(defaultValue); + return this.mutex.runExclusive(async () => { + if (this.subjects.has(key)) { + const subject$ = this.subjects.get(key); + if (subject$?.value === undefined) { + subject$?.next(defaultValue); + } + return; } - return; - } - const value = await this.loadValue(key, defaultValue); - this.subjects.set( - key, - new BehaviorSubject(value) - ); + const value = await this.loadValue(key, defaultValue); + this.subjects.set( + key, + new BehaviorSubject(value) + ); + }); } private async loadValue(key: string, defaultValue: SupportedTypes) { diff --git a/src/app/shared/repositories/proof/proof.ts b/src/app/shared/repositories/proof/proof.ts index 7646c9778..a178a3f9f 100644 --- a/src/app/shared/repositories/proof/proof.ts +++ b/src/app/shared/repositories/proof/proof.ts @@ -56,6 +56,8 @@ export class Proof { */ cameraSource: CameraSource = CameraSource.Camera; + private _timestamp?: number; + /** * Used to sort the assets in the VERIFIED tab either by timestamp or uploadedAt (if available). * Since timestamp getter now ensures values are always in milliseconds, we don't need @@ -79,15 +81,22 @@ export class Proof { * * This getter ensures timestamps are always returned in milliseconds regardless of * how they're stored (seconds or milliseconds). + * + * The computed value is cached because `truth` is a readonly reference and + * `truth.timestamp` is treated as immutable after Proof construction. */ get timestamp() { - const MILLISECONDS_PER_SECOND = 1000; - const MILLISECONDS_THRESHOLD = 10000000000; // 10^10, timestamps after March 2001 - - // Convert to milliseconds if the timestamp is in seconds - return this.truth.timestamp > MILLISECONDS_THRESHOLD - ? this.truth.timestamp - : this.truth.timestamp * MILLISECONDS_PER_SECOND; + if (this._timestamp === undefined) { + const MILLISECONDS_PER_SECOND = 1000; + const MILLISECONDS_THRESHOLD = 10000000000; // 10^10, timestamps after March 2001 + + // Convert to milliseconds if the timestamp is in seconds + this._timestamp = + this.truth.timestamp > MILLISECONDS_THRESHOLD + ? this.truth.timestamp + : this.truth.timestamp * MILLISECONDS_PER_SECOND; + } + return this._timestamp; } get deviceName() { From 5398a2199c8765e03108838779dc6b86e4dda046 Mon Sep 17 00:00:00 2001 From: Omni Date: Thu, 16 Apr 2026 08:35:35 +0000 Subject: [PATCH 3/7] feat: update rc/app/features/invitation/invitation.page.spec.ts by Olga Shen & Omni --- .../invitation/invitation.page.spec.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/app/features/invitation/invitation.page.spec.ts b/src/app/features/invitation/invitation.page.spec.ts index 99b2d567f..a9c1c2c7d 100644 --- a/src/app/features/invitation/invitation.page.spec.ts +++ b/src/app/features/invitation/invitation.page.spec.ts @@ -1,5 +1,8 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { IonicModule } from '@ionic/angular'; +import { of } from 'rxjs'; +import { DiaBackendAuthService } from '../../shared/dia-backend/auth/dia-backend-auth.service'; +import { ShareService } from '../../shared/share/share.service'; import { SharedTestingModule } from '../../shared/shared-testing.module'; import { InvitationPage } from './invitation.page'; @@ -7,10 +10,30 @@ describe('InvitationPage', () => { let component: InvitationPage; let fixture: ComponentFixture; + const diaBackendAuthServiceStub = { + referralCode$: of(''), + getReferralCode: jasmine.createSpy().and.resolveTo(''), + syncUser$: jasmine.createSpy().and.returnValue(of(void 0)), + }; + + const shareServiceStub = { + shareReferralCode: jasmine.createSpy(), + }; + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [InvitationPage], imports: [IonicModule.forRoot(), SharedTestingModule], + providers: [ + { + provide: DiaBackendAuthService, + useValue: diaBackendAuthServiceStub, + }, + { + provide: ShareService, + useValue: shareServiceStub, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(InvitationPage); From 00dae28b09ad939d58e2395fda43eeeb1695d50a Mon Sep 17 00:00:00 2001 From: Omni Date: Thu, 16 Apr 2026 09:01:54 +0000 Subject: [PATCH 4/7] test: stub auth in HomePage spec --- src/app/features/home/home.page.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/app/features/home/home.page.spec.ts b/src/app/features/home/home.page.spec.ts index 8f77adb31..86b167244 100644 --- a/src/app/features/home/home.page.spec.ts +++ b/src/app/features/home/home.page.spec.ts @@ -1,5 +1,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { of } from 'rxjs'; +import { DiaBackendAuthService } from '../../shared/dia-backend/auth/dia-backend-auth.service'; import { SharedTestingModule } from '../../shared/shared-testing.module'; import { CaptureTabComponent } from './capture-tab/capture-tab.component'; import { UploadingBarComponent } from './capture-tab/uploading-bar/uploading-bar.component'; @@ -10,6 +12,17 @@ describe('HomePage', () => { let component: HomePage; let fixture: ComponentFixture; + const diaBackendAuthServiceStub = { + username$: of(''), + email$: of(''), + profileName$: of(''), + profile$: of({ + description: '', + profile_background_thumbnail: '', + }), + syncUser$: jasmine.createSpy().and.returnValue(of(void 0)), + }; + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [ @@ -19,6 +32,12 @@ describe('HomePage', () => { UploadingBarComponent, ], imports: [SharedTestingModule], + providers: [ + { + provide: DiaBackendAuthService, + useValue: diaBackendAuthServiceStub, + }, + ], }).compileComponents(); const router = TestBed.inject(Router); From df1e717da2498505c2310ebc70e2a27d3628b471 Mon Sep 17 00:00:00 2001 From: Omni Date: Thu, 16 Apr 2026 09:09:43 +0000 Subject: [PATCH 5/7] test: stub wallet page auth deps --- src/app/features/wallets/wallets.page.spec.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/app/features/wallets/wallets.page.spec.ts b/src/app/features/wallets/wallets.page.spec.ts index efa9740a7..da24d6fa9 100644 --- a/src/app/features/wallets/wallets.page.spec.ts +++ b/src/app/features/wallets/wallets.page.spec.ts @@ -1,5 +1,9 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { IonicModule } from '@ionic/angular'; +import { BehaviorSubject, of } from 'rxjs'; +import { CaptureAppWebCryptoApiSignatureProvider } from '../../shared/collector/signature/capture-app-web-crypto-api-signature-provider/capture-app-web-crypto-api-signature-provider.service'; +import { DiaBackendAuthService } from '../../shared/dia-backend/auth/dia-backend-auth.service'; +import { DiaBackendWalletService } from '../../shared/dia-backend/wallet/dia-backend-wallet.service'; import { SharedTestingModule } from '../../shared/shared-testing.module'; import { WalletsPage } from './wallets.page'; @@ -7,10 +11,39 @@ describe('WalletsPage', () => { let component: WalletsPage; let fixture: ComponentFixture; + const diaBackendAuthServiceStub = { + cachedQueryJWTToken$: of({ access: 'token', refresh: 'refresh' }), + }; + + const diaBackendWalletServiceStub = { + assetWalletAddr$: of('asset-wallet-address'), + networkConnected$: of(true), + reloadWallet$: new BehaviorSubject(false), + }; + + const signatureProviderStub = { + publicKey$: of('public-key'), + privateKey$: of('private-key'), + }; + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [WalletsPage], imports: [IonicModule.forRoot(), SharedTestingModule], + providers: [ + { + provide: DiaBackendAuthService, + useValue: diaBackendAuthServiceStub, + }, + { + provide: DiaBackendWalletService, + useValue: diaBackendWalletServiceStub, + }, + { + provide: CaptureAppWebCryptoApiSignatureProvider, + useValue: signatureProviderStub, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(WalletsPage); From bf2fad94a1a4c58dbb27944364f5a247e8c56c89 Mon Sep 17 00:00:00 2001 From: Omni Date: Thu, 16 Apr 2026 09:28:09 +0000 Subject: [PATCH 6/7] feat: update src/app/features/home/collection-tab/collection-tab/coll... by Olga Shen & Omni --- .../capture-tab/capture-tab.component.spec.ts | 28 +++++++++++++++++++ .../collection-tab.component.spec.ts | 23 ++++++++++++++- .../features/settings/settings.page.spec.ts | 23 +++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/app/features/home/capture-tab/capture-tab.component.spec.ts b/src/app/features/home/capture-tab/capture-tab.component.spec.ts index 5952e9911..b4446838c 100644 --- a/src/app/features/home/capture-tab/capture-tab.component.spec.ts +++ b/src/app/features/home/capture-tab/capture-tab.component.spec.ts @@ -1,4 +1,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { DiaBackendAuthService } from '../../../shared/dia-backend/auth/dia-backend-auth.service'; +import { DiaBackendTransactionRepository } from '../../../shared/dia-backend/transaction/dia-backend-transaction-repository.service'; import { SharedTestingModule } from '../../../shared/shared-testing.module'; import { CaptureTabComponent } from './capture-tab.component'; import { UploadingBarComponent } from './uploading-bar/uploading-bar.component'; @@ -7,10 +10,35 @@ describe('CaptureTabComponent', () => { let component: CaptureTabComponent; let fixture: ComponentFixture; + const diaBackendAuthServiceStub = { + username$: of(''), + email$: of(''), + profileName$: of(''), + profile$: of({ + description: '', + profile_background_thumbnail: '', + }), + syncUser$: jasmine.createSpy().and.returnValue(of(void 0)), + }; + + const diaBackendTransactionRepositoryStub = { + inbox$: of({ count: 0 }), + }; + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [CaptureTabComponent, UploadingBarComponent], imports: [SharedTestingModule], + providers: [ + { + provide: DiaBackendAuthService, + useValue: diaBackendAuthServiceStub, + }, + { + provide: DiaBackendTransactionRepository, + useValue: diaBackendTransactionRepositoryStub, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(CaptureTabComponent); diff --git a/src/app/features/home/collection-tab/collection-tab/collection-tab.component.spec.ts b/src/app/features/home/collection-tab/collection-tab/collection-tab.component.spec.ts index 55c543502..3b301c827 100644 --- a/src/app/features/home/collection-tab/collection-tab/collection-tab.component.spec.ts +++ b/src/app/features/home/collection-tab/collection-tab/collection-tab.component.spec.ts @@ -1,16 +1,37 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { BehaviorSubject, of } from 'rxjs'; +import { DiaBackendAuthService } from '../../../../shared/dia-backend/auth/dia-backend-auth.service'; +import { IframeService } from '../../../../shared/iframe/iframe.service'; import { SharedTestingModule } from '../../../../shared/shared-testing.module'; - import { CollectionTabComponent } from './collection-tab.component'; describe('CollectionTabComponent', () => { let component: CollectionTabComponent; let fixture: ComponentFixture; + const diaBackendAuthServiceStub = { + cachedQueryJWTToken$: of({ access: 'token', refresh: 'refresh' }), + }; + + const iframeServiceStub = { + collectionTabRefreshRequested$: new BehaviorSubject(undefined), + collectionTabIframeNavigateBack$: of(void 0), + }; + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [CollectionTabComponent], imports: [SharedTestingModule], + providers: [ + { + provide: DiaBackendAuthService, + useValue: diaBackendAuthServiceStub, + }, + { + provide: IframeService, + useValue: iframeServiceStub, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(CollectionTabComponent); diff --git a/src/app/features/settings/settings.page.spec.ts b/src/app/features/settings/settings.page.spec.ts index 532aa9b9d..cc3aaa5e1 100644 --- a/src/app/features/settings/settings.page.spec.ts +++ b/src/app/features/settings/settings.page.spec.ts @@ -1,4 +1,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { CaptureAppWebCryptoApiSignatureProvider } from '../../shared/collector/signature/capture-app-web-crypto-api-signature-provider/capture-app-web-crypto-api-signature-provider.service'; +import { DiaBackendAuthService } from '../../shared/dia-backend/auth/dia-backend-auth.service'; import { SharedTestingModule } from '../../shared/shared-testing.module'; import { SettingsPage } from './settings.page'; @@ -6,10 +9,30 @@ describe('SettingsPage', () => { let component: SettingsPage; let fixture: ComponentFixture; + const diaBackendAuthServiceStub = { + email$: of('tester@example.com'), + emailVerified$: of(false), + syncUser$: jasmine.createSpy().and.returnValue(of(void 0)), + }; + + const signatureProviderStub = { + privateKey$: of('private-key-for-test'), + }; + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [SettingsPage], imports: [SharedTestingModule], + providers: [ + { + provide: DiaBackendAuthService, + useValue: diaBackendAuthServiceStub, + }, + { + provide: CaptureAppWebCryptoApiSignatureProvider, + useValue: signatureProviderStub, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(SettingsPage); From f211b90c1cdc45a625cc17d71e242fbd0d354fda Mon Sep 17 00:00:00 2001 From: Omni Date: Thu, 16 Apr 2026 10:13:40 +0000 Subject: [PATCH 7/7] fix: add markForCheck for OnPush segment change and remove unused reconnected$ - CaptureTabComponent: programmatic segment assignment via captureTabService now calls markForCheck() to ensure OnPush change detection picks up the new value - NetworkService: remove reconnected$ observable (unused dead code) --- .../home/capture-tab/capture-tab.component.ts | 5 ++++- src/app/shared/network/network.service.ts | 11 +---------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/app/features/home/capture-tab/capture-tab.component.ts b/src/app/features/home/capture-tab/capture-tab.component.ts index 5af5d44c8..de0ac0e7d 100644 --- a/src/app/features/home/capture-tab/capture-tab.component.ts +++ b/src/app/features/home/capture-tab/capture-tab.component.ts @@ -180,7 +180,10 @@ export class CaptureTabComponent implements OnInit { private initSegmentListener() { this.captureTabService.segment$ .pipe( - tap(segment => (this.segment = segment)), + tap(segment => { + this.segment = segment; + this.changeDetectorRef.markForCheck(); + }), untilDestroyed(this) ) .subscribe(); diff --git a/src/app/shared/network/network.service.ts b/src/app/shared/network/network.service.ts index a5a0b10bb..867af69e1 100644 --- a/src/app/shared/network/network.service.ts +++ b/src/app/shared/network/network.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, NgZone } from '@angular/core'; import { ConnectionStatus, NetworkPlugin } from '@capacitor/network'; import { defer, merge, ReplaySubject } from 'rxjs'; -import { distinctUntilChanged, filter, pairwise, pluck } from 'rxjs/operators'; +import { distinctUntilChanged, pluck } from 'rxjs/operators'; import { NETOWRK_PLUGIN } from '../capacitor-plugins/capacitor-plugins.module'; @Injectable({ @@ -15,15 +15,6 @@ export class NetworkService { this.status$ ).pipe(pluck('connected'), distinctUntilChanged()); - /** - * Emits when connectivity is restored (transitions from disconnected to connected). - * Can be used to trigger automatic upload recovery after a network outage. - */ - readonly reconnected$ = this.connected$.pipe( - pairwise(), - filter(([prev, curr]) => !prev && curr) - ); - constructor( @Inject(NETOWRK_PLUGIN) private readonly networkPlugin: NetworkPlugin,