xref: /aosp_15_r20/development/tools/winscope/src/app/components/app_component.ts (revision 90c8c64db3049935a07c6143d7fd006e26f8ecca)
1/*
2 * Copyright (C) 2022 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import {
18  ChangeDetectorRef,
19  Component,
20  Inject,
21  Injector,
22  NgZone,
23  ViewChild,
24  ViewEncapsulation,
25} from '@angular/core';
26import {createCustomElement} from '@angular/elements';
27import {FormControl, Validators} from '@angular/forms';
28import {MatDialog} from '@angular/material/dialog';
29import {Title} from '@angular/platform-browser';
30import {AbtChromeExtensionProtocol} from 'abt_chrome_extension/abt_chrome_extension_protocol';
31import {Mediator} from 'app/mediator';
32import {TimelineData} from 'app/timeline_data';
33import {TracePipeline} from 'app/trace_pipeline';
34import {Download} from 'common/download';
35import {FileUtils} from 'common/file_utils';
36import {globalConfig} from 'common/global_config';
37import {InMemoryStorage} from 'common/in_memory_storage';
38import {PersistentStore} from 'common/persistent_store';
39import {Store} from 'common/store';
40import {Timestamp} from 'common/time';
41import {UrlUtils} from 'common/url_utils';
42import {UserNotifier} from 'common/user_notifier';
43import {CrossToolProtocol} from 'cross_tool/cross_tool_protocol';
44import {Analytics} from 'logging/analytics';
45import {ProgressListener} from 'messaging/progress_listener';
46import {
47  AppFilesCollected,
48  AppFilesUploaded,
49  AppInitialized,
50  AppRefreshDumpsRequest,
51  AppResetRequest,
52  AppTraceViewRequest,
53  DarkModeToggled,
54  WinscopeEvent,
55  WinscopeEventType,
56} from 'messaging/winscope_event';
57import {WinscopeEventListener} from 'messaging/winscope_event_listener';
58import {AdbConnection} from 'trace_collection/adb_connection';
59import {AdbFiles} from 'trace_collection/adb_files';
60import {ProxyConnection} from 'trace_collection/proxy_connection';
61import {iconDividerStyle} from 'viewers/components/styles/icon_divider.styles';
62import {ViewerInputMethodComponent} from 'viewers/components/viewer_input_method_component';
63import {Viewer} from 'viewers/viewer';
64import {ViewerInputComponent} from 'viewers/viewer_input/viewer_input_component';
65import {ViewerJankCujsComponent} from 'viewers/viewer_jank_cujs/viewer_jank_cujs_component';
66import {ViewerMediaBasedComponent} from 'viewers/viewer_media_based/viewer_media_based_component';
67import {ViewerProtologComponent} from 'viewers/viewer_protolog/viewer_protolog_component';
68import {ViewerSearchComponent} from 'viewers/viewer_search/viewer_search_component';
69import {ViewerSurfaceFlingerComponent} from 'viewers/viewer_surface_flinger/viewer_surface_flinger_component';
70import {ViewerTransactionsComponent} from 'viewers/viewer_transactions/viewer_transactions_component';
71import {ViewerTransitionsComponent} from 'viewers/viewer_transitions/viewer_transitions_component';
72import {ViewerViewCaptureComponent} from 'viewers/viewer_view_capture/viewer_view_capture_component';
73import {ViewerWindowManagerComponent} from 'viewers/viewer_window_manager/viewer_window_manager_component';
74import {CollectTracesComponent} from './collect_traces_component';
75import {ShortcutsComponent} from './shortcuts_component';
76import {SnackBarOpener} from './snack_bar_opener';
77import {TimelineComponent} from './timeline/timeline_component';
78import {TraceViewComponent} from './trace_view_component';
79import {UploadTracesComponent} from './upload_traces_component';
80
81@Component({
82  selector: 'app-root',
83  encapsulation: ViewEncapsulation.None,
84  template: `
85    <mat-toolbar class="toolbar">
86      <div class="horizontal-align vertical-align">
87        <img class="app-title fixed" [src]="getLogoUrl()"/>
88      </div>
89
90      <div class="horizontal-align vertical-align">
91        <div *ngIf="showDataLoadedElements" class="download-files-section">
92          <div class="file-descriptor vertical-align">
93            <button
94              mat-icon-button
95              *ngIf="showCrossToolSyncButton()"
96              [matTooltip]="getCrossToolSyncTooltip()"
97              class="cross-tool-sync-button"
98              (click)="onCrossToolSyncButtonClick()"
99              [color]="getCrossToolSyncButtonColor()">
100              <mat-icon class="material-symbols-outlined">cloud_sync</mat-icon>
101            </button>
102            <span *ngIf="!isEditingFilename" class="download-file-info mat-body-2">
103              {{ filenameFormControl.value }}
104            </span>
105            <span *ngIf="!isEditingFilename" class="download-file-ext mat-body-2">.zip</span>
106            <mat-form-field
107              class="file-name-input-field"
108              *ngIf="isEditingFilename"
109              floatLabel="always"
110              (keydown.esc)="trySubmitFilename()"
111              (keydown.enter)="trySubmitFilename()"
112              (focusout)="trySubmitFilename()"
113              matTooltip="Allowed: A-Z a-z 0-9 . _ - #">
114              <mat-label>Edit file name</mat-label>
115              <input matInput class="right-align" [formControl]="filenameFormControl" />
116              <span matSuffix>.zip</span>
117            </mat-form-field>
118            <button
119              *ngIf="isEditingFilename"
120              mat-icon-button
121              class="check-button"
122              matTooltip="Submit file name"
123              (click)="trySubmitFilename()">
124              <mat-icon>check</mat-icon>
125            </button>
126            <button
127              *ngIf="!isEditingFilename"
128              mat-icon-button
129              class="edit-button"
130              matTooltip="Edit file name"
131              (click)="onPencilIconClick()">
132              <mat-icon>edit</mat-icon>
133            </button>
134            <button
135              mat-icon-button
136              [disabled]="isEditingFilename"
137              matTooltip="Download all traces"
138              class="save-button"
139              (click)="onDownloadTracesButtonClick()">
140              <mat-icon class="material-symbols-outlined">download</mat-icon>
141            </button>
142          </div>
143          <mat-progress-bar
144            *ngIf="downloadProgress !== undefined"
145            mode="determinate"
146            [value]="downloadProgress">
147          </mat-progress-bar>
148        </div>
149
150        <div *ngIf="showDataLoadedElements" class="icon-divider toolbar-icon-divider"></div>
151        <button
152          *ngIf="showDataLoadedElements && dumpsUploaded()"
153          color="primary"
154          mat-icon-button
155          matTooltip="Refresh dumps"
156          class="refresh-dumps"
157          (click)="onRefreshDumpsButtonClick()">
158          <mat-icon class="material-symbols-outlined">refresh</mat-icon>
159        </button>
160        <button
161          *ngIf="showDataLoadedElements"
162          mat-icon-button
163          matTooltip="Upload or collect new trace"
164          class="upload-new"
165          (click)="onUploadNewButtonClick()">
166          <mat-icon class="material-symbols-outlined">upload</mat-icon>
167        </button>
168
169        <button
170          mat-icon-button
171          matTooltip="Shortcuts"
172          class="shortcuts"
173          (click)="openShortcutsPanel()">
174          <mat-icon>keyboard_command_key</mat-icon>
175        </button>
176
177        <button
178          mat-icon-button
179          matTooltip="Documentation"
180          class="documentation"
181          (click)="goToDocumentation()">
182          <mat-icon>menu_book</mat-icon>
183        </button>
184
185        <button
186          mat-icon-button
187          class="report-bug"
188          matTooltip="Report bug"
189          (click)="goToBuganizer()">
190          <mat-icon>bug_report</mat-icon>
191        </button>
192
193        <button
194          mat-icon-button
195          class="dark-mode"
196          matTooltip="Switch to {{ isDarkModeOn ? 'light' : 'dark' }} mode"
197          (click)="toggleDarkMode()">
198          <mat-icon>
199            {{ isDarkModeOn ? 'brightness_5' : 'brightness_4' }}
200          </mat-icon>
201        </button>
202      </div>
203    </mat-toolbar>
204
205    <mat-divider></mat-divider>
206
207    <mat-drawer-container autosize disableClose autoFocus>
208      <mat-drawer-content>
209        <ng-container *ngIf="dataLoaded; else noLoadedTracesBlock">
210          <trace-view class="viewers" [viewers]="viewers" [store]="store"></trace-view>
211
212          <mat-divider></mat-divider>
213        </ng-container>
214      </mat-drawer-content>
215
216      <mat-drawer #drawer mode="overlay" opened="true" [baseHeight]="collapsedTimelineHeight">
217        <timeline
218          *ngIf="dataLoaded"
219          [allTraces]="tracePipeline.getTraces()"
220          [timelineData]="timelineData"
221          [store]="store"
222          (collapsedTimelineSizeChanged)="onCollapsedTimelineSizeChanged($event)"></timeline>
223      </mat-drawer>
224    </mat-drawer-container>
225
226    <ng-template #noLoadedTracesBlock>
227      <div class="center">
228        <div class="landing-content">
229          <h1 class="welcome-info mat-headline">
230            Welcome to Winscope. Please select source to view traces.
231          </h1>
232
233          <div class="card-grid landing-grid">
234            <collect-traces
235              class="collect-traces-card homepage-card"
236              [storage]="traceConfigStorage"
237              [adbConnection]="adbConnection"
238              (filesCollected)="onFilesCollected($event)"></collect-traces>
239
240            <upload-traces
241              #uploadTraces
242              class="upload-traces-card homepage-card"
243              [tracePipeline]="tracePipeline"
244              (filesUploaded)="onFilesUploaded($event)"
245              (viewTracesButtonClick)="onViewTracesButtonClick()"
246              (downloadTracesClick)="onDownloadTracesButtonClick(uploadTraces)"></upload-traces>
247          </div>
248        </div>
249      </div>
250    </ng-template>
251  `,
252  styles: [
253    `
254      .toolbar {
255        gap: 10px;
256        justify-content: space-between;
257        min-height: 64px;
258      }
259      .app-title {
260        height: 100%;
261      }
262      .welcome-info {
263        margin: 16px 0 6px 0;
264        text-align: center;
265      }
266      .homepage-card {
267        display: flex;
268        flex-direction: column;
269        flex: 1;
270        overflow: auto;
271        height: 870px;
272      }
273      .horizontal-align {
274        justify-content: center;
275      }
276      .vertical-align {
277        text-align: center;
278        align-items: center;
279        overflow-x: hidden;
280        display: flex;
281      }
282      .fixed {
283        min-width: fit-content;
284      }
285      .download-files-section {
286        overflow-x: hidden;
287      }
288      .file-descriptor {
289        font-size: 14px;
290        padding-left: 10px;
291        max-width: 700px;
292      }
293      .download-file-info {
294        text-overflow: ellipsis;
295        overflow-x: hidden;
296        padding-top: 3px;
297        max-width: 650px;
298      }
299      .download-file-ext {
300        padding-top: 3px;
301      }
302      .file-name-input-field .right-align {
303        text-align: right;
304      }
305      .file-name-input-field .mat-form-field-wrapper {
306        padding-bottom: 10px;
307        width: 600px;
308      }
309      .toolbar-icon-divider {
310        margin-right: 6px;
311        margin-left: 6px;
312        height: 20px;
313      }
314      .viewers {
315        height: 0;
316        flex-grow: 1;
317        display: flex;
318        flex-direction: column;
319        overflow: auto;
320      }
321      .center {
322        display: flex;
323        align-content: center;
324        flex-direction: column;
325        justify-content: center;
326        align-items: center;
327        justify-items: center;
328        flex-grow: 1;
329      }
330      .landing-content {
331        width: 100%;
332      }
333      .landing-content .card-grid {
334        max-width: 1800px;
335        flex-grow: 1;
336        margin: auto;
337      }
338    `,
339    iconDividerStyle,
340  ],
341})
342export class AppComponent implements WinscopeEventListener {
343  title = 'winscope';
344  timelineData = new TimelineData();
345  abtChromeExtensionProtocol = new AbtChromeExtensionProtocol();
346  crossToolProtocol: CrossToolProtocol;
347  dataLoaded = false;
348  showDataLoadedElements = false;
349  collapsedTimelineHeight = 0;
350  isEditingFilename = false;
351  store = new PersistentStore();
352  viewers: Viewer[] = [];
353
354  isDarkModeOn = false;
355  changeDetectorRef: ChangeDetectorRef;
356  tracePipeline: TracePipeline;
357  mediator: Mediator;
358  currentTimestamp?: Timestamp;
359  filenameFormControl = new FormControl(
360    'winscope',
361    Validators.compose([
362      Validators.required,
363      Validators.pattern(FileUtils.DOWNLOAD_FILENAME_REGEX),
364    ]),
365  );
366  adbConnection: AdbConnection = new ProxyConnection();
367  traceConfigStorage: Store;
368  downloadProgress: number | undefined;
369
370  @ViewChild(UploadTracesComponent)
371  uploadTracesComponent?: UploadTracesComponent;
372  @ViewChild(CollectTracesComponent)
373  collectTracesComponent?: CollectTracesComponent;
374  @ViewChild(TraceViewComponent) traceViewComponent?: TraceViewComponent;
375  @ViewChild(TimelineComponent) timelineComponent?: TimelineComponent;
376
377  constructor(
378    @Inject(Injector) injector: Injector,
379    @Inject(ChangeDetectorRef) changeDetectorRef: ChangeDetectorRef,
380    @Inject(SnackBarOpener) snackbarOpener: SnackBarOpener,
381    @Inject(Title) private pageTitle: Title,
382    @Inject(NgZone) private ngZone: NgZone,
383    @Inject(MatDialog) private dialog: MatDialog,
384  ) {
385    this.changeDetectorRef = changeDetectorRef;
386    UserNotifier.setSnackBarOpener(snackbarOpener);
387    this.tracePipeline = new TracePipeline();
388    this.crossToolProtocol = new CrossToolProtocol(
389      this.tracePipeline.getTimestampConverter(),
390    );
391    this.mediator = new Mediator(
392      this.tracePipeline,
393      this.timelineData,
394      this.abtChromeExtensionProtocol,
395      this.crossToolProtocol,
396      this,
397      new PersistentStore(),
398    );
399
400    const storeDarkMode = this.store.get('dark-mode');
401    const prefersDarkQuery = window.matchMedia?.(
402      '(prefers-color-scheme: dark)',
403    );
404    this.setDarkMode(
405      storeDarkMode ? storeDarkMode === 'true' : prefersDarkQuery.matches,
406    );
407
408    if (!customElements.get('viewer-input-method')) {
409      customElements.define(
410        'viewer-input-method',
411        createCustomElement(ViewerInputMethodComponent, {injector}),
412      );
413    }
414    if (!customElements.get('viewer-protolog')) {
415      customElements.define(
416        'viewer-protolog',
417        createCustomElement(ViewerProtologComponent, {injector}),
418      );
419    }
420    if (!customElements.get('viewer-media-based')) {
421      customElements.define(
422        'viewer-media-based',
423        createCustomElement(ViewerMediaBasedComponent, {injector}),
424      );
425    }
426    if (!customElements.get('viewer-surface-flinger')) {
427      customElements.define(
428        'viewer-surface-flinger',
429        createCustomElement(ViewerSurfaceFlingerComponent, {injector}),
430      );
431    }
432    if (!customElements.get('viewer-transactions')) {
433      customElements.define(
434        'viewer-transactions',
435        createCustomElement(ViewerTransactionsComponent, {injector}),
436      );
437    }
438    if (!customElements.get('viewer-window-manager')) {
439      customElements.define(
440        'viewer-window-manager',
441        createCustomElement(ViewerWindowManagerComponent, {injector}),
442      );
443    }
444    if (!customElements.get('viewer-transitions')) {
445      customElements.define(
446        'viewer-transitions',
447        createCustomElement(ViewerTransitionsComponent, {injector}),
448      );
449    }
450    if (!customElements.get('viewer-view-capture')) {
451      customElements.define(
452        'viewer-view-capture',
453        createCustomElement(ViewerViewCaptureComponent, {injector}),
454      );
455    }
456    if (!customElements.get('viewer-jank-cujs')) {
457      customElements.define(
458        'viewer-jank-cujs',
459        createCustomElement(ViewerJankCujsComponent, {injector}),
460      );
461    }
462    if (!customElements.get('viewer-input')) {
463      customElements.define(
464        'viewer-input',
465        createCustomElement(ViewerInputComponent, {injector}),
466      );
467    }
468    if (!customElements.get('viewer-search')) {
469      customElements.define(
470        'viewer-search',
471        createCustomElement(ViewerSearchComponent, {injector}),
472      );
473    }
474
475    this.traceConfigStorage =
476      globalConfig.MODE === 'PROD'
477        ? new PersistentStore()
478        : new InMemoryStorage();
479
480    window.onunhandledrejection = (evt) => {
481      Analytics.Error.logGlobalException(evt.reason);
482    };
483  }
484
485  async ngAfterViewInit() {
486    await this.mediator.onWinscopeEvent(new AppInitialized());
487  }
488
489  ngAfterViewChecked() {
490    this.mediator.setUploadTracesComponent(this.uploadTracesComponent);
491    this.mediator.setCollectTracesComponent(this.collectTracesComponent);
492    this.mediator.setTraceViewComponent(this.traceViewComponent);
493    this.mediator.setTimelineComponent(this.timelineComponent);
494  }
495
496  onCollapsedTimelineSizeChanged(height: number) {
497    this.collapsedTimelineHeight = height;
498    this.changeDetectorRef.detectChanges();
499  }
500
501  getLogoUrl(): string {
502    const logoPath = this.isDarkModeOn
503      ? 'logo_dark_mode.svg'
504      : 'logo_light_mode.svg';
505    return UrlUtils.getRootUrl() + logoPath;
506  }
507
508  async setDarkMode(enabled: boolean) {
509    document.body.classList.toggle('dark-mode', enabled);
510    this.store.add('dark-mode', `${enabled}`);
511    this.isDarkModeOn = enabled;
512    await this.mediator.onWinscopeEvent(new DarkModeToggled(enabled));
513  }
514
515  onPencilIconClick() {
516    this.isEditingFilename = true;
517  }
518
519  trySubmitFilename() {
520    if (this.filenameFormControl.invalid) {
521      return;
522    }
523    this.isEditingFilename = false;
524    this.pageTitle.setTitle(`Winscope | ${this.filenameFormControl.value}`);
525  }
526
527  async onDownloadTracesButtonClick(progressListener: ProgressListener = this) {
528    if (this.filenameFormControl.invalid) {
529      return;
530    }
531    const archiveBlob =
532      await this.tracePipeline.makeZipArchiveWithLoadedTraceFiles(
533        (perc: number) => {
534          progressListener.onProgressUpdate('Downloading', 90 * perc);
535        },
536      );
537    const archiveFilename = `${
538      this.showDataLoadedElements
539        ? this.filenameFormControl.value
540        : this.tracePipeline.getDownloadArchiveFilename()
541    }.zip`;
542    this.downloadTraces(archiveBlob, archiveFilename);
543    progressListener.onOperationFinished(true);
544  }
545
546  async onFilesCollected(files: AdbFiles) {
547    await this.mediator.onWinscopeEvent(new AppFilesCollected(files));
548  }
549
550  async onFilesUploaded(files: File[]) {
551    await this.mediator.onWinscopeEvent(new AppFilesUploaded(files));
552  }
553
554  async onRefreshDumpsButtonClick() {
555    Analytics.Tracing.logRefreshDumps();
556    await this.mediator.onWinscopeEvent(new AppRefreshDumpsRequest());
557  }
558
559  async onUploadNewButtonClick() {
560    await this.mediator.onWinscopeEvent(new AppResetRequest());
561    this.store.clear('treeView');
562  }
563
564  async onViewTracesButtonClick() {
565    await this.mediator.onWinscopeEvent(new AppTraceViewRequest());
566  }
567
568  onProgressUpdate(message: string, progressPercentage: number | undefined) {
569    this.ngZone.run(() => {
570      this.downloadProgress = progressPercentage;
571    });
572  }
573
574  onOperationFinished(success: boolean) {
575    this.ngZone.run(() => {
576      this.downloadProgress = undefined;
577    });
578  }
579
580  downloadTraces(blob: Blob, filename: string) {
581    const url = window.URL.createObjectURL(blob);
582    Download.fromUrl(url, filename);
583  }
584
585  async onWinscopeEvent(event: WinscopeEvent) {
586    await event.visit(WinscopeEventType.VIEWERS_LOADED, async (event) => {
587      this.viewers = event.viewers;
588      this.filenameFormControl.setValue(
589        this.tracePipeline.getDownloadArchiveFilename(),
590      );
591      this.pageTitle.setTitle(`Winscope | ${this.filenameFormControl.value}`);
592      this.isEditingFilename = false;
593
594      // some elements e.g. timeline require dataLoaded to be set outside NgZone to render
595      this.dataLoaded = true;
596      this.changeDetectorRef.detectChanges();
597
598      // tooltips must be rendered inside ngZone due to limitation of MatTooltip,
599      // therefore toolbar elements controlled by a different boolean
600      this.ngZone.run(() => {
601        this.showDataLoadedElements = true;
602      });
603    });
604
605    await event.visit(WinscopeEventType.VIEWERS_UNLOADED, async (event) => {
606      this.dataLoaded = false;
607      this.showDataLoadedElements = false;
608      this.pageTitle.setTitle('Winscope');
609      this.changeDetectorRef.detectChanges();
610    });
611  }
612
613  openShortcutsPanel() {
614    this.dialog.open(ShortcutsComponent, {
615      height: 'fit-content',
616      maxWidth: '860px',
617    });
618  }
619
620  goToDocumentation() {
621    Analytics.Help.logDocumentationOpened();
622    this.goToLink(
623      'https://source.android.com/docs/core/graphics/tracing-win-transitions',
624    );
625  }
626
627  goToBuganizer() {
628    Analytics.Help.logBuganizerOpened();
629    this.goToLink('https://b.corp.google.com/issues/new?component=909476');
630  }
631
632  toggleDarkMode() {
633    if (!this.isDarkModeOn) {
634      Analytics.Settings.logDarkModeEnabled();
635    }
636    this.setDarkMode(!this.isDarkModeOn);
637  }
638
639  dumpsUploaded() {
640    return !this.timelineData.hasMoreThanOneDistinctTimestamp();
641  }
642
643  showCrossToolSyncButton() {
644    return this.crossToolProtocol.isConnected();
645  }
646
647  getCrossToolSyncTooltip() {
648    const currStatus = this.crossToolProtocol.getAllowTimestampSync();
649
650    return `Cross Tool Sync ${this.translateStatus(
651      currStatus,
652    )} (Click to turn ${this.translateStatus(!currStatus)})`;
653  }
654
655  onCrossToolSyncButtonClick() {
656    this.crossToolProtocol.setAllowTimestampSync(
657      !this.crossToolProtocol.getAllowTimestampSync(),
658    );
659    Analytics.Settings.logCrossToolSync(
660      this.crossToolProtocol.getAllowTimestampSync(),
661    );
662  }
663
664  getCrossToolSyncButtonColor() {
665    return this.crossToolProtocol.getAllowTimestampSync()
666      ? 'primary'
667      : 'accent';
668  }
669
670  private goToLink(url: string) {
671    window.open(url, '_blank');
672  }
673
674  private translateStatus(status: boolean) {
675    return status ? 'ON' : 'OFF';
676  }
677}
678