/*
* 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,
ElementRef,
EventEmitter,
HostListener,
Inject,
Input,
Output,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import {
AbstractControl,
FormControl,
FormGroup,
ValidationErrors,
ValidatorFn,
Validators,
} from '@angular/forms';
import {DomSanitizer, SafeUrl} from '@angular/platform-browser';
import {TimelineData} from 'app/timeline_data';
import {assertDefined} from 'common/assert_utils';
import {FunctionUtils} from 'common/function_utils';
import {PersistentStore} from 'common/persistent_store';
import {StringUtils} from 'common/string_utils';
import {TimeRange, Timestamp} from 'common/time';
import {TimestampUtils} from 'common/timestamp_utils';
import {Analytics} from 'logging/analytics';
import {
ActiveTraceChanged,
ExpandedTimelineToggled,
TracePositionUpdate,
WinscopeEvent,
WinscopeEventType,
} from 'messaging/winscope_event';
import {
EmitEvent,
WinscopeEventEmitter,
} from 'messaging/winscope_event_emitter';
import {WinscopeEventListener} from 'messaging/winscope_event_listener';
import {Trace} from 'trace/trace';
import {TRACE_INFO} from 'trace/trace_info';
import {TracePosition} from 'trace/trace_position';
import {TraceType, TraceTypeUtils} from 'trace/trace_type';
import {multlineTooltip} from 'viewers/components/styles/tooltip.styles';
import {MiniTimelineComponent} from './mini-timeline/mini_timeline_component';
@Component({
selector: 'timeline',
encapsulation: ViewEncapsulation.None,
template: `
expand_circle_up
expand_circle_down
No screenrecording frame to show
Current timestamp before first screenrecording frame.
chevron_left
chevron_right
Filter traces in the timeline
{{ TRACE_INFO[trace.type].icon }}
{{ TRACE_INFO[trace.type].name }}
Done
{{ TRACE_INFO[selectedTrace.type].icon }}
8">
more_horiz
No timeline to show!
All loaded traces contain no timestamps.
No timeline to show!
Only a single timestamp has been recorded.
`,
styles: [
`
.navbar-toggle {
display: flex;
flex-direction: column;
align-items: end;
position: relative;
}
#toggle {
width: fit-content;
position: absolute;
top: -41px;
right: 0px;
z-index: 1000;
border: 1px solid #3333;
border-bottom: 0px;
border-right: 0px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
background-color: var(--drawer-color);
}
.navbar {
display: flex;
width: 100%;
flex-direction: row;
align-items: center;
justify-content: center;
}
#expanded-nav {
display: flex;
flex-direction: row;
border-bottom: 1px solid #3333;
border-top: 1px solid #3333;
}
#time-selector {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 10px;
margin-left: 0.5rem;
height: 116px;
width: 282px;
background-color: var(--drawer-block-primary);
}
#time-selector .mat-form-field-wrapper {
width: 100%;
}
#time-selector .mat-form-field-infix, #trace-selector .mat-form-field-infix {
padding: 0 0.75rem 0 0.5rem !important;
border-top: unset;
}
#time-selector .mat-form-field-flex, #time-selector .field-suffix {
border-radius: 0;
padding: 0;
display: flex;
align-items: center;
}
.bookmark-icon {
cursor: pointer;
}
.time-selector-form {
display: flex;
flex-direction: column;
height: 60px;
width: 90%;
justify-content: center;
align-items: center;
gap: 5px;
}
.time-selector-form mat-form-field {
margin-bottom: -1.34375em;
display: flex;
width: 100%;
font-size: 12px;
}
.time-selector-form input {
text-overflow: ellipsis;
font-weight: bold;
}
.time-selector-form .time-difference {
padding-right: 2px;
}
#time-selector .time-controls {
border-radius: 10px;
margin: 0.5rem;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 90%;
background-color: var(--drawer-block-secondary);
}
#time-selector .mat-icon-button {
width: 24px;
height: 24px;
padding-left: 3px;
padding-right: 3px;
}
#time-selector .mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
display: flex;
}
.shown-selection .trace-icon {
font-size: 18px;
width: 18px;
height: 18px;
padding-left: 4px;
padding-right: 4px;
padding-top: 2px;
}
#mini-timeline {
flex-grow: 1;
align-self: stretch;
}
#video-content {
position: relative;
min-width: 20rem;
min-height: 35rem;
align-self: stretch;
text-align: center;
border: 2px solid black;
flex-basis: 0px;
flex-grow: 1;
display: flex;
align-items: center;
}
#video {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
}
#expanded-timeline {
flex-grow: 1;
}
#trace-selector .mat-form-field-infix {
width: 80px;
}
#trace-selector .shown-selection {
height: 116px;
border-radius: 10px;
display: flex;
justify-content: center;
flex-wrap: wrap;
align-content: flex-start;
background-color: var(--drawer-block-primary);
}
#trace-selector .filter-header {
padding-top: 4px;
display: flex;
gap: 2px;
}
.shown-selection .trace-icons {
display: flex;
justify-content: center;
flex-wrap: wrap;
align-content: flex-start;
width: 70%;
}
#trace-selector .mat-select-trigger {
height: unset;
flex-direction: column-reverse;
}
#trace-selector .mat-select-arrow-wrapper {
display: none;
}
#trace-selector .mat-form-field-wrapper {
padding: 0;
}
:has(>.select-traces-panel) {
max-height: unset !important;
font-family: 'Roboto', sans-serif;
position: relative;
bottom: 120px;
}
.tip {
padding: 16px;
font-weight: 300;
}
.actions {
width: 100%;
padding: 1.5rem;
float: right;
display: flex;
justify-content: flex-end;
}
.no-video-message {
padding: 1rem;
font-family: 'Roboto', sans-serif;
}
.no-timestamps-msg {
padding: 1rem;
align-items: center;
display: flex;
flex-direction: column;
}
`,
multlineTooltip,
],
})
export class TimelineComponent
implements WinscopeEventEmitter, WinscopeEventListener
{
readonly TOGGLE_BUTTON_CLASS: string = 'button-toggle-expansion';
readonly MAX_SELECTED_TRACES = 3;
@Input() timelineData: TimelineData | undefined;
@Input() store: PersistentStore | undefined;
@Output() readonly collapsedTimelineSizeChanged = new EventEmitter();
@ViewChild('collapsedTimeline') private collapsedTimelineRef:
| ElementRef
| undefined;
@ViewChild('miniTimeline') miniTimeline: MiniTimelineComponent | undefined;
videoUrl: SafeUrl | undefined;
initialZoom: TimeRange | undefined = undefined;
selectedTraces: Array> = [];
sortedAvailableTraces: Array> = [];
selectedTracesFormControl = new FormControl>>([]);
selectedTimeFormControl = new FormControl('undefined');
selectedNsFormControl = new FormControl(
'undefined',
Validators.compose([Validators.required, this.validateNsFormat]),
);
timestampForm = new FormGroup({
selectedTime: this.selectedTimeFormControl,
selectedNs: this.selectedNsFormControl,
});
TRACE_INFO = TRACE_INFO;
isInputFormFocused = false;
storeKeyDeselectedTraces = 'miniTimeline.deselectedTraces';
bookmarks: Timestamp[] = [];
private expanded = false;
private emitEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC;
private expandedTimelineScrollEvent: WheelEvent | undefined;
private expandedTimelineMouseXRatio: number | undefined;
private seekTracePosition?: TracePosition;
constructor(
@Inject(DomSanitizer) private sanitizer: DomSanitizer,
@Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef,
) {}
ngOnInit() {
const timelineData = assertDefined(this.timelineData);
if (timelineData.hasTimestamps()) {
this.updateTimeInputValuesToCurrentTimestamp();
}
const converter = assertDefined(timelineData.getTimestampConverter());
const validatorFn: ValidatorFn = (control: AbstractControl) => {
const valid = converter.validateHumanInput(control.value ?? '');
return !valid ? {invalidInput: control.value} : null;
};
this.selectedTimeFormControl.addValidators(
assertDefined(Validators.compose([Validators.required, validatorFn])),
);
const screenRecordingVideo = timelineData.getScreenRecordingVideo();
if (screenRecordingVideo) {
this.videoUrl = this.sanitizer.bypassSecurityTrustUrl(
URL.createObjectURL(screenRecordingVideo),
);
}
// sorted to be displayed in order corresponding to viewer tabs
this.sortedAvailableTraces =
this.timelineData
?.getTraces()
.mapTrace((trace) => trace)
.sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type)) ??
[];
const storedDeselectedTraces = this.getStoredDeselectedTraceTypes();
this.selectedTraces = this.sortedAvailableTraces.filter((trace) => {
return !storedDeselectedTraces.includes(trace.type);
});
this.selectedTracesFormControl = new FormControl>>(
this.selectedTraces,
);
const initialTraceToCropZoom = this.sortedAvailableTraces.find((trace) => {
return (
trace.type !== TraceType.SCREEN_RECORDING &&
TraceTypeUtils.isTraceTypeWithViewer(trace.type) &&
trace.lengthEntries > 0
);
});
if (initialTraceToCropZoom) {
this.initialZoom = new TimeRange(
initialTraceToCropZoom.getEntry(0).getTimestamp(),
timelineData.getFullTimeRange().to,
);
}
}
ngAfterViewInit() {
const height = assertDefined(this.collapsedTimelineRef).nativeElement
.offsetHeight;
this.collapsedTimelineSizeChanged.emit(height);
}
setEmitEvent(callback: EmitEvent) {
this.emitEvent = callback;
}
getVideoCurrentTime() {
return assertDefined(
this.timelineData,
).searchCorrespondingScreenRecordingTimeSeconds(
this.getCurrentTracePosition(),
);
}
getCurrentTracePosition(): TracePosition {
if (this.seekTracePosition) {
return this.seekTracePosition;
}
const position = assertDefined(this.timelineData).getCurrentPosition();
if (position === undefined) {
throw Error(
'A trace position should be available by the time the timeline is loaded',
);
}
return position;
}
getSelectedTracesToShow(): Array> {
const sortedSelectedTraces = this.getSelectedTracesSortedByDisplayOrder();
return sortedSelectedTraces.length > 8
? sortedSelectedTraces.slice(0, 7)
: sortedSelectedTraces.slice(0, 8);
}
async onWinscopeEvent(event: WinscopeEvent) {
await event.visit(WinscopeEventType.TRACE_POSITION_UPDATE, async () => {
this.updateTimeInputValuesToCurrentTimestamp();
});
await event.visit(WinscopeEventType.ACTIVE_TRACE_CHANGED, async (event) => {
await this.miniTimeline?.drawer?.draw();
this.updateSelectedTraces(event.trace);
});
await event.visit(WinscopeEventType.DARK_MODE_TOGGLED, async (event) => {
const activeTrace = this.timelineData?.getActiveTrace();
if (activeTrace === undefined) {
return;
}
await this.miniTimeline?.drawer?.draw();
});
}
async toggleExpand() {
this.expanded = !this.expanded;
this.changeDetectorRef.detectChanges();
if (this.expanded) {
Analytics.Navigation.logExpandedTimelineOpened();
}
await this.emitEvent(new ExpandedTimelineToggled(this.expanded));
}
async updatePosition(position: TracePosition) {
assertDefined(this.timelineData).setPosition(position);
await this.emitEvent(new TracePositionUpdate(position));
}
updateSeekTimestamp(timestamp: Timestamp | undefined) {
if (timestamp) {
this.seekTracePosition = assertDefined(
this.timelineData,
).makePositionFromActiveTrace(timestamp);
} else {
this.seekTracePosition = undefined;
}
this.updateTimeInputValuesToCurrentTimestamp();
}
isOptionDisabled(trace: Trace) {
return this.timelineData?.getActiveTrace() === trace;
}
applyNewTraceSelection(clickedTrace: Trace) {
this.selectedTraces =
this.selectedTracesFormControl.value ?? this.sortedAvailableTraces;
this.updateStoredDeselectedTraceTypes(clickedTrace);
}
@HostListener('document:focusin', ['$event'])
handleFocusInEvent(event: FocusEvent) {
if (
(event.target as HTMLInputElement)?.tagName === 'INPUT' &&
(event.target as HTMLInputElement)?.type === 'text'
) {
//check if text input field focused
this.isInputFormFocused = true;
}
}
@HostListener('document:focusout', ['$event'])
handleFocusOutEvent(event: FocusEvent) {
if (
(event.target as HTMLInputElement)?.tagName === 'INPUT' &&
(event.target as HTMLInputElement)?.type === 'text'
) {
//check if text input field focused
this.isInputFormFocused = false;
}
}
@HostListener('document:keydown', ['$event'])
async handleKeyboardEvent(event: KeyboardEvent) {
if (
this.isInputFormFocused ||
!assertDefined(this.timelineData).hasTimestamps()
) {
return;
}
if (event.key === 'ArrowLeft') {
await this.moveToPreviousEntry();
} else if (event.key === 'ArrowRight') {
await this.moveToNextEntry();
}
}
hasPrevEntry(): boolean {
const activeTrace = this.timelineData?.getActiveTrace();
if (!activeTrace) {
return false;
}
return (
assertDefined(this.timelineData).getPreviousEntryFor(activeTrace) !==
undefined
);
}
hasNextEntry(): boolean {
const activeTrace = this.timelineData?.getActiveTrace();
if (!activeTrace) {
return false;
}
return (
assertDefined(this.timelineData).getNextEntryFor(activeTrace) !==
undefined
);
}
async moveToPreviousEntry() {
const activeTrace = this.timelineData?.getActiveTrace();
if (!activeTrace) {
return;
}
const timelineData = assertDefined(this.timelineData);
timelineData.moveToPreviousEntryFor(activeTrace);
const position = assertDefined(timelineData.getCurrentPosition());
await this.emitEvent(new TracePositionUpdate(position));
}
async moveToNextEntry() {
const activeTrace = this.timelineData?.getActiveTrace();
if (!activeTrace) {
return;
}
const timelineData = assertDefined(this.timelineData);
timelineData.moveToNextEntryFor(activeTrace);
const position = assertDefined(timelineData.getCurrentPosition());
await this.emitEvent(new TracePositionUpdate(position));
}
async onHumanTimeInputChange(event: Event) {
if (event.type !== 'change' || !this.selectedTimeFormControl.valid) {
return;
}
const target = event.target as HTMLInputElement;
let input = target.value;
// if hh:mm:ss.zz format, append date of current timestamp
if (TimestampUtils.isRealTimeOnlyFormat(input)) {
const date = assertDefined(
TimestampUtils.extractDateFromHumanTimestamp(
this.getCurrentTracePosition().timestamp.format(),
),
);
input = date + 'T' + input;
}
const timelineData = assertDefined(this.timelineData);
const timestamp = assertDefined(
timelineData.getTimestampConverter(),
).makeTimestampFromHuman(input);
Analytics.Navigation.logTimeInput('human');
await this.updatePosition(
timelineData.makePositionFromActiveTrace(timestamp),
);
this.updateTimeInputValuesToCurrentTimestamp();
}
async onNanosecondsInputTimeChange(event: Event) {
if (event.type !== 'change' || !this.selectedNsFormControl.valid) {
return;
}
const target = event.target as HTMLInputElement;
const timelineData = assertDefined(this.timelineData);
const timestamp = assertDefined(
timelineData.getTimestampConverter(),
).makeTimestampFromNs(StringUtils.parseBigIntStrippingUnit(target.value));
Analytics.Navigation.logTimeInput('ns');
await this.updatePosition(
timelineData.makePositionFromActiveTrace(timestamp),
);
this.updateTimeInputValuesToCurrentTimestamp();
}
onKeydownEnterTimeInputField(event: KeyboardEvent) {
if (this.selectedTimeFormControl.valid) {
(event.target as HTMLInputElement).blur();
}
}
onKeydownEnterNanosecondsTimeInputField(event: KeyboardEvent) {
if (this.selectedNsFormControl.valid) {
(event.target as HTMLInputElement).blur();
}
}
updateScrollEvent(event: WheelEvent) {
this.expandedTimelineScrollEvent = event;
}
updateExpandedTimelineMouseXRatio(mouseXRatio: number | undefined) {
this.expandedTimelineMouseXRatio = mouseXRatio;
}
getCopyPositionTooltip(position: string): string {
return `Copy current position:\n${position}`;
}
getHumanTimeTooltip(): string {
const [date, time] = this.getCurrentTracePosition()
.timestamp.format()
.split(', ');
return `
Date: ${date}
Time: ${time}\xa0\xa0\xa0\xa0${this.getUTCOffset()}
Edit field to update position by inputting time as
"hh:mm:ss.zz", "YYYY-MM-DDThh:mm:ss.zz", or "YYYY-MM-DD, hh:mm:ss.zz"
`;
}
getCopyHumanTimeTooltip(): string {
return this.getCopyPositionTooltip(this.getHumanTime());
}
getHumanTime(): string {
return this.getCurrentTracePosition().timestamp.format();
}
onTimeCopied(type: 'ns' | 'human') {
Analytics.Navigation.logTimeCopied(type);
}
getUTCOffset(): string {
return assertDefined(
this.timelineData?.getTimestampConverter(),
).getUTCOffset();
}
currentPositionBookmarked(): boolean {
const currentTimestampNs =
this.getCurrentTracePosition().timestamp.getValueNs();
return this.bookmarks.some((bm) => bm.getValueNs() === currentTimestampNs);
}
toggleBookmarkCurrentPosition(event: PointerEvent) {
const currentTimestamp = this.getCurrentTracePosition().timestamp;
this.toggleBookmarkRange(new TimeRange(currentTimestamp, currentTimestamp));
event.stopPropagation();
}
toggleBookmarkRange(range: TimeRange, rangeContainsBookmark?: boolean) {
if (rangeContainsBookmark === undefined) {
rangeContainsBookmark = this.bookmarks.some((bookmark) =>
range.containsTimestamp(bookmark),
);
}
const clickedNs = (range.from.getValueNs() + range.to.getValueNs()) / 2n;
if (rangeContainsBookmark) {
const closestBookmark = this.bookmarks.reduce((prev, curr) => {
if (clickedNs - curr.getValueNs() < 0) return prev;
return Math.abs(Number(curr.getValueNs() - clickedNs)) <
Math.abs(Number(prev.getValueNs() - clickedNs))
? curr
: prev;
});
this.bookmarks = this.bookmarks.filter(
(bm) => bm.getValueNs() !== closestBookmark.getValueNs(),
);
} else {
this.bookmarks = this.bookmarks.concat([
assertDefined(
this.timelineData?.getTimestampConverter(),
).makeTimestampFromNs(clickedNs),
]);
}
}
removeAllBookmarks() {
this.bookmarks = [];
}
async onTimelineTraceClicked(trace: Trace) {
await this.emitEvent(new ActiveTraceChanged(trace));
this.changeDetectorRef.detectChanges();
}
private updateSelectedTraces(trace: Trace | undefined) {
if (!trace) {
return;
}
if (!this.selectedTraces.includes(trace)) {
// Create new object to make sure we trigger an update on Mini Timeline child component
this.selectedTraces = [...this.selectedTraces, trace];
this.selectedTracesFormControl.setValue(this.selectedTraces);
}
}
private updateTimeInputValuesToCurrentTimestamp() {
const currentTimestampNs =
this.getCurrentTracePosition().timestamp.getValueNs();
const timelineData = assertDefined(this.timelineData);
let formattedCurrentTimestamp = assertDefined(
timelineData.getTimestampConverter(),
)
.makeTimestampFromNs(currentTimestampNs)
.format();
if (TimestampUtils.isHumanRealTimestampFormat(formattedCurrentTimestamp)) {
formattedCurrentTimestamp = assertDefined(
TimestampUtils.extractTimeFromHumanTimestamp(formattedCurrentTimestamp),
);
}
this.selectedTimeFormControl.setValue(formattedCurrentTimestamp);
this.selectedNsFormControl.setValue(`${currentTimestampNs} ns`);
}
private getSelectedTracesSortedByDisplayOrder(): Array> {
return this.selectedTraces
.slice()
.sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type));
}
private getStoredDeselectedTraceTypes(): TraceType[] {
const storedDeselectedTraces = this.store?.get(
this.storeKeyDeselectedTraces,
);
return JSON.parse(storedDeselectedTraces ?? '[]');
}
private updateStoredDeselectedTraceTypes(clickedTrace: Trace) {
if (!this.store) {
return;
}
let storedDeselected = this.getStoredDeselectedTraceTypes();
if (
this.selectedTraces.includes(clickedTrace) &&
storedDeselected.includes(clickedTrace.type)
) {
storedDeselected = storedDeselected.filter(
(stored) => stored !== clickedTrace.type,
);
} else if (
!this.selectedTraces.includes(clickedTrace) &&
!storedDeselected.includes(clickedTrace.type)
) {
Analytics.Navigation.logTraceTimelineDeselected(
TRACE_INFO[clickedTrace.type].name,
);
storedDeselected.push(clickedTrace.type);
}
this.store.add(
this.storeKeyDeselectedTraces,
JSON.stringify(storedDeselected),
);
}
private validateNsFormat(control: FormControl): ValidationErrors | null {
const valid = TimestampUtils.isNsFormat(control.value ?? '');
return !valid ? {invalidInput: control.value} : null;
}
}