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