| /* |
| * Copyright (C) 2022 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| import { |
| ChangeDetectorRef, |
| Component, |
| Inject, |
| Injector, |
| NgZone, |
| ViewChild, |
| ViewEncapsulation, |
| } from '@angular/core'; |
| import {createCustomElement} from '@angular/elements'; |
| import {FormControl, Validators} from '@angular/forms'; |
| import {MatDialog} from '@angular/material/dialog'; |
| import {Title} from '@angular/platform-browser'; |
| import {AbtChromeExtensionProtocol} from 'abt_chrome_extension/abt_chrome_extension_protocol'; |
| import {Mediator} from 'app/mediator'; |
| import {TimelineData} from 'app/timeline_data'; |
| import {TracePipeline} from 'app/trace_pipeline'; |
| import {FileUtils} from 'common/file_utils'; |
| import {globalConfig} from 'common/global_config'; |
| import {InMemoryStorage} from 'common/in_memory_storage'; |
| import {PersistentStore} from 'common/persistent_store'; |
| import {Timestamp} from 'common/time'; |
| import {UrlUtils} from 'common/url_utils'; |
| import {UserNotifier} from 'common/user_notifier'; |
| import {CrossToolProtocol} from 'cross_tool/cross_tool_protocol'; |
| import {Analytics} from 'logging/analytics'; |
| import {ProgressListener} from 'messaging/progress_listener'; |
| import { |
| AppFilesCollected, |
| AppFilesUploaded, |
| AppInitialized, |
| AppRefreshDumpsRequest, |
| AppResetRequest, |
| AppTraceViewRequest, |
| DarkModeToggled, |
| WinscopeEvent, |
| WinscopeEventType, |
| } from 'messaging/winscope_event'; |
| import {WinscopeEventListener} from 'messaging/winscope_event_listener'; |
| import {AdbConnection} from 'trace_collection/adb_connection'; |
| import {AdbFiles} from 'trace_collection/adb_files'; |
| import {ProxyConnection} from 'trace_collection/proxy_connection'; |
| import {iconDividerStyle} from 'viewers/components/styles/icon_divider.styles'; |
| import {ViewerInputMethodComponent} from 'viewers/components/viewer_input_method_component'; |
| import {ViewerMediaBasedComponent} from 'viewers/components/viewer_media_based_component'; |
| import {Viewer} from 'viewers/viewer'; |
| import {ViewerInputComponent} from 'viewers/viewer_input/viewer_input_component'; |
| import {ViewerJankCujsComponent} from 'viewers/viewer_jank_cujs/viewer_jank_cujs_component'; |
| import {ViewerProtologComponent} from 'viewers/viewer_protolog/viewer_protolog_component'; |
| import {ViewerSurfaceFlingerComponent} from 'viewers/viewer_surface_flinger/viewer_surface_flinger_component'; |
| import {ViewerTransactionsComponent} from 'viewers/viewer_transactions/viewer_transactions_component'; |
| import {ViewerTransitionsComponent} from 'viewers/viewer_transitions/viewer_transitions_component'; |
| import {ViewerViewCaptureComponent} from 'viewers/viewer_view_capture/viewer_view_capture_component'; |
| import {ViewerWindowManagerComponent} from 'viewers/viewer_window_manager/viewer_window_manager_component'; |
| import {CollectTracesComponent} from './collect_traces_component'; |
| import {ShortcutsComponent} from './shortcuts_component'; |
| import {SnackBarOpener} from './snack_bar_opener'; |
| import {TimelineComponent} from './timeline/timeline_component'; |
| import {TraceViewComponent} from './trace_view_component'; |
| import {UploadTracesComponent} from './upload_traces_component'; |
| |
| @Component({ |
| selector: 'app-root', |
| encapsulation: ViewEncapsulation.None, |
| template: ` |
| <mat-toolbar class="toolbar"> |
| <div class="horizontal-align vertical-align"> |
| <img class="app-title fixed" [src]="getLogoUrl()"/> |
| </div> |
| |
| <div class="horizontal-align vertical-align"> |
| <div *ngIf="showDataLoadedElements" class="download-files-section"> |
| <div class="file-descriptor vertical-align"> |
| <button |
| mat-icon-button |
| *ngIf="showCrossToolSyncButton()" |
| [matTooltip]="getCrossToolSyncTooltip()" |
| class="cross-tool-sync-button" |
| (click)="onCrossToolSyncButtonClick()" |
| [color]="getCrossToolSyncButtonColor()"> |
| <mat-icon class="material-symbols-outlined">cloud_sync</mat-icon> |
| </button> |
| <span *ngIf="!isEditingFilename" class="download-file-info mat-body-2"> |
| {{ filenameFormControl.value }} |
| </span> |
| <span *ngIf="!isEditingFilename" class="download-file-ext mat-body-2">.zip</span> |
| <mat-form-field |
| class="file-name-input-field" |
| *ngIf="isEditingFilename" |
| floatLabel="always" |
| (keydown.enter)="onCheckIconClick()" |
| (focusout)="onCheckIconClick()" |
| matTooltip="Allowed: A-Z a-z 0-9 . _ - #"> |
| <mat-label>Edit file name</mat-label> |
| <input matInput class="right-align" [formControl]="filenameFormControl" /> |
| <span matSuffix>.zip</span> |
| </mat-form-field> |
| <button |
| *ngIf="isEditingFilename" |
| mat-icon-button |
| class="check-button" |
| matTooltip="Submit file name" |
| (click)="onCheckIconClick()"> |
| <mat-icon>check</mat-icon> |
| </button> |
| <button |
| *ngIf="!isEditingFilename" |
| mat-icon-button |
| class="edit-button" |
| matTooltip="Edit file name" |
| (click)="onPencilIconClick()"> |
| <mat-icon>edit</mat-icon> |
| </button> |
| <button |
| mat-icon-button |
| [disabled]="isEditingFilename" |
| matTooltip="Download all traces" |
| class="save-button" |
| (click)="onDownloadTracesButtonClick()"> |
| <mat-icon class="material-symbols-outlined">download</mat-icon> |
| </button> |
| </div> |
| <mat-progress-bar |
| *ngIf="downloadProgress !== undefined" |
| mode="determinate" |
| [value]="downloadProgress"> |
| </mat-progress-bar> |
| </div> |
| |
| <div *ngIf="showDataLoadedElements" class="icon-divider toolbar-icon-divider"></div> |
| <button |
| *ngIf="showDataLoadedElements && dumpsUploaded()" |
| color="primary" |
| mat-icon-button |
| matTooltip="Refresh dumps" |
| class="refresh-dumps" |
| (click)="onRefreshDumpsButtonClick()"> |
| <mat-icon class="material-symbols-outlined">refresh</mat-icon> |
| </button> |
| <button |
| *ngIf="showDataLoadedElements" |
| mat-icon-button |
| matTooltip="Upload or collect new trace" |
| class="upload-new" |
| (click)="onUploadNewButtonClick()"> |
| <mat-icon class="material-symbols-outlined">upload</mat-icon> |
| </button> |
| |
| <button |
| mat-icon-button |
| matTooltip="Shortcuts" |
| class="shortcuts" |
| (click)="openShortcutsPanel()"> |
| <mat-icon>keyboard_command_key</mat-icon> |
| </button> |
| |
| <button |
| mat-icon-button |
| matTooltip="Documentation" |
| class="documentation" |
| (click)="goToDocumentation()"> |
| <mat-icon>menu_book</mat-icon> |
| </button> |
| |
| <button |
| mat-icon-button |
| class="report-bug" |
| matTooltip="Report bug" |
| (click)="goToBuganizer()"> |
| <mat-icon>bug_report</mat-icon> |
| </button> |
| |
| <button |
| mat-icon-button |
| class="dark-mode" |
| matTooltip="Switch to {{ isDarkModeOn ? 'light' : 'dark' }} mode" |
| (click)="toggleDarkMode()"> |
| <mat-icon> |
| {{ isDarkModeOn ? 'brightness_5' : 'brightness_4' }} |
| </mat-icon> |
| </button> |
| </div> |
| </mat-toolbar> |
| |
| <mat-divider></mat-divider> |
| |
| <mat-drawer-container autosize disableClose autoFocus> |
| <mat-drawer-content> |
| <ng-container *ngIf="dataLoaded; else noLoadedTracesBlock"> |
| <trace-view class="viewers" [viewers]="viewers" [store]="store"></trace-view> |
| |
| <mat-divider></mat-divider> |
| </ng-container> |
| </mat-drawer-content> |
| |
| <mat-drawer #drawer mode="overlay" opened="true" [baseHeight]="collapsedTimelineHeight"> |
| <timeline |
| *ngIf="dataLoaded" |
| [allTraces]="tracePipeline.getTraces()" |
| [timelineData]="timelineData" |
| [store]="store" |
| (collapsedTimelineSizeChanged)="onCollapsedTimelineSizeChanged($event)"></timeline> |
| </mat-drawer> |
| </mat-drawer-container> |
| |
| <ng-template #noLoadedTracesBlock> |
| <div class="center"> |
| <div class="landing-content"> |
| <h1 class="welcome-info mat-headline"> |
| Welcome to Winscope. Please select source to view traces. |
| </h1> |
| |
| <div class="card-grid landing-grid"> |
| <collect-traces |
| class="collect-traces-card homepage-card" |
| [storage]="traceConfigStorage" |
| [adbConnection]="adbConnection" |
| (filesCollected)="onFilesCollected($event)"></collect-traces> |
| |
| <upload-traces |
| #uploadTraces |
| class="upload-traces-card homepage-card" |
| [tracePipeline]="tracePipeline" |
| (filesUploaded)="onFilesUploaded($event)" |
| (viewTracesButtonClick)="onViewTracesButtonClick()" |
| (downloadTracesClick)="onDownloadTracesButtonClick(uploadTraces)"></upload-traces> |
| </div> |
| </div> |
| </div> |
| </ng-template> |
| `, |
| styles: [ |
| ` |
| .toolbar { |
| gap: 10px; |
| justify-content: space-between; |
| min-height: 64px; |
| } |
| .app-title { |
| height: 100%; |
| } |
| .welcome-info { |
| margin: 16px 0 6px 0; |
| text-align: center; |
| } |
| .homepage-card { |
| display: flex; |
| flex-direction: column; |
| flex: 1; |
| overflow: auto; |
| height: 820px; |
| } |
| .horizontal-align { |
| justify-content: center; |
| } |
| .vertical-align { |
| text-align: center; |
| align-items: center; |
| overflow-x: hidden; |
| display: flex; |
| } |
| .fixed { |
| min-width: fit-content; |
| } |
| .download-files-section { |
| overflow-x: hidden; |
| } |
| .file-descriptor { |
| font-size: 14px; |
| padding-left: 10px; |
| max-width: 700px; |
| } |
| .download-file-info { |
| text-overflow: ellipsis; |
| overflow-x: hidden; |
| padding-top: 3px; |
| max-width: 650px; |
| } |
| .download-file-ext { |
| padding-top: 3px; |
| } |
| .file-name-input-field .right-align { |
| text-align: right; |
| } |
| .file-name-input-field .mat-form-field-wrapper { |
| padding-bottom: 10px; |
| width: 600px; |
| } |
| .toolbar-icon-divider { |
| margin-right: 6px; |
| margin-left: 6px; |
| height: 20px; |
| } |
| .viewers { |
| height: 0; |
| flex-grow: 1; |
| display: flex; |
| flex-direction: column; |
| overflow: auto; |
| } |
| .center { |
| display: flex; |
| align-content: center; |
| flex-direction: column; |
| justify-content: center; |
| align-items: center; |
| justify-items: center; |
| flex-grow: 1; |
| } |
| .landing-content { |
| width: 100%; |
| } |
| .landing-content .card-grid { |
| max-width: 1800px; |
| flex-grow: 1; |
| margin: auto; |
| } |
| `, |
| iconDividerStyle, |
| ], |
| }) |
| export class AppComponent implements WinscopeEventListener { |
| title = 'winscope'; |
| timelineData = new TimelineData(); |
| abtChromeExtensionProtocol = new AbtChromeExtensionProtocol(); |
| crossToolProtocol: CrossToolProtocol; |
| dataLoaded = false; |
| showDataLoadedElements = false; |
| collapsedTimelineHeight = 0; |
| isEditingFilename = false; |
| store = new PersistentStore(); |
| viewers: Viewer[] = []; |
| |
| isDarkModeOn = false; |
| changeDetectorRef: ChangeDetectorRef; |
| tracePipeline: TracePipeline; |
| mediator: Mediator; |
| currentTimestamp?: Timestamp; |
| filenameFormControl = new FormControl( |
| 'winscope', |
| Validators.compose([ |
| Validators.required, |
| Validators.pattern(FileUtils.DOWNLOAD_FILENAME_REGEX), |
| ]), |
| ); |
| adbConnection: AdbConnection = new ProxyConnection(); |
| traceConfigStorage: Storage; |
| downloadProgress: number | undefined; |
| |
| @ViewChild(UploadTracesComponent) |
| uploadTracesComponent?: UploadTracesComponent; |
| @ViewChild(CollectTracesComponent) |
| collectTracesComponent?: CollectTracesComponent; |
| @ViewChild(TraceViewComponent) traceViewComponent?: TraceViewComponent; |
| @ViewChild(TimelineComponent) timelineComponent?: TimelineComponent; |
| |
| constructor( |
| @Inject(Injector) injector: Injector, |
| @Inject(ChangeDetectorRef) changeDetectorRef: ChangeDetectorRef, |
| @Inject(SnackBarOpener) snackbarOpener: SnackBarOpener, |
| @Inject(Title) private pageTitle: Title, |
| @Inject(NgZone) private ngZone: NgZone, |
| @Inject(MatDialog) private dialog: MatDialog, |
| ) { |
| this.changeDetectorRef = changeDetectorRef; |
| UserNotifier.setSnackBarOpener(snackbarOpener); |
| this.tracePipeline = new TracePipeline(); |
| this.crossToolProtocol = new CrossToolProtocol( |
| this.tracePipeline.getTimestampConverter(), |
| ); |
| this.mediator = new Mediator( |
| this.tracePipeline, |
| this.timelineData, |
| this.abtChromeExtensionProtocol, |
| this.crossToolProtocol, |
| this, |
| localStorage, |
| ); |
| |
| const storeDarkMode = this.store.get('dark-mode'); |
| const prefersDarkQuery = window.matchMedia?.( |
| '(prefers-color-scheme: dark)', |
| ); |
| this.setDarkMode( |
| storeDarkMode ? storeDarkMode === 'true' : prefersDarkQuery.matches, |
| ); |
| |
| if (!customElements.get('viewer-input-method')) { |
| customElements.define( |
| 'viewer-input-method', |
| createCustomElement(ViewerInputMethodComponent, {injector}), |
| ); |
| } |
| if (!customElements.get('viewer-protolog')) { |
| customElements.define( |
| 'viewer-protolog', |
| createCustomElement(ViewerProtologComponent, {injector}), |
| ); |
| } |
| if (!customElements.get('viewer-media-based')) { |
| customElements.define( |
| 'viewer-media-based', |
| createCustomElement(ViewerMediaBasedComponent, {injector}), |
| ); |
| } |
| if (!customElements.get('viewer-surface-flinger')) { |
| customElements.define( |
| 'viewer-surface-flinger', |
| createCustomElement(ViewerSurfaceFlingerComponent, {injector}), |
| ); |
| } |
| if (!customElements.get('viewer-transactions')) { |
| customElements.define( |
| 'viewer-transactions', |
| createCustomElement(ViewerTransactionsComponent, {injector}), |
| ); |
| } |
| if (!customElements.get('viewer-window-manager')) { |
| customElements.define( |
| 'viewer-window-manager', |
| createCustomElement(ViewerWindowManagerComponent, {injector}), |
| ); |
| } |
| if (!customElements.get('viewer-transitions')) { |
| customElements.define( |
| 'viewer-transitions', |
| createCustomElement(ViewerTransitionsComponent, {injector}), |
| ); |
| } |
| if (!customElements.get('viewer-view-capture')) { |
| customElements.define( |
| 'viewer-view-capture', |
| createCustomElement(ViewerViewCaptureComponent, {injector}), |
| ); |
| } |
| if (!customElements.get('viewer-jank-cujs')) { |
| customElements.define( |
| 'viewer-jank-cujs', |
| createCustomElement(ViewerJankCujsComponent, {injector}), |
| ); |
| } |
| if (!customElements.get('viewer-input')) { |
| customElements.define( |
| 'viewer-input', |
| createCustomElement(ViewerInputComponent, {injector}), |
| ); |
| } |
| |
| this.traceConfigStorage = |
| globalConfig.MODE === 'PROD' ? localStorage : new InMemoryStorage(); |
| |
| window.onunhandledrejection = (evt) => { |
| Analytics.Error.logGlobalException(evt.reason); |
| }; |
| } |
| |
| async ngAfterViewInit() { |
| await this.mediator.onWinscopeEvent(new AppInitialized()); |
| } |
| |
| ngAfterViewChecked() { |
| this.mediator.setUploadTracesComponent(this.uploadTracesComponent); |
| this.mediator.setCollectTracesComponent(this.collectTracesComponent); |
| this.mediator.setTraceViewComponent(this.traceViewComponent); |
| this.mediator.setTimelineComponent(this.timelineComponent); |
| } |
| |
| onCollapsedTimelineSizeChanged(height: number) { |
| this.collapsedTimelineHeight = height; |
| this.changeDetectorRef.detectChanges(); |
| } |
| |
| getLogoUrl(): string { |
| const logoPath = this.isDarkModeOn |
| ? 'logo_dark_mode.svg' |
| : 'logo_light_mode.svg'; |
| return UrlUtils.getRootUrl() + logoPath; |
| } |
| |
| async setDarkMode(enabled: boolean) { |
| document.body.classList.toggle('dark-mode', enabled); |
| this.store.add('dark-mode', `${enabled}`); |
| this.isDarkModeOn = enabled; |
| await this.mediator.onWinscopeEvent(new DarkModeToggled(enabled)); |
| } |
| |
| onPencilIconClick() { |
| this.isEditingFilename = true; |
| } |
| |
| onCheckIconClick() { |
| if (this.filenameFormControl.invalid) { |
| return; |
| } |
| this.isEditingFilename = false; |
| this.pageTitle.setTitle(`Winscope | ${this.filenameFormControl.value}`); |
| } |
| |
| async onDownloadTracesButtonClick(progressListener: ProgressListener = this) { |
| if (this.filenameFormControl.invalid) { |
| return; |
| } |
| const archiveBlob = |
| await this.tracePipeline.makeZipArchiveWithLoadedTraceFiles( |
| (perc: number) => { |
| progressListener.onProgressUpdate('Downloading', 90 * perc); |
| }, |
| ); |
| const archiveFilename = `${ |
| this.showDataLoadedElements |
| ? this.filenameFormControl.value |
| : this.tracePipeline.getDownloadArchiveFilename() |
| }.zip`; |
| await this.downloadTraces(archiveBlob, archiveFilename); |
| progressListener.onOperationFinished(true); |
| } |
| |
| async onFilesCollected(files: AdbFiles) { |
| await this.mediator.onWinscopeEvent(new AppFilesCollected(files)); |
| } |
| |
| async onFilesUploaded(files: File[]) { |
| await this.mediator.onWinscopeEvent(new AppFilesUploaded(files)); |
| } |
| |
| async onRefreshDumpsButtonClick() { |
| Analytics.Tracing.logRefreshDumps(); |
| await this.mediator.onWinscopeEvent(new AppRefreshDumpsRequest()); |
| } |
| |
| async onUploadNewButtonClick() { |
| await this.mediator.onWinscopeEvent(new AppResetRequest()); |
| this.store.clear('treeView'); |
| } |
| |
| async onViewTracesButtonClick() { |
| await this.mediator.onWinscopeEvent(new AppTraceViewRequest()); |
| } |
| |
| onProgressUpdate(message: string, progressPercentage: number | undefined) { |
| this.ngZone.run(() => { |
| this.downloadProgress = progressPercentage; |
| }); |
| } |
| |
| onOperationFinished(success: boolean) { |
| this.ngZone.run(() => { |
| this.downloadProgress = undefined; |
| }); |
| } |
| |
| async downloadTraces(blob: Blob, filename: string) { |
| const a = document.createElement('a'); |
| document.body.appendChild(a); |
| const url = window.URL.createObjectURL(blob); |
| a.href = url; |
| a.download = filename; |
| a.click(); |
| window.URL.revokeObjectURL(url); |
| document.body.removeChild(a); |
| } |
| |
| async onWinscopeEvent(event: WinscopeEvent) { |
| await event.visit(WinscopeEventType.VIEWERS_LOADED, async (event) => { |
| this.viewers = event.viewers; |
| this.filenameFormControl.setValue( |
| this.tracePipeline.getDownloadArchiveFilename(), |
| ); |
| this.pageTitle.setTitle(`Winscope | ${this.filenameFormControl.value}`); |
| this.isEditingFilename = false; |
| |
| // some elements e.g. timeline require dataLoaded to be set outside NgZone to render |
| this.dataLoaded = true; |
| this.changeDetectorRef.detectChanges(); |
| |
| // tooltips must be rendered inside ngZone due to limitation of MatTooltip, |
| // therefore toolbar elements controlled by a different boolean |
| this.ngZone.run(() => { |
| this.showDataLoadedElements = true; |
| }); |
| }); |
| |
| await event.visit(WinscopeEventType.VIEWERS_UNLOADED, async (event) => { |
| this.dataLoaded = false; |
| this.showDataLoadedElements = false; |
| this.pageTitle.setTitle('Winscope'); |
| this.changeDetectorRef.detectChanges(); |
| }); |
| } |
| |
| openShortcutsPanel() { |
| this.dialog.open(ShortcutsComponent, { |
| height: 'fit-content', |
| maxWidth: '860px', |
| }); |
| } |
| |
| goToDocumentation() { |
| Analytics.Help.logDocumentationOpened(); |
| this.goToLink( |
| 'https://source.android.com/docs/core/graphics/tracing-win-transitions', |
| ); |
| } |
| |
| goToBuganizer() { |
| Analytics.Help.logBuganizerOpened(); |
| this.goToLink('https://b.corp.google.com/issues/new?component=909476'); |
| } |
| |
| toggleDarkMode() { |
| if (!this.isDarkModeOn) { |
| Analytics.Settings.logDarkModeEnabled(); |
| } |
| this.setDarkMode(!this.isDarkModeOn); |
| } |
| |
| dumpsUploaded() { |
| return !this.timelineData.hasMoreThanOneDistinctTimestamp(); |
| } |
| |
| showCrossToolSyncButton() { |
| return this.crossToolProtocol.isConnected(); |
| } |
| |
| getCrossToolSyncTooltip() { |
| const currStatus = this.crossToolProtocol.getAllowTimestampSync(); |
| |
| return `Cross Tool Sync ${this.translateStatus( |
| currStatus, |
| )} (Click to turn ${this.translateStatus(!currStatus)})`; |
| } |
| |
| onCrossToolSyncButtonClick() { |
| this.crossToolProtocol.setAllowTimestampSync( |
| !this.crossToolProtocol.getAllowTimestampSync(), |
| ); |
| Analytics.Settings.logCrossToolSync( |
| this.crossToolProtocol.getAllowTimestampSync(), |
| ); |
| } |
| |
| getCrossToolSyncButtonColor() { |
| return this.crossToolProtocol.getAllowTimestampSync() |
| ? 'primary' |
| : 'accent'; |
| } |
| |
| private goToLink(url: string) { |
| window.open(url, '_blank'); |
| } |
| |
| private translateStatus(status: boolean) { |
| return status ? 'ON' : 'OFF'; |
| } |
| } |