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