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