• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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