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