Add jank CUJ tag viewer

Screenshot: https://screenshot.googleplex.com/6PLjrtN7UZq3gdo
Test: npm run test:presubmit
Change-Id: Ibe149f2a573e068a1b3a04a50d99d91d42e74da1
diff --git a/tools/winscope/src/app/app_module.ts b/tools/winscope/src/app/app_module.ts
index cf7c9c1..6fada3c 100644
--- a/tools/winscope/src/app/app_module.ts
+++ b/tools/winscope/src/app/app_module.ts
@@ -63,6 +63,7 @@
 import {UserOptionsComponent} from 'viewers/components/user_options_component';
 import {ViewerInputMethodComponent} from 'viewers/components/viewer_input_method_component';
 import {ViewCapturePropertyGroupsComponent} from 'viewers/components/view_capture_property_groups_component';
+import {ViewerJankCujsComponent} from 'viewers/viewer_jank_cujs/viewer_jank_cujs_component';
 import {ProtologScrollDirective} from 'viewers/viewer_protolog/scroll_strategy/protolog_scroll_directive';
 import {ViewerProtologComponent} from 'viewers/viewer_protolog/viewer_protolog_component';
 import {ViewerScreenRecordingComponent} from 'viewers/viewer_screen_recording/viewer_screen_recording_component';
@@ -102,6 +103,7 @@
     ViewerSurfaceFlingerComponent,
     ViewerInputMethodComponent,
     ViewerProtologComponent,
+    ViewerJankCujsComponent,
     ViewerTransactionsComponent,
     ViewerScreenRecordingComponent,
     ViewerTransitionsComponent,
diff --git a/tools/winscope/src/app/components/app_component.ts b/tools/winscope/src/app/components/app_component.ts
index b5fced6..f502a9f 100644
--- a/tools/winscope/src/app/components/app_component.ts
+++ b/tools/winscope/src/app/components/app_component.ts
@@ -60,6 +60,7 @@
 import {iconDividerStyle} from 'viewers/components/styles/icon_divider.styles';
 import {ViewerInputMethodComponent} from 'viewers/components/viewer_input_method_component';
 import {Viewer} from 'viewers/viewer';
+import {ViewerJankCujsComponent} from 'viewers/viewer_jank_cujs/viewer_jank_cujs_component';
 import {ViewerProtologComponent} from 'viewers/viewer_protolog/viewer_protolog_component';
 import {ViewerScreenRecordingComponent} from 'viewers/viewer_screen_recording/viewer_screen_recording_component';
 import {ViewerSurfaceFlingerComponent} from 'viewers/viewer_surface_flinger/viewer_surface_flinger_component';
@@ -440,6 +441,12 @@
         createCustomElement(ViewerViewCaptureComponent, {injector}),
       );
     }
+    if (!customElements.get('viewer-jank-cujs')) {
+      customElements.define(
+        'viewer-jank-cujs',
+        createCustomElement(ViewerJankCujsComponent, {injector}),
+      );
+    }
 
     this.traceConfigStorage =
       globalConfig.MODE === 'PROD' ? localStorage : new InMemoryStorage();
diff --git a/tools/winscope/src/parsers/events/traces_parser_cujs.ts b/tools/winscope/src/parsers/events/traces_parser_cujs.ts
index 13861aa..f377671 100644
--- a/tools/winscope/src/parsers/events/traces_parser_cujs.ts
+++ b/tools/winscope/src/parsers/events/traces_parser_cujs.ts
@@ -15,6 +15,7 @@
  */
 
 import {assertDefined} from 'common/assert_utils';
+import {Timestamp} from 'common/time';
 import {ParserTimestampConverter} from 'common/timestamp_converter';
 import {AbstractTracesParser} from 'parsers/traces/abstract_traces_parser';
 import {CoarseVersion} from 'trace/coarse_version';
@@ -26,7 +27,6 @@
 import {CujType} from './cuj_type';
 import {EventTag} from './event_tag';
 import {AddCujProperties} from './operations/add_cuj_properties';
-import { Timestamp } from 'common/time';
 
 export class TracesParserCujs extends AbstractTracesParser<PropertyTreeNode> {
   private static readonly AddCujProperties = new AddCujProperties();
@@ -75,9 +75,7 @@
     this.timestamps = [];
     for (let index = 0; index < this.getLengthEntries(); index++) {
       const entry = await this.getEntry(index);
-      const timestamp = entry
-        ?.getChildByName('startTimestamp')
-        ?.getValue();
+      const timestamp = entry?.getChildByName('startTimestamp')?.getValue();
       this.timestamps.push(timestamp);
     }
   }
diff --git a/tools/winscope/src/trace/trace_type.ts b/tools/winscope/src/trace/trace_type.ts
index 8bbdb3b..f5ed102 100644
--- a/tools/winscope/src/trace/trace_type.ts
+++ b/tools/winscope/src/trace/trace_type.ts
@@ -99,6 +99,7 @@
     TraceType.PROTO_LOG,
     TraceType.VIEW_CAPTURE,
     TraceType.TRANSITION,
+    TraceType.CUJS,
   ];
 
   static isTraceTypeWithViewer(t: TraceType): boolean {
diff --git a/tools/winscope/src/viewers/common/ui_data_log.ts b/tools/winscope/src/viewers/common/ui_data_log.ts
index 134d48f..461509a 100644
--- a/tools/winscope/src/viewers/common/ui_data_log.ts
+++ b/tools/winscope/src/viewers/common/ui_data_log.ts
@@ -70,6 +70,9 @@
   DISPATCH_TIME = 'Dispatch Time',
   DURATION = 'Duration',
   STATUS = 'Status',
+  CUJ_TYPE = 'Type',
+  START_TIME = 'Start Time',
+  END_TIME = 'End Time',
 }
 
 export const LogFieldClassNames: ReadonlyMap<LogFieldName, string> = new Map([
@@ -86,8 +89,11 @@
   [LogFieldName.TEXT, 'text'],
   [LogFieldName.TRANSITION_ID, 'transition-id'],
   [LogFieldName.TRANSITION_TYPE, 'transition-type'],
+  [LogFieldName.CUJ_TYPE, 'jank_cuj-type'],
   [LogFieldName.SEND_TIME, 'send-time time'],
   [LogFieldName.DISPATCH_TIME, 'dispatch-time time'],
+  [LogFieldName.START_TIME, 'start-time time'],
+  [LogFieldName.END_TIME, 'end-time time'],
   [LogFieldName.DURATION, 'duration'],
   [LogFieldName.STATUS, 'status'],
 ]);
diff --git a/tools/winscope/src/viewers/components/styles/log_component.styles.ts b/tools/winscope/src/viewers/components/styles/log_component.styles.ts
index fc2818f..dbf8b68 100644
--- a/tools/winscope/src/viewers/components/styles/log_component.styles.ts
+++ b/tools/winscope/src/viewers/components/styles/log_component.styles.ts
@@ -130,11 +130,11 @@
     flex: 2;
   }
 
-  .dispatch-time {
-    flex: 4;
+  .jank_cuj-type {
+    flex: 5;
   }
 
-  .send-time {
+  .start-time, .end-time, .dispatch-time, .send-time {
     flex: 4;
   }
 
diff --git a/tools/winscope/src/viewers/viewer_factory.ts b/tools/winscope/src/viewers/viewer_factory.ts
index 61b9a65..d255a45 100644
--- a/tools/winscope/src/viewers/viewer_factory.ts
+++ b/tools/winscope/src/viewers/viewer_factory.ts
@@ -22,6 +22,7 @@
 import {ViewerInputMethodClients} from './viewer_input_method_clients/viewer_input_method_clients';
 import {ViewerInputMethodManagerService} from './viewer_input_method_manager_service/viewer_input_method_manager_service';
 import {ViewerInputMethodService} from './viewer_input_method_service/viewer_input_method_service';
+import {ViewerJankCujs} from './viewer_jank_cujs/viewer_jank_cujs';
 import {ViewerProtoLog} from './viewer_protolog/viewer_protolog';
 import {ViewerScreenshot} from './viewer_screen_recording/viewer_screenshot';
 import {ViewerScreenRecording} from './viewer_screen_recording/viewer_screen_recording';
@@ -43,6 +44,7 @@
     ViewerScreenRecording,
     ViewerScreenshot,
     ViewerTransitions,
+    ViewerJankCujs,
   ];
 
   createViewers(traces: Traces, storage: Storage): Viewer[] {
diff --git a/tools/winscope/src/viewers/viewer_jank_cujs/presenter.ts b/tools/winscope/src/viewers/viewer_jank_cujs/presenter.ts
new file mode 100644
index 0000000..09ab5df
--- /dev/null
+++ b/tools/winscope/src/viewers/viewer_jank_cujs/presenter.ts
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2024 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 {assertDefined} from 'common/assert_utils';
+import {TimeDuration} from 'common/time_duration';
+import {Trace} from 'trace/trace';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+import {
+  AbstractLogViewerPresenter,
+  NotifyLogViewCallbackType,
+} from 'viewers/common/abstract_log_viewer_presenter';
+import {LogPresenter} from 'viewers/common/log_presenter';
+import {PropertiesPresenter} from 'viewers/common/properties_presenter';
+import {LogField, LogFieldName} from 'viewers/common/ui_data_log';
+import {CujEntry, CujStatus, CujType, UiData} from './ui_data';
+
+export class Presenter extends AbstractLogViewerPresenter {
+  static readonly FIELD_NAMES = [
+    LogFieldName.CUJ_TYPE,
+    LogFieldName.START_TIME,
+    LogFieldName.END_TIME,
+    LogFieldName.DURATION,
+    LogFieldName.STATUS,
+  ];
+  private static readonly VALUE_NA = 'N/A';
+
+  private isInitialized = false;
+  private transitionTrace: Trace<PropertyTreeNode>;
+
+  protected override logPresenter = new LogPresenter(false);
+  protected override propertiesPresenter = new PropertiesPresenter({}, [], []);
+
+  constructor(
+    trace: Trace<PropertyTreeNode>,
+    notifyViewCallback: NotifyLogViewCallbackType,
+  ) {
+    super(trace, notifyViewCallback, UiData.EMPTY);
+    this.transitionTrace = trace;
+  }
+
+  protected async initializeIfNeeded() {
+    if (this.isInitialized) {
+      return;
+    }
+
+    const allEntries = await this.makeUiDataEntries();
+
+    this.logPresenter.setAllEntries(allEntries);
+    this.logPresenter.setHeaders(Presenter.FIELD_NAMES);
+    this.refreshUIData(UiData.EMPTY);
+    this.isInitialized = true;
+  }
+
+  private async makeUiDataEntries(): Promise<CujEntry[]> {
+    const cujs: CujEntry[] = [];
+    for (
+      let traceIndex = 0;
+      traceIndex < this.transitionTrace.lengthEntries;
+      ++traceIndex
+    ) {
+      const entry = assertDefined(this.trace.getEntry(traceIndex));
+      const cujNode = await entry.getValue();
+
+      let status: CujStatus | undefined;
+      let statusIcon: string | undefined;
+      let statusIconColor: string | undefined;
+      if (assertDefined(cujNode.getChildByName('canceled')).getValue()) {
+        status = CujStatus.CANCELLED;
+        statusIcon = 'close';
+        statusIconColor = 'red';
+      } else {
+        status = CujStatus.EXECUTED;
+        statusIcon = 'check';
+        statusIconColor = 'green';
+      }
+
+      const startTs = cujNode.getChildByName('startTimestamp')?.getValue();
+      const endTs = cujNode.getChildByName('endTimestamp')?.getValue();
+
+      let timeDiff: TimeDuration | undefined = undefined;
+      if (startTs && endTs) {
+        const timeDiffNs = endTs.minus(startTs.getValueNs()).getValueNs();
+        timeDiff = new TimeDuration(timeDiffNs);
+      }
+
+      const cujTypeId = assertDefined(
+        cujNode.getChildByName('cujType'),
+      ).getValue();
+
+      const fields: LogField[] = [
+        {
+          name: LogFieldName.CUJ_TYPE,
+          value: `${CujType[cujTypeId]} (${cujTypeId})`,
+        },
+        {
+          name: LogFieldName.START_TIME,
+          value: startTs ?? Presenter.VALUE_NA,
+        },
+        {
+          name: LogFieldName.END_TIME,
+          value: endTs ?? Presenter.VALUE_NA,
+        },
+        {
+          name: LogFieldName.DURATION,
+          value: timeDiff?.format() ?? Presenter.VALUE_NA,
+        },
+        {
+          name: LogFieldName.STATUS,
+          value: status ?? Presenter.VALUE_NA,
+          icon: statusIcon,
+          iconColor: statusIconColor,
+        },
+      ];
+      cujs.push(new CujEntry(entry, fields, cujNode));
+    }
+
+    return cujs;
+  }
+}
diff --git a/tools/winscope/src/viewers/viewer_jank_cujs/presenter_test.ts b/tools/winscope/src/viewers/viewer_jank_cujs/presenter_test.ts
new file mode 100644
index 0000000..9941762
--- /dev/null
+++ b/tools/winscope/src/viewers/viewer_jank_cujs/presenter_test.ts
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2024 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 ANYf KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {assertDefined} from 'common/assert_utils';
+import {TracePositionUpdate} from 'messaging/winscope_event';
+import {TracesBuilder} from 'test/unit/traces_builder';
+import {TraceBuilder} from 'test/unit/trace_builder';
+import {UnitTestUtils} from 'test/unit/utils';
+import {Parser} from 'trace/parser';
+import {Trace} from 'trace/trace';
+import {Traces} from 'trace/traces';
+import {TraceType} from 'trace/trace_type';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+import {NotifyLogViewCallbackType} from 'viewers/common/abstract_log_viewer_presenter';
+import {AbstractLogViewerPresenterTest} from 'viewers/common/abstract_log_viewer_presenter_test';
+import {Presenter} from './presenter';
+
+class PresenterJankCujsTest extends AbstractLogViewerPresenterTest {
+  private trace: Trace<PropertyTreeNode> | undefined;
+  private positionUpdate: TracePositionUpdate | undefined;
+  private secondPositionUpdate: TracePositionUpdate | undefined;
+
+  override readonly shouldExecuteHeaderTests = true;
+  override readonly shouldExecuteFilterTests = false;
+  override readonly shouldExecuteCurrentIndexTests = false;
+  override readonly shouldExecutePropertiesTests = true;
+
+  override readonly totalOutputEntries = 16;
+  override readonly expectedIndexOfSecondPositionUpdate = 2;
+  override readonly logEntryClickIndex = 3;
+
+  override async setUpTestEnvironment(): Promise<void> {
+    const parser = (await UnitTestUtils.getTracesParser([
+      'traces/eventlog.winscope',
+    ])) as Parser<PropertyTreeNode>;
+
+    this.trace = new TraceBuilder<PropertyTreeNode>()
+      .setType(TraceType.CUJS)
+      .setParser(parser)
+      .build();
+
+    this.positionUpdate = TracePositionUpdate.fromTraceEntry(
+      this.trace.getEntry(0),
+    );
+    this.secondPositionUpdate = TracePositionUpdate.fromTraceEntry(
+      this.trace.getEntry(2),
+    );
+  }
+
+  override createPresenterWithEmptyTrace(
+    callback: NotifyLogViewCallbackType,
+  ): Presenter {
+    const traces = new TracesBuilder()
+      .setEntries(TraceType.TRANSITION, [])
+      .build();
+    const trace = assertDefined(traces.getTrace(TraceType.TRANSITION));
+    return new Presenter(trace, callback);
+  }
+
+  override async createPresenter(
+    callback: NotifyLogViewCallbackType,
+  ): Promise<Presenter> {
+    const trace = assertDefined(this.trace);
+    const traces = new Traces();
+    traces.addTrace(trace);
+
+    const presenter = new Presenter(
+      trace,
+      callback as NotifyLogViewCallbackType,
+    );
+    await presenter.onAppEvent(this.getPositionUpdate()); // trigger initialization
+    return presenter;
+  }
+
+  override getPositionUpdate(): TracePositionUpdate {
+    return assertDefined(this.positionUpdate);
+  }
+
+  override getSecondPositionUpdate(): TracePositionUpdate {
+    return assertDefined(this.secondPositionUpdate);
+  }
+}
+
+describe('PresenterJankCujsTest', () => {
+  new PresenterJankCujsTest().execute();
+});
diff --git a/tools/winscope/src/viewers/viewer_jank_cujs/ui_data.ts b/tools/winscope/src/viewers/viewer_jank_cujs/ui_data.ts
new file mode 100644
index 0000000..ea4aa95
--- /dev/null
+++ b/tools/winscope/src/viewers/viewer_jank_cujs/ui_data.ts
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2024 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 {TraceEntry} from 'trace/trace';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+import {
+  LogEntry,
+  LogField,
+  LogFieldName,
+  UiDataLog,
+} from 'viewers/common/ui_data_log';
+import {UiPropertyTreeNode} from 'viewers/common/ui_property_tree_node';
+
+export class UiData implements UiDataLog {
+  constructor(
+    public headers: LogFieldName[],
+    public entries: LogEntry[],
+    public selectedIndex: undefined | number,
+    public scrollToIndex: undefined | number,
+    public propertiesTree: undefined | UiPropertyTreeNode,
+  ) {}
+
+  static EMPTY = new UiData([], [], undefined, undefined, undefined);
+}
+export class CujEntry implements LogEntry {
+  constructor(
+    public traceEntry: TraceEntry<PropertyTreeNode>,
+    public fields: LogField[],
+    public propertiesTree: PropertyTreeNode | undefined,
+  ) {}
+}
+
+export enum CujStatus {
+  EXECUTED = 'EXECUTED',
+  CANCELLED = 'CANCELLED',
+}
+
+/**
+ * Source of truth found at:`frameworks/base/core/java/com/android/internal/jank/Cuj.java`
+ */
+export enum CujType {
+  NOTIFICATION_SHADE_EXPAND_COLLAPSE = 0,
+  NOTIFICATION_SHADE_SCROLL_FLING = 2,
+  NOTIFICATION_SHADE_ROW_EXPAND = 3,
+  NOTIFICATION_SHADE_ROW_SWIPE = 4,
+  NOTIFICATION_SHADE_QS_EXPAND_COLLAPSE = 5,
+  NOTIFICATION_SHADE_QS_SCROLL_SWIPE = 6,
+  LAUNCHER_APP_LAUNCH_FROM_RECENTS = 7,
+  LAUNCHER_APP_LAUNCH_FROM_ICON = 8,
+  LAUNCHER_APP_CLOSE_TO_HOME = 9,
+  LAUNCHER_APP_CLOSE_TO_PIP = 10,
+  LAUNCHER_QUICK_SWITCH = 11,
+  NOTIFICATION_HEADS_UP_APPEAR = 12,
+  NOTIFICATION_HEADS_UP_DISAPPEAR = 13,
+  NOTIFICATION_ADD = 14,
+  NOTIFICATION_REMOVE = 15,
+  NOTIFICATION_APP_START = 16,
+  LOCKSCREEN_PASSWORD_APPEAR = 17,
+  LOCKSCREEN_PATTERN_APPEAR = 18,
+  LOCKSCREEN_PIN_APPEAR = 19,
+  LOCKSCREEN_PASSWORD_DISAPPEAR = 20,
+  LOCKSCREEN_PATTERN_DISAPPEAR = 21,
+  LOCKSCREEN_PIN_DISAPPEAR = 22,
+  LOCKSCREEN_TRANSITION_FROM_AOD = 23,
+  LOCKSCREEN_TRANSITION_TO_AOD = 24,
+  LAUNCHER_OPEN_ALL_APPS = 25,
+  LAUNCHER_ALL_APPS_SCROLL = 26,
+  LAUNCHER_APP_LAUNCH_FROM_WIDGET = 27,
+  SETTINGS_PAGE_SCROLL = 28,
+  LOCKSCREEN_UNLOCK_ANIMATION = 29,
+  SHADE_APP_LAUNCH_FROM_HISTORY_BUTTON = 30,
+  SHADE_APP_LAUNCH_FROM_MEDIA_PLAYER = 31,
+  SHADE_APP_LAUNCH_FROM_QS_TILE = 32,
+  SHADE_APP_LAUNCH_FROM_SETTINGS_BUTTON = 33,
+  STATUS_BAR_APP_LAUNCH_FROM_CALL_CHIP = 34,
+  PIP_TRANSITION = 35,
+  WALLPAPER_TRANSITION = 36,
+  USER_SWITCH = 37,
+  SPLASHSCREEN_AVD = 38,
+  SPLASHSCREEN_EXIT_ANIM = 39,
+  SCREEN_OFF = 40,
+  SCREEN_OFF_SHOW_AOD = 41,
+  ONE_HANDED_ENTER_TRANSITION = 42,
+  ONE_HANDED_EXIT_TRANSITION = 43,
+  UNFOLD_ANIM = 44,
+  SUW_LOADING_TO_SHOW_INFO_WITH_ACTIONS = 45,
+  SUW_SHOW_FUNCTION_SCREEN_WITH_ACTIONS = 46,
+  SUW_LOADING_TO_NEXT_FLOW = 47,
+  SUW_LOADING_SCREEN_FOR_STATUS = 48,
+  SPLIT_SCREEN_ENTER = 49,
+  SPLIT_SCREEN_EXIT = 50,
+  LOCKSCREEN_LAUNCH_CAMERA = 51, // reserved.
+  SPLIT_SCREEN_RESIZE = 52,
+  SETTINGS_SLIDER = 53,
+  TAKE_SCREENSHOT = 54,
+  VOLUME_CONTROL = 55,
+  BIOMETRIC_PROMPT_TRANSITION = 56,
+  SETTINGS_TOGGLE = 57,
+  SHADE_DIALOG_OPEN = 58,
+  USER_DIALOG_OPEN = 59,
+  TASKBAR_EXPAND = 60,
+  TASKBAR_COLLAPSE = 61,
+  SHADE_CLEAR_ALL = 62,
+  LAUNCHER_UNLOCK_ENTRANCE_ANIMATION = 63,
+  LOCKSCREEN_OCCLUSION = 64,
+  RECENTS_SCROLLING = 65,
+  LAUNCHER_APP_SWIPE_TO_RECENTS = 66,
+  LAUNCHER_CLOSE_ALL_APPS_SWIPE = 67,
+  LAUNCHER_CLOSE_ALL_APPS_TO_HOME = 68,
+  LOCKSCREEN_CLOCK_MOVE_ANIMATION = 70,
+  LAUNCHER_OPEN_SEARCH_RESULT = 71,
+  // 72 - 77 are reserved for b/281564325.
+
+  /**
+   * In some cases when we do not have any end-target, we play a simple slide-down animation.
+   * eg: Open an app from Overview/Task switcher such that there is no home-screen icon.
+   * eg: Exit the app using back gesture.
+   */
+  LAUNCHER_APP_CLOSE_TO_HOME_FALLBACK = 78,
+  // 79 is reserved.
+  IME_INSETS_SHOW_ANIMATION = 80,
+  IME_INSETS_HIDE_ANIMATION = 81,
+
+  SPLIT_SCREEN_DOUBLE_TAP_DIVIDER = 82,
+
+  LAUNCHER_UNFOLD_ANIM = 83,
+
+  PREDICTIVE_BACK_CROSS_ACTIVITY = 84,
+  PREDICTIVE_BACK_CROSS_TASK = 85,
+  PREDICTIVE_BACK_HOME = 86,
+  // 87 is reserved - previously assigned to deprecated CUJ_LAUNCHER_SEARCH_QSB_OPEN.
+  BACK_PANEL_ARROW = 88,
+  LAUNCHER_CLOSE_ALL_APPS_BACK = 89,
+  LAUNCHER_SEARCH_QSB_WEB_SEARCH = 90,
+  LAUNCHER_LAUNCH_APP_PAIR_FROM_WORKSPACE = 91,
+  LAUNCHER_LAUNCH_APP_PAIR_FROM_TASKBAR = 92,
+  LAUNCHER_SAVE_APP_PAIR = 93,
+  LAUNCHER_ALL_APPS_SEARCH_BACK = 95,
+  LAUNCHER_TASKBAR_ALL_APPS_CLOSE_BACK = 96,
+  LAUNCHER_TASKBAR_ALL_APPS_SEARCH_BACK = 97,
+  LAUNCHER_WIDGET_PICKER_CLOSE_BACK = 98,
+  LAUNCHER_WIDGET_PICKER_SEARCH_BACK = 99,
+  LAUNCHER_WIDGET_BOTTOM_SHEET_CLOSE_BACK = 100,
+  LAUNCHER_WIDGET_EDU_SHEET_CLOSE_BACK = 101,
+  LAUNCHER_PRIVATE_SPACE_LOCK = 102,
+  LAUNCHER_PRIVATE_SPACE_UNLOCK = 103,
+}
diff --git a/tools/winscope/src/viewers/viewer_jank_cujs/viewer_jank_cujs.ts b/tools/winscope/src/viewers/viewer_jank_cujs/viewer_jank_cujs.ts
new file mode 100644
index 0000000..28f21e8
--- /dev/null
+++ b/tools/winscope/src/viewers/viewer_jank_cujs/viewer_jank_cujs.ts
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2024 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 {WinscopeEvent} from 'messaging/winscope_event';
+import {EmitEvent} from 'messaging/winscope_event_emitter';
+import {Trace} from 'trace/trace';
+import {Traces} from 'trace/traces';
+import {TraceType} from 'trace/trace_type';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+import {NotifyLogViewCallbackType} from 'viewers/common/abstract_log_viewer_presenter';
+import {View, Viewer, ViewType} from 'viewers/viewer';
+import {Presenter} from './presenter';
+import {UiData} from './ui_data';
+
+export class ViewerJankCujs implements Viewer {
+  static readonly DEPENDENCIES: TraceType[] = [TraceType.CUJS];
+
+  private readonly trace: Trace<PropertyTreeNode>;
+  private readonly htmlElement: HTMLElement;
+  private readonly presenter: Presenter;
+  private readonly view: View;
+
+  constructor(trace: Trace<PropertyTreeNode>, traces: Traces) {
+    this.trace = trace;
+    this.htmlElement = document.createElement('viewer-jank-cujs');
+    const notifyViewCallback = (data: UiData) => {
+      (this.htmlElement as any).inputData = data;
+    };
+    this.presenter = new Presenter(
+      trace,
+      notifyViewCallback as NotifyLogViewCallbackType,
+    );
+    this.presenter.addEventListeners(this.htmlElement);
+
+    this.view = new View(
+      ViewType.TAB,
+      this.getTraces(),
+      this.htmlElement,
+      'Jank CUJs',
+    );
+  }
+
+  async onWinscopeEvent(event: WinscopeEvent) {
+    await this.presenter.onAppEvent(event);
+  }
+
+  setEmitEvent(callback: EmitEvent) {
+    this.presenter.setEmitEvent(callback);
+  }
+
+  getViews(): View[] {
+    return [this.view];
+  }
+
+  getTraces(): Array<Trace<PropertyTreeNode>> {
+    return [this.trace];
+  }
+}
diff --git a/tools/winscope/src/viewers/viewer_jank_cujs/viewer_jank_cujs_component.ts b/tools/winscope/src/viewers/viewer_jank_cujs/viewer_jank_cujs_component.ts
new file mode 100644
index 0000000..bc4384c
--- /dev/null
+++ b/tools/winscope/src/viewers/viewer_jank_cujs/viewer_jank_cujs_component.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2024 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 {Component, Input, ViewChild} from '@angular/core';
+import {TraceType} from 'trace/trace_type';
+import {LogComponent} from 'viewers/common/log_component';
+import {viewerCardStyle} from 'viewers/components/styles/viewer_card.styles';
+import {UiData} from './ui_data';
+
+@Component({
+  selector: 'viewer-jank-cujs',
+  template: `
+    <div class="card-grid">
+       <log-view
+        class="log-view"
+        [selectedIndex]="inputData?.selectedIndex"
+        [scrollToIndex]="inputData?.scrollToIndex"
+        [currentIndex]="inputData?.currentIndex"
+        [entries]="inputData?.entries"
+        [headers]="inputData?.headers"
+        [traceType]="${TraceType.CUJS}"
+        [showTraceEntryTimes]="false"
+        [showCurrentTimeButton]="false">
+      </log-view>
+    </div>
+  `,
+  styles: [viewerCardStyle],
+})
+export class ViewerJankCujsComponent {
+  @Input() inputData: UiData | undefined;
+
+  @ViewChild(LogComponent)
+  logComponent?: LogComponent;
+}
diff --git a/tools/winscope/src/viewers/viewer_jank_cujs/viewer_jank_cujs_component_test.ts b/tools/winscope/src/viewers/viewer_jank_cujs/viewer_jank_cujs_component_test.ts
new file mode 100644
index 0000000..7a54ccb
--- /dev/null
+++ b/tools/winscope/src/viewers/viewer_jank_cujs/viewer_jank_cujs_component_test.ts
@@ -0,0 +1,161 @@
+/*
+ * Copyright (C) 2023 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 {ScrollingModule} from '@angular/cdk/scrolling';
+import {
+  ComponentFixture,
+  ComponentFixtureAutoDetect,
+  TestBed,
+} from '@angular/core/testing';
+import {MatDividerModule} from '@angular/material/divider';
+import {MatIconModule} from '@angular/material/icon';
+import {assertDefined} from 'common/assert_utils';
+import {TimeDuration} from 'common/time_duration';
+import {TimestampConverterUtils} from 'test/unit/timestamp_converter_utils';
+import {TraceBuilder} from 'test/unit/trace_builder';
+import {UnitTestUtils} from 'test/unit/utils';
+import {Parser} from 'trace/parser';
+import {Trace, TraceEntry} from 'trace/trace';
+import {TraceType} from 'trace/trace_type';
+import {PropertyTreeNode} from 'trace/tree_node/property_tree_node';
+import {LogComponent} from 'viewers/common/log_component';
+import {LogEntry, LogField, LogFieldName} from 'viewers/common/ui_data_log';
+import {CollapsedSectionsComponent} from 'viewers/components/collapsed_sections_component';
+import {CollapsibleSectionTitleComponent} from 'viewers/components/collapsible_section_title_component';
+import {PropertiesComponent} from 'viewers/components/properties_component';
+import {PropertyTreeNodeDataViewComponent} from 'viewers/components/property_tree_node_data_view_component';
+import {TreeComponent} from 'viewers/components/tree_component';
+import {TreeNodeComponent} from 'viewers/components/tree_node_component';
+import {Presenter} from './presenter';
+import {CujStatus, CujType, UiData} from './ui_data';
+import {ViewerJankCujsComponent} from './viewer_jank_cujs_component';
+
+describe('ViewerJankCujsComponent', () => {
+  let fixture: ComponentFixture<ViewerJankCujsComponent>;
+  let component: ViewerJankCujsComponent;
+  let htmlElement: HTMLElement;
+
+  let trace: Trace<PropertyTreeNode>;
+  let entry: TraceEntry<PropertyTreeNode>;
+
+  beforeAll(async () => {
+    const parser = (await UnitTestUtils.getTracesParser([
+      'traces/eventlog.winscope',
+    ])) as Parser<PropertyTreeNode>;
+
+    trace = new TraceBuilder<PropertyTreeNode>()
+      .setParser(parser)
+      .setType(TraceType.CUJS)
+      .build();
+
+    entry = trace.getEntry(0);
+  });
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      providers: [{provide: ComponentFixtureAutoDetect, useValue: true}],
+      imports: [MatDividerModule, ScrollingModule, MatIconModule],
+      declarations: [
+        ViewerJankCujsComponent,
+        TreeComponent,
+        TreeNodeComponent,
+        PropertyTreeNodeDataViewComponent,
+        PropertiesComponent,
+        CollapsedSectionsComponent,
+        CollapsibleSectionTitleComponent,
+        LogComponent,
+      ],
+      schemas: [],
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(ViewerJankCujsComponent);
+    component = fixture.componentInstance;
+    htmlElement = fixture.nativeElement;
+
+    component.inputData = makeUiData();
+    fixture.detectChanges();
+  });
+
+  it('can be created', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('renders entries', () => {
+    expect(htmlElement.querySelector('.scroll')).toBeTruthy();
+
+    const entry = assertDefined(htmlElement.querySelector('.scroll .entry'));
+    expect(entry.innerHTML).toContain('LOCKSCREEN_PASSWORD_DISAPPEAR');
+    expect(entry.innerHTML).toContain('30ns');
+  });
+
+  function makeUiData(): UiData {
+    let mockTransitionIdCounter = 0;
+
+    const cujEntries = [
+      createMockCujEntry(entry, 20, 30, mockTransitionIdCounter++),
+      createMockCujEntry(entry, 66, 42, 50, CujStatus.CANCELLED),
+      createMockCujEntry(entry, 46, 49, mockTransitionIdCounter++),
+      createMockCujEntry(entry, 59, 58, 70, CujStatus.EXECUTED),
+    ];
+
+    const uiData = UiData.EMPTY;
+    uiData.entries = cujEntries;
+    uiData.selectedIndex = 0;
+    uiData.headers = Presenter.FIELD_NAMES;
+    return uiData;
+  }
+
+  function createMockCujEntry(
+    entry: TraceEntry<PropertyTreeNode>,
+    cujTypeId: number,
+    startTsNanos: number,
+    endTsNanos: number,
+    status = CujStatus.EXECUTED,
+  ): LogEntry {
+    const fields: LogField[] = [
+      {
+        name: LogFieldName.CUJ_TYPE,
+        value: `${CujType[cujTypeId]} (${cujTypeId})`,
+      },
+      {
+        name: LogFieldName.START_TIME,
+        value: TimestampConverterUtils.makeElapsedTimestamp(
+          BigInt(startTsNanos),
+        ),
+      },
+      {
+        name: LogFieldName.END_TIME,
+        value: TimestampConverterUtils.makeElapsedTimestamp(BigInt(endTsNanos)),
+      },
+      {
+        name: LogFieldName.DURATION,
+        value: new TimeDuration(BigInt(endTsNanos - startTsNanos)).format(),
+      },
+      {
+        name: LogFieldName.STATUS,
+        value: status,
+        icon: 'check',
+        iconColor: 'green',
+      },
+    ];
+
+    return {
+      traceEntry: entry,
+      fields,
+      propertiesTree: undefined,
+    };
+  }
+});