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 ElementRef, 21 Inject, 22 Input, 23 NgZone, 24 SimpleChanges, 25} from '@angular/core'; 26import {FormControl, ValidationErrors, Validators} from '@angular/forms'; 27import {overlayPanelStyles} from 'app/styles/overlay_panel.styles'; 28import {assertDefined} from 'common/assert_utils'; 29import {FunctionUtils} from 'common/function_utils'; 30import {Store} from 'common/store/store'; 31import {Analytics} from 'logging/analytics'; 32import { 33 FilterPresetApplyRequest, 34 FilterPresetSaveRequest, 35 TabbedViewSwitched, 36 WinscopeEvent, 37 WinscopeEventType, 38} from 'messaging/winscope_event'; 39import { 40 EmitEvent, 41 WinscopeEventEmitter, 42} from 'messaging/winscope_event_emitter'; 43import {WinscopeEventListener} from 'messaging/winscope_event_listener'; 44import {TRACE_INFO} from 'trace/trace_info'; 45import {TraceType} from 'trace/trace_type'; 46import {inlineButtonStyle} from 'viewers/components/styles/clickable_property.styles'; 47import {View, Viewer, ViewType} from 'viewers/viewer'; 48 49interface Tab { 50 view: View; 51 addedToDom: boolean; 52 isTooltipStable: boolean; 53} 54 55@Component({ 56 selector: 'trace-view', 57 template: ` 58 <div class="overlay-container"> 59 </div> 60 <div class="header-items-wrapper"> 61 <div class="trace-tabs-wrapper header-items-wrapper"> 62 <nav mat-tab-nav-bar class="tabs-navigation-bar"> 63 <a 64 *ngFor="let tab of tabs; last as isLast" 65 mat-tab-link 66 [active]="isCurrentActiveTab(tab)" 67 [class.active]="isCurrentActiveTab(tab)" 68 [matTooltip]="getTabTooltip(tab.view)" 69 matTooltipPosition="above" 70 [matTooltipShowDelay]="300" 71 [matTooltipDisabled]="!tab.isTooltipStable" 72 (click)="onTabClick(tab)" 73 (focus)="$event.target.blur()" 74 (mouseenter)="onTabHover($event, tab)" 75 [class.last]="isLast" 76 class="tab"> 77 <mat-icon 78 class="icon" 79 [style]="{color: getTabIconColor(tab), marginRight: '0.5rem'}"> 80 {{ getTabIcon(tab) }} 81 </mat-icon> 82 <span> 83 {{ getTitle(tab.view) }} 84 </span> 85 </a> 86 </nav> 87 </div> 88 89 <button 90 [disabled]="!currentTabHasFilterPresets()" 91 mat-flat-button 92 cdkOverlayOrigin 93 #filterPresetsTrigger="cdkOverlayOrigin" 94 color="primary" 95 class="filter-presets" 96 (click)="onFilterPresetsClick()"> 97 <span class="filter-presets-label"> 98 <mat-icon class="material-symbols-outlined">save</mat-icon> 99 <span> Filter Presets </span> 100 </span> 101 </button> 102 103 <ng-template 104 cdkConnectedOverlay 105 [cdkConnectedOverlayOrigin]="filterPresetsTrigger" 106 [cdkConnectedOverlayOpen]="isFilterPresetsPanelOpen" 107 [cdkConnectedOverlayHasBackdrop]="true" 108 cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" 109 (backdropClick)="onFilterPresetsClick()" 110 > 111 <div class="overlay-panel filter-presets-panel"> 112 <h2 class="overlay-panel-title"> 113 <span> FILTER PRESETS </span> 114 <button (click)="onFilterPresetsClick()" class="close-button" mat-icon-button> 115 <mat-icon> close </mat-icon> 116 </button> 117 </h2> 118 <div class="overlay-panel-content"> 119 <span class="mat-body-1"> Save the current configuration of filters for this trace type to access later, or select one of the existing configurations below. </span> 120 121 <div class="overlay-panel-section save-section"> 122 <span class="mat-body-2 overlay-panel-section-title"> Preset Name </span> 123 <div class="save-field outline-field"> 124 <mat-form-field appearance="outline"> 125 <input matInput [formControl]="filterPresetNameControl" (keydown.enter)="savePreset()"/> 126 <mat-error *ngIf="filterPresetNameControl.invalid && filterPresetNameControl.value">Preset with that name already exists.</mat-error> 127 </mat-form-field> 128 <button mat-flat-button color="primary" [disabled]="filterPresetNameControl.invalid" (click)="savePreset()"> Save </button> 129 </div> 130 </div> 131 132 <mat-divider></mat-divider> 133 134 <div class="overlay-panel-section existing-presets-section"> 135 <span class="mat-body-2 overlay-panel-section-title"> Apply a preset </span> 136 <span class="mat-body-1" *ngIf="getCurrentFilterPresets().length === 0"> No existing presets found. </span> 137 <div *ngFor="let preset of getCurrentFilterPresets()" class="existing-preset inline"> 138 <button 139 mat-button 140 color="primary" 141 (click)="onExistingPresetClick(preset)"> 142 {{ preset.split(".")[0] }} 143 </button> 144 <button mat-icon-button class="delete-button" (click)="deletePreset(preset)"> 145 <mat-icon class="material-symbols-outlined"> delete </mat-icon> 146 </button> 147 </div> 148 </div> 149 </div> 150 </div> 151 </ng-template> 152 </div> 153 <mat-divider></mat-divider> 154 <div class="trace-view-content"></div> 155 `, 156 styles: [ 157 ` 158 .tab.active { 159 opacity: 100%; 160 } 161 162 .header-items-wrapper { 163 display: flex; 164 flex-direction: row; 165 justify-content: space-between; 166 align-items: center; 167 } 168 169 .trace-tabs-wrapper { 170 overflow-x: auto; 171 } 172 173 .tabs-navigation-bar { 174 height: 100%; 175 border-bottom: 0px; 176 } 177 178 .trace-view-content { 179 height: 100%; 180 overflow: auto; 181 background-color: var(--trace-view-background-color); 182 } 183 184 .tab { 185 overflow-x: hidden; 186 text-overflow: ellipsis; 187 } 188 189 .tab:not(.last):after { 190 content: ''; 191 position: absolute; 192 right: 0; 193 height: 60%; 194 width: 1px; 195 background-color: #C4C0C0; 196 } 197 198 .filter-presets { 199 line-height: 24px; 200 padding: 0 10px; 201 margin-inline: 10px; 202 min-width: fit-content; 203 min-height: fit-content; 204 } 205 206 .filter-presets-label { 207 display: flex; 208 flex-direction: row; 209 align-items: center; 210 } 211 212 .filter-presets-label .mat-icon { 213 margin-inline-end: 5px; 214 } 215 216 .filter-presets-panel { 217 max-width: 440px; 218 max-height: 500px; 219 overflow-y: auto; 220 border-radius: 15px; 221 } 222 223 .existing-preset { 224 display: flex; 225 flex-direction: row; 226 justify-content: space-between; 227 align-items: center; 228 width: 100%: 229 } 230 231 .existing-preset:hover { 232 background-color: var(--hover-element-color); 233 } 234 235 .existing-preset:not(:hover) .delete-button { 236 opacity: 0.5; 237 } 238 `, 239 overlayPanelStyles, 240 inlineButtonStyle, 241 ], 242}) 243export class TraceViewComponent 244 implements WinscopeEventEmitter, WinscopeEventListener 245{ 246 @Input() viewers: Viewer[] = []; 247 @Input() store: Store | undefined; 248 249 TRACE_INFO = TRACE_INFO; 250 tabs: Tab[] = []; 251 isFilterPresetsPanelOpen = false; 252 filterPresetNameControl = new FormControl( 253 '', 254 assertDefined( 255 Validators.compose([ 256 Validators.required, 257 (control: FormControl) => 258 this.validateFilterPresetName( 259 control, 260 this.allFilterPresets, 261 (input: string) => 262 this.makeFilterPresetName( 263 input, 264 assertDefined(this.getCurrentTabTraceType()), 265 ), 266 ), 267 ]), 268 ), 269 ); 270 271 private currentActiveTab: undefined | Tab; 272 private emitAppEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC; 273 private filterPresetsStoreKey = 'filterPresets'; 274 private allFilterPresets: string[] = []; 275 276 constructor( 277 @Inject(ElementRef) private elementRef: ElementRef, 278 @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef, 279 @Inject(NgZone) private ngZone: NgZone, 280 ) {} 281 282 ngOnChanges(changes: SimpleChanges) { 283 if (changes['store']?.firstChange) { 284 const storedPresets = this.store?.get(this.filterPresetsStoreKey); 285 if (storedPresets) { 286 this.allFilterPresets = JSON.parse(storedPresets); 287 } 288 } 289 this.renderViewsTab(changes['viewers']?.firstChange ?? false); 290 this.renderViewsOverlay(); 291 } 292 293 getTabIconColor(tab: Tab): string { 294 if (tab.view.type === ViewType.GLOBAL_SEARCH) return ''; 295 const trace = tab.view.traces.at(0); 296 if (!trace) return ''; 297 return TRACE_INFO[trace.type].color; 298 } 299 300 getTabIcon(tab: Tab): string { 301 if (tab.view.type === ViewType.GLOBAL_SEARCH) { 302 return TRACE_INFO[TraceType.SEARCH].icon; 303 } 304 const trace = tab.view.traces.at(0); 305 if (!trace) return ''; 306 return TRACE_INFO[trace.type].icon; 307 } 308 309 onTabHover(event: MouseEvent, tab: Tab) { 310 if (tab.isTooltipStable) { 311 return; 312 } 313 this.ngZone.run(() => { 314 (event.target as HTMLElement).dispatchEvent(new Event('mouseleave')); 315 tab.isTooltipStable = true; 316 this.changeDetectorRef.detectChanges(); 317 (event.target as HTMLElement)?.dispatchEvent(new Event('mouseenter')); 318 }); 319 } 320 321 async onTabClick(tab: Tab) { 322 await this.showTab(tab, false); 323 } 324 325 async onWinscopeEvent(event: WinscopeEvent) { 326 await event.visit( 327 WinscopeEventType.TABBED_VIEW_SWITCH_REQUEST, 328 async (event) => { 329 const tab = this.tabs.find((tab) => 330 tab.view.traces.some((trace) => trace === event.newActiveTrace), 331 ); 332 await this.showTab(assertDefined(tab), false); 333 }, 334 ); 335 } 336 337 setEmitEvent(callback: EmitEvent) { 338 this.emitAppEvent = callback; 339 } 340 341 isCurrentActiveTab(tab: Tab) { 342 return tab === this.currentActiveTab; 343 } 344 345 getTabTooltip(view: View): string { 346 const desc = new Set(); 347 view.traces.forEach((trace) => 348 trace.getDescriptors().forEach((d) => desc.add(d)), 349 ); 350 return Array.from(desc).join(', '); 351 } 352 353 getTitle(view: View): string { 354 const isDump = view.traces.length === 1 && view.traces.at(0)?.isDump(); 355 return view.title + (isDump ? ' Dump' : ''); 356 } 357 358 getCurrentFilterPresets(): string[] { 359 const currentTabTraceType = this.getCurrentTabTraceType(); 360 if (currentTabTraceType === undefined) return []; 361 return this.allFilterPresets.filter((preset) => 362 preset.includes(TRACE_INFO[currentTabTraceType].name), 363 ); 364 } 365 366 onFilterPresetsClick() { 367 this.ngZone.run(() => { 368 this.isFilterPresetsPanelOpen = !this.isFilterPresetsPanelOpen; 369 this.changeDetectorRef.detectChanges(); 370 }); 371 } 372 373 async savePreset() { 374 if (this.filterPresetNameControl.invalid) return; 375 await this.ngZone.run(async () => { 376 const value = assertDefined(this.filterPresetNameControl.value); 377 const currentTabTraceType = assertDefined(this.getCurrentTabTraceType()); 378 const presetName = this.makeFilterPresetName(value, currentTabTraceType); 379 380 this.allFilterPresets.push(presetName); 381 if (this.store) { 382 this.store?.add( 383 this.filterPresetsStoreKey, 384 JSON.stringify(this.allFilterPresets), 385 ); 386 } 387 388 this.filterPresetNameControl.reset(); 389 this.changeDetectorRef.detectChanges(); 390 await this.emitAppEvent( 391 new FilterPresetSaveRequest(presetName, currentTabTraceType), 392 ); 393 }); 394 } 395 396 onExistingPresetClick(preset: string) { 397 this.emitAppEvent( 398 new FilterPresetApplyRequest( 399 preset, 400 assertDefined(this.getCurrentTabTraceType()), 401 ), 402 ); 403 } 404 405 deletePreset(preset: string) { 406 this.allFilterPresets = this.allFilterPresets.filter((p) => p !== preset); 407 this.store?.clear(preset); 408 this.store?.add( 409 this.filterPresetsStoreKey, 410 JSON.stringify(this.allFilterPresets), 411 ); 412 this.filterPresetNameControl.updateValueAndValidity(); 413 this.changeDetectorRef.detectChanges(); 414 } 415 416 currentTabHasFilterPresets(): boolean { 417 const currentTabTraceType = this.getCurrentTabTraceType(); 418 return ( 419 currentTabTraceType !== undefined && 420 [ 421 TraceType.SURFACE_FLINGER, 422 TraceType.WINDOW_MANAGER, 423 TraceType.INPUT_METHOD_CLIENTS, 424 TraceType.INPUT_METHOD_MANAGER_SERVICE, 425 TraceType.INPUT_METHOD_SERVICE, 426 TraceType.VIEW_CAPTURE, 427 ].includes(currentTabTraceType) 428 ); 429 } 430 431 private getCurrentTabTraceType(): TraceType | undefined { 432 return this.currentActiveTab?.view.traces.at(0)?.type; 433 } 434 435 private renderViewsTab(firstToRender: boolean) { 436 this.tabs = this.viewers 437 .map((viewer) => viewer.getViews()) 438 .flat() 439 .filter((view) => view.type !== ViewType.OVERLAY) 440 .map((view) => { 441 return { 442 view, 443 addedToDom: false, 444 isTooltipStable: false, 445 }; 446 }); 447 448 if (this.tabs.length > 0) { 449 const tabToShow = assertDefined( 450 this.tabs.find((tab) => tab.view.type !== ViewType.GLOBAL_SEARCH), 451 ); 452 this.showTab(tabToShow, firstToRender); 453 } 454 } 455 456 private renderViewsOverlay() { 457 const views: View[] = this.viewers 458 .map((viewer) => viewer.getViews()) 459 .flat() 460 .filter((view) => view.type === ViewType.OVERLAY); 461 462 if (views.length > 1) { 463 throw new Error( 464 'Only one overlay view is supported. To allow more overlay views, either create more than' + 465 ' one draggable containers in this component or move the cdkDrag directives into the' + 466 " overlay view when the new Angular's directive composition API is available" + 467 ' (https://github.com/angular/angular/issues/8785).', 468 ); 469 } 470 471 views.forEach((view) => { 472 view.htmlElement.style.pointerEvents = 'all'; 473 const container = assertDefined( 474 this.elementRef.nativeElement.querySelector('.overlay-container'), 475 ); 476 container.appendChild(view.htmlElement); 477 }); 478 } 479 480 private async showTab(tab: Tab, firstToRender: boolean) { 481 const startTimeMs = Date.now(); 482 if (this.currentActiveTab) { 483 this.currentActiveTab.view.htmlElement.style.display = 'none'; 484 } 485 486 const firstSwitch = !tab.addedToDom; 487 if (firstSwitch) { 488 // Workaround for b/255966194: 489 // make sure that the first time a tab content is rendered 490 // (added to the DOM) it has style.display == "". This fixes the 491 // initialization/rendering issues with cdk-virtual-scroll-viewport 492 // components inside the tab contents. 493 const traceViewContent = assertDefined( 494 this.elementRef.nativeElement.querySelector('.trace-view-content'), 495 ); 496 traceViewContent.appendChild(tab.view.htmlElement); 497 tab.addedToDom = true; 498 } else { 499 tab.view.htmlElement.style.display = ''; 500 } 501 502 this.currentActiveTab = tab; 503 504 if (!firstToRender) { 505 await this.emitAppEvent(new TabbedViewSwitched(tab.view)); 506 Analytics.Navigation.logTabSwitched( 507 tab.view.title, 508 Date.now() - startTimeMs, 509 firstSwitch, 510 ); 511 } 512 if (firstSwitch) { 513 Analytics.Memory.logUsage('tab_initialized', {firstSwitch}); 514 } 515 } 516 517 private validateFilterPresetName( 518 control: FormControl, 519 filterPresets: string[], 520 makeFilterPresetName: (input: string) => string, 521 ): ValidationErrors | null { 522 const valid = 523 control.value && 524 !filterPresets.includes(makeFilterPresetName(control.value)); 525 return !valid ? {invalidInput: control.value} : null; 526 } 527 528 private makeFilterPresetName(input: string, traceType: TraceType) { 529 return input + '.' + TRACE_INFO[traceType].name; 530 } 531} 532