Add screenshot support in winscope.

Can upload .png files to be displayed in SR viewer - only if SR trace not also uploaded.
Can take a screenshot when taking a WM/SF dump in via winscope collection UI.
Cannot upload a .png file or take just a screenshot without also uploading a winscope trace.

Fixes: 274910593
Test: npm run test:unit:ci
Change-Id: I9e1712cd2acb354c0a6e030306c3c35c4efcd1dd
diff --git a/tools/winscope/src/adb/winscope_proxy.py b/tools/winscope/src/adb/winscope_proxy.py
index b1421ce..9e705d7 100644
--- a/tools/winscope/src/adb/winscope_proxy.py
+++ b/tools/winscope/src/adb/winscope_proxy.py
@@ -471,12 +471,12 @@
     "virtualdisplays": "TRACE_FLAG_VIRTUAL_DISPLAYS",
 }
 
-#Keep up to date with options in DataAdb.vue
+#Keep up to date with options in trace_collection_utils.ts
 CONFIG_SF_SELECTION = [
     "sfbuffersize",
 ]
 
-#Keep up to date with options in DataAdb.vue
+#Keep up to date with options in trace_collection_utils.ts
 CONFIG_WM_SELECTION = [
     "wmbuffersize",
     "tracingtype",
@@ -530,6 +530,11 @@
 """
     ),
 
+    "screenshot": DumpTarget(
+        File("/data/local/tmp/screenshot.png", "screenshot.png"),
+        "screencap -p > /data/local/tmp/screenshot.png"
+    ),
+
     "perfetto_dump": DumpTarget(
         File(PERFETTO_DUMP_FILE, "dump.perfetto-trace"),
         f"""
diff --git a/tools/winscope/src/app/components/app_component.ts b/tools/winscope/src/app/components/app_component.ts
index e6688e9..08b9d9c 100644
--- a/tools/winscope/src/app/components/app_component.ts
+++ b/tools/winscope/src/app/components/app_component.ts
@@ -422,6 +422,12 @@
           run: true,
           config: undefined,
         },
+        screenshot: {
+          name: 'Screenshot',
+          isTraceCollection: undefined,
+          run: true,
+          config: undefined,
+        },
       },
       this.traceConfigStorage
     );
diff --git a/tools/winscope/src/app/loaded_parsers.ts b/tools/winscope/src/app/loaded_parsers.ts
index 9b102b6..684fb5e 100644
--- a/tools/winscope/src/app/loaded_parsers.ts
+++ b/tools/winscope/src/app/loaded_parsers.ts
@@ -41,6 +41,7 @@
     }
 
     legacyParsers = this.filterOutLegacyParsersWithOldData(legacyParsers, errorListener);
+    legacyParsers = this.filterScreenshotParsersIfRequired(legacyParsers, errorListener);
 
     this.addLegacyParsers(legacyParsers, errorListener);
   }
@@ -224,6 +225,48 @@
     });
   }
 
+  private filterScreenshotParsersIfRequired(
+    newLegacyParsers: FileAndParser[],
+    errorListener: WinscopeErrorListener
+  ): FileAndParser[] {
+    const oldScreenRecordingParser = this.legacyParsers.get(TraceType.SCREEN_RECORDING)?.parser;
+    const oldScreenshotParser = this.legacyParsers.get(TraceType.SCREENSHOT)?.parser;
+
+    const newScreenRecordingParsers = newLegacyParsers.filter(
+      (fileAndParser) => fileAndParser.parser.getTraceType() === TraceType.SCREEN_RECORDING
+    );
+    const newScreenshotParsers = newLegacyParsers.filter(
+      (fileAndParser) => fileAndParser.parser.getTraceType() === TraceType.SCREENSHOT
+    );
+
+    if (oldScreenRecordingParser || newScreenRecordingParsers.length > 0) {
+      newScreenshotParsers.forEach((newScreenshotParser) => {
+        errorListener.onError(
+          new TraceOverridden(
+            newScreenshotParser.parser.getDescriptors().join(),
+            TraceType.SCREEN_RECORDING
+          )
+        );
+      });
+
+      if (oldScreenshotParser) {
+        errorListener.onError(
+          new TraceOverridden(
+            oldScreenshotParser.getDescriptors().join(),
+            TraceType.SCREEN_RECORDING
+          )
+        );
+        this.remove(TraceType.SCREENSHOT);
+      }
+
+      return newLegacyParsers.filter(
+        (fileAndParser) => fileAndParser.parser.getTraceType() !== TraceType.SCREENSHOT
+      );
+    }
+
+    return newLegacyParsers;
+  }
+
   private findLastTimeGapAboveThreshold(ranges: readonly TimeRange[]): TimeRange | undefined {
     const rangesSortedByEnd = ranges
       .slice()
diff --git a/tools/winscope/src/app/loaded_parsers_test.ts b/tools/winscope/src/app/loaded_parsers_test.ts
index 487cfa8..53a748d 100644
--- a/tools/winscope/src/app/loaded_parsers_test.ts
+++ b/tools/winscope/src/app/loaded_parsers_test.ts
@@ -267,6 +267,56 @@
     });
   });
 
+  describe('handles screen recordings and screenshots', () => {
+    const parserScreenRecording = new ParserBuilder<object>()
+      .setType(TraceType.SCREEN_RECORDING)
+      .setTimestamps(timestamps)
+      .setDescriptors(['screen_recording.mp4'])
+      .build();
+    const parserScreenshot = new ParserBuilder<object>()
+      .setType(TraceType.SCREENSHOT)
+      .setTimestamps(timestamps)
+      .setDescriptors(['screenshot.png'])
+      .build();
+    const overrideError = new TraceOverridden('screenshot.png', TraceType.SCREEN_RECORDING);
+
+    it('loads screenshot parser', () => {
+      loadParsers([parserScreenshot], []);
+      expectLoadResult([parserScreenshot], []);
+    });
+
+    it('loads screen recording parser', () => {
+      loadParsers([parserScreenRecording], []);
+      expectLoadResult([parserScreenRecording], []);
+    });
+
+    it('discards screenshot parser in favour of screen recording parser', () => {
+      loadParsers([parserScreenshot, parserScreenRecording], []);
+      expectLoadResult([parserScreenRecording], [overrideError]);
+    });
+
+    it('does not load screenshot parser after loading screen recording parser in same call', () => {
+      loadParsers([parserScreenRecording, parserScreenshot], []);
+      expectLoadResult([parserScreenRecording], [overrideError]);
+    });
+
+    it('does not load screenshot parser after loading screen recording parser in previous call', () => {
+      loadParsers([parserScreenRecording], []);
+      expectLoadResult([parserScreenRecording], []);
+
+      loadParsers([parserScreenshot], []);
+      expectLoadResult([parserScreenRecording], [overrideError]);
+    });
+
+    it('overrides previously loaded screenshot parser with screen recording parser', () => {
+      loadParsers([parserScreenshot], []);
+      expectLoadResult([parserScreenshot], []);
+
+      loadParsers([parserScreenRecording], []);
+      expectLoadResult([parserScreenRecording], [overrideError]);
+    });
+  });
+
   it('can remove parsers', () => {
     loadParsers([parserSf0], [parserWm0]);
     expectLoadResult([parserSf0, parserWm0], []);
diff --git a/tools/winscope/src/app/timeline_data.ts b/tools/winscope/src/app/timeline_data.ts
index edb80ec..de4402c 100644
--- a/tools/winscope/src/app/timeline_data.ts
+++ b/tools/winscope/src/app/timeline_data.ts
@@ -33,6 +33,9 @@
   private explicitlySetPosition?: TracePosition;
   private explicitlySetSelection?: TimeRange;
   private explicitlySetZoomRange?: TimeRange;
+  private lastReturnedCurrentPosition?: TracePosition;
+  private lastReturnedFullTimeRange?: TimeRange;
+  private lastReturnedCurrentEntries = new Map<TraceType, TraceEntry<any> | undefined>();
   private activeViewTraceTypes: TraceType[] = []; // dependencies of current active view
 
   initialize(traces: Traces, screenRecordingVideo: Blob | undefined) {
@@ -67,7 +70,6 @@
     }
   }
 
-  private lastReturnedCurrentPosition?: TracePosition;
   getCurrentPosition(): TracePosition | undefined {
     if (this.explicitlySetPosition) {
       return this.explicitlySetPosition;
@@ -138,7 +140,6 @@
     return this.timestampType;
   }
 
-  private lastReturnedFullTimeRange?: TimeRange;
   getFullTimeRange(): TimeRange {
     if (!this.firstEntry || !this.lastEntry) {
       throw Error('Trying to get full time range when there are no timestamps');
@@ -250,7 +251,6 @@
     return trace.getEntry(currentIndex + 1);
   }
 
-  private lastReturnedCurrentEntries = new Map<TraceType, TraceEntry<any> | undefined>();
   findCurrentEntryFor(type: TraceType): TraceEntry<{}> | undefined {
     const position = this.getCurrentPosition();
     if (!position) {
@@ -290,7 +290,10 @@
     this.explicitlySetPosition = undefined;
     this.timestampType = undefined;
     this.explicitlySetSelection = undefined;
+    this.lastReturnedCurrentPosition = undefined;
     this.screenRecordingVideo = undefined;
+    this.lastReturnedFullTimeRange = undefined;
+    this.lastReturnedCurrentEntries.clear();
     this.activeViewTraceTypes = [];
   }
 
diff --git a/tools/winscope/src/app/trace_file_filter.ts b/tools/winscope/src/app/trace_file_filter.ts
index 562eaa9..51ae6f5 100644
--- a/tools/winscope/src/app/trace_file_filter.ts
+++ b/tools/winscope/src/app/trace_file_filter.ts
@@ -20,8 +20,8 @@
 import {TraceFile} from 'trace/trace_file';
 
 export interface FilterResult {
-  perfetto?: TraceFile;
   legacy: TraceFile[];
+  perfetto?: TraceFile;
 }
 
 export class TraceFileFilter {
@@ -37,7 +37,6 @@
     const bugreportMainEntry = files.find((file) => file.file.name === 'main_entry.txt');
     const perfettoFiles = files.filter((file) => this.isPerfettoFile(file));
     const legacyFiles = files.filter((file) => !this.isPerfettoFile(file));
-
     if (!(await this.isBugreport(bugreportMainEntry, files))) {
       const perfettoFile = this.pickLargestFile(perfettoFiles, errorListener);
       return {
diff --git a/tools/winscope/src/app/trace_icons.ts b/tools/winscope/src/app/trace_icons.ts
deleted file mode 100644
index 958e731..0000000
--- a/tools/winscope/src/app/trace_icons.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import {TraceType} from 'trace/trace_type';
-
-const WINDOW_MANAGER_ICON = 'view_compact';
-const SURFACE_FLINGER_ICON = 'filter_none';
-const SCREEN_RECORDING_ICON = 'videocam';
-const TRANSACTION_ICON = 'timeline';
-const WAYLAND_ICON = 'filter_none';
-const PROTO_LOG_ICON = 'notes';
-const SYSTEM_UI_ICON = 'filter_none';
-const VIEW_CAPTURE_ICON = 'filter_none';
-const IME_ICON = 'keyboard';
-const TAG_ICON = 'details';
-const TRACE_ERROR_ICON = 'warning';
-
-interface IconMap {
-  [key: number]: string;
-}
-
-export const TRACE_ICONS: IconMap = {
-  [TraceType.WINDOW_MANAGER]: WINDOW_MANAGER_ICON,
-  [TraceType.SURFACE_FLINGER]: SURFACE_FLINGER_ICON,
-  [TraceType.SCREEN_RECORDING]: SCREEN_RECORDING_ICON,
-  [TraceType.TRANSACTIONS]: TRANSACTION_ICON,
-  [TraceType.TRANSACTIONS_LEGACY]: TRANSACTION_ICON,
-  [TraceType.WAYLAND]: WAYLAND_ICON,
-  [TraceType.WAYLAND_DUMP]: WAYLAND_ICON,
-  [TraceType.PROTO_LOG]: PROTO_LOG_ICON,
-  [TraceType.SYSTEM_UI]: SYSTEM_UI_ICON,
-  [TraceType.VIEW_CAPTURE_LAUNCHER_ACTIVITY]: VIEW_CAPTURE_ICON,
-  [TraceType.VIEW_CAPTURE_TASKBAR_DRAG_LAYER]: VIEW_CAPTURE_ICON,
-  [TraceType.VIEW_CAPTURE_TASKBAR_OVERLAY_DRAG_LAYER]: VIEW_CAPTURE_ICON,
-  [TraceType.INPUT_METHOD_CLIENTS]: IME_ICON,
-  [TraceType.INPUT_METHOD_SERVICE]: IME_ICON,
-  [TraceType.INPUT_METHOD_MANAGER_SERVICE]: IME_ICON,
-};
diff --git a/tools/winscope/src/app/trace_info.ts b/tools/winscope/src/app/trace_info.ts
index fc9e888..fb3854b 100644
--- a/tools/winscope/src/app/trace_info.ts
+++ b/tools/winscope/src/app/trace_info.ts
@@ -19,14 +19,13 @@
 const WINDOW_MANAGER_ICON = 'web';
 const SURFACE_FLINGER_ICON = 'layers';
 const SCREEN_RECORDING_ICON = 'videocam';
+const SCREENSHOT_ICON = 'image';
 const TRANSACTION_ICON = 'show_chart';
 const WAYLAND_ICON = 'filter_none';
 const PROTO_LOG_ICON = 'notes';
 const SYSTEM_UI_ICON = 'filter_none';
 const VIEW_CAPTURE_ICON = 'filter_none';
 const IME_ICON = 'keyboard_alt';
-const TAG_ICON = 'details';
-const TRACE_ERROR_ICON = 'warning';
 const EVENT_LOG_ICON = 'description';
 const TRANSITION_ICON = 'animation';
 const CUJ_ICON = 'label';
@@ -59,6 +58,12 @@
     color: '#8A9CF9',
     downloadArchiveDir: '',
   },
+  [TraceType.SCREENSHOT]: {
+    name: 'Screenshot',
+    icon: SCREENSHOT_ICON,
+    color: '#8A9CF9',
+    downloadArchiveDir: '',
+  },
   [TraceType.TRANSACTIONS]: {
     name: 'Transactions',
     icon: TRANSACTION_ICON,
diff --git a/tools/winscope/src/app/trace_pipeline.ts b/tools/winscope/src/app/trace_pipeline.ts
index 5a85518..fefe7c4 100644
--- a/tools/winscope/src/app/trace_pipeline.ts
+++ b/tools/winscope/src/app/trace_pipeline.ts
@@ -114,7 +114,9 @@
   }
 
   async getScreenRecordingVideo(): Promise<undefined | Blob> {
-    const screenRecording = this.getTraces().getTrace(TraceType.SCREEN_RECORDING);
+    const traces = this.getTraces();
+    const screenRecording =
+      traces.getTrace(TraceType.SCREEN_RECORDING) ?? traces.getTrace(TraceType.SCREENSHOT);
     if (!screenRecording || screenRecording.lengthEntries === 0) {
       return undefined;
     }
@@ -133,6 +135,7 @@
     progressListener: ProgressListener | undefined
   ) {
     const filterResult = await this.traceFileFilter.filter(unzippedArchive, errorListener);
+
     if (!filterResult.perfetto && filterResult.legacy.length === 0) {
       errorListener.onError(new NoInputFiles());
       return;
diff --git a/tools/winscope/src/app/trace_pipeline_test.ts b/tools/winscope/src/app/trace_pipeline_test.ts
index cb2f004..fd28d9d 100644
--- a/tools/winscope/src/app/trace_pipeline_test.ts
+++ b/tools/winscope/src/app/trace_pipeline_test.ts
@@ -20,6 +20,7 @@
 import {
   CorruptedArchive,
   NoInputFiles,
+  TraceOverridden,
   UnsupportedFileFormat,
   WinscopeError,
 } from 'messaging/winscope_error';
@@ -144,23 +145,23 @@
   });
 
   it('is robust to invalid trace files', async () => {
-    const invalidFiles = [await UnitTestUtils.getFixtureFile('winscope_homepage.png')];
+    const invalidFiles = [await UnitTestUtils.getFixtureFile('winscope_homepage.jpg')];
 
     await loadFiles(invalidFiles);
 
-    await expectLoadResult(0, [new UnsupportedFileFormat('winscope_homepage.png')]);
+    await expectLoadResult(0, [new UnsupportedFileFormat('winscope_homepage.jpg')]);
   });
 
   it('is robust to mixed valid and invalid trace files', async () => {
     expect(tracePipeline.getTraces().getSize()).toEqual(0);
     const files = [
-      await UnitTestUtils.getFixtureFile('winscope_homepage.png'),
+      await UnitTestUtils.getFixtureFile('winscope_homepage.jpg'),
       await UnitTestUtils.getFixtureFile('traces/dump_WindowManager.pb'),
     ];
 
     await loadFiles(files);
 
-    await expectLoadResult(1, [new UnsupportedFileFormat('winscope_homepage.png')]);
+    await expectLoadResult(1, [new UnsupportedFileFormat('winscope_homepage.jpg')]);
   });
 
   it('can remove traces', async () => {
@@ -205,6 +206,31 @@
     expect(video?.size).toBeGreaterThan(0);
   });
 
+  it('gets screenshot data', async () => {
+    const files = [await UnitTestUtils.getFixtureFile('traces/screenshot.png')];
+    await loadFiles(files);
+    await expectLoadResult(1, []);
+
+    const video = await tracePipeline.getScreenRecordingVideo();
+    expect(video).toBeDefined();
+    expect(video?.size).toBeGreaterThan(0);
+  });
+
+  it('prioritises screenrecording over screenshot data', async () => {
+    const files = [
+      await UnitTestUtils.getFixtureFile('traces/screenshot.png'),
+      await UnitTestUtils.getFixtureFile(
+        'traces/elapsed_and_real_timestamp/screen_recording_metadata_v2.mp4'
+      ),
+    ];
+    await loadFiles(files);
+    await expectLoadResult(1, [new TraceOverridden('screenshot.png', TraceType.SCREEN_RECORDING)]);
+
+    const video = await tracePipeline.getScreenRecordingVideo();
+    expect(video).toBeDefined();
+    expect(video?.size).toBeGreaterThan(0);
+  });
+
   it('creates traces with correct type', async () => {
     await loadFiles([validSfFile, validWmFile]);
     await expectLoadResult(2, []);
diff --git a/tools/winscope/src/messaging/winscope_error.ts b/tools/winscope/src/messaging/winscope_error.ts
index 1ce4ffd..ce468d6 100644
--- a/tools/winscope/src/messaging/winscope_error.ts
+++ b/tools/winscope/src/messaging/winscope_error.ts
@@ -16,6 +16,7 @@
 
 import {ElapsedTimestamp, TimeRange} from 'common/time';
 import {TimeUtils} from 'common/time_utils';
+import {TraceType} from 'trace/trace_type';
 
 export interface WinscopeError {
   getType(): string;
@@ -73,13 +74,18 @@
 }
 
 export class TraceOverridden implements WinscopeError {
-  constructor(private readonly descriptor: string) {}
+  constructor(private readonly descriptor: string, private readonly overridingType?: TraceType) {}
 
   getType(): string {
     return 'trace overridden';
   }
 
   getMessage(): string {
+    if (this.overridingType !== undefined) {
+      return `${this.descriptor}: overridden by another trace of type ${
+        TraceType[this.overridingType]
+      }`;
+    }
     return `${this.descriptor}: overridden by another trace of same type`;
   }
 }
diff --git a/tools/winscope/src/parsers/abstract_parser.ts b/tools/winscope/src/parsers/abstract_parser.ts
index 2594f53..5b0260f 100644
--- a/tools/winscope/src/parsers/abstract_parser.ts
+++ b/tools/winscope/src/parsers/abstract_parser.ts
@@ -46,7 +46,7 @@
 
   async parse() {
     const traceBuffer = new Uint8Array(await this.traceFile.file.arrayBuffer());
-    ParsingUtils.throwIfMagicNumberDoesntMatch(traceBuffer, this.getMagicNumber());
+    ParsingUtils.throwIfMagicNumberDoesNotMatch(traceBuffer, this.getMagicNumber());
     this.decodedEntries = this.decodeTrace(traceBuffer);
     this.timestamps = this.decodeTimestamps();
   }
diff --git a/tools/winscope/src/parsers/parser_factory.ts b/tools/winscope/src/parsers/parser_factory.ts
index d00b934..935a269 100644
--- a/tools/winscope/src/parsers/parser_factory.ts
+++ b/tools/winscope/src/parsers/parser_factory.ts
@@ -27,6 +27,7 @@
 import {ParserInputMethodManagerService} from './input_method/parser_input_method_manager_service';
 import {ParserInputMethodService} from './input_method/parser_input_method_service';
 import {ParserProtoLog} from './protolog/parser_protolog';
+import {ParserScreenshot} from './screen_recording/parser_screenshot';
 import {ParserScreenRecording} from './screen_recording/parser_screen_recording';
 import {ParserScreenRecordingLegacy} from './screen_recording/parser_screen_recording_legacy';
 import {ParserSurfaceFlinger} from './surface_flinger/parser_surface_flinger';
@@ -51,6 +52,7 @@
     ParserTransitionsWm,
     ParserTransitionsShell,
     ParserViewCapture,
+    ParserScreenshot,
   ];
 
   async createParsers(
diff --git a/tools/winscope/src/parsers/parsing_utils.ts b/tools/winscope/src/parsers/parsing_utils.ts
index 3e56a19..9bb4bf6 100644
--- a/tools/winscope/src/parsers/parsing_utils.ts
+++ b/tools/winscope/src/parsers/parsing_utils.ts
@@ -17,7 +17,10 @@
 import {ArrayUtils} from 'common/array_utils';
 
 export class ParsingUtils {
-  static throwIfMagicNumberDoesntMatch(traceBuffer: Uint8Array, magicNumber: number[] | undefined) {
+  static throwIfMagicNumberDoesNotMatch(
+    traceBuffer: Uint8Array,
+    magicNumber: number[] | undefined
+  ) {
     if (magicNumber !== undefined) {
       const bufferContainsMagicNumber = ArrayUtils.equal(
         magicNumber,
diff --git a/tools/winscope/src/parsers/screen_recording/parser_screenshot.ts b/tools/winscope/src/parsers/screen_recording/parser_screenshot.ts
new file mode 100644
index 0000000..9ee5bb1
--- /dev/null
+++ b/tools/winscope/src/parsers/screen_recording/parser_screenshot.ts
@@ -0,0 +1,61 @@
+/*
+ * 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 {Timestamp, TimestampType} from 'common/time';
+import {AbstractParser} from 'parsers/abstract_parser';
+import {ScreenRecordingTraceEntry} from 'trace/screen_recording';
+import {TraceFile} from 'trace/trace_file';
+import {TraceType} from 'trace/trace_type';
+
+class ParserScreenshot extends AbstractParser<ScreenRecordingTraceEntry> {
+  private static readonly MAGIC_NUMBER = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; // currently only support png files
+
+  constructor(trace: TraceFile) {
+    super(trace);
+  }
+
+  override getTraceType(): TraceType {
+    return TraceType.SCREENSHOT;
+  }
+
+  override getMagicNumber(): number[] | undefined {
+    return ParserScreenshot.MAGIC_NUMBER;
+  }
+
+  override getTimestamp(type: TimestampType, decodedEntry: number): Timestamp | undefined {
+    if (type === TimestampType.ELAPSED) {
+      return new Timestamp(TimestampType.ELAPSED, 0n);
+    } else if (type === TimestampType.REAL) {
+      return new Timestamp(TimestampType.REAL, 0n);
+    }
+    return undefined;
+  }
+
+  override decodeTrace(screenshotData: Uint8Array): number[] {
+    return [0]; // require a non-empty array to be returned so trace can provide timestamps
+  }
+
+  override processDecodedEntry(
+    index: number,
+    timestampType: TimestampType,
+    entry: number
+  ): ScreenRecordingTraceEntry {
+    const screenshotData = this.traceFile.file;
+    return new ScreenRecordingTraceEntry(0, screenshotData, true);
+  }
+}
+
+export {ParserScreenshot};
diff --git a/tools/winscope/src/parsers/screen_recording/parser_screenshot_test.ts b/tools/winscope/src/parsers/screen_recording/parser_screenshot_test.ts
new file mode 100644
index 0000000..1df898f
--- /dev/null
+++ b/tools/winscope/src/parsers/screen_recording/parser_screenshot_test.ts
@@ -0,0 +1,57 @@
+/*
+ * 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 {Timestamp, TimestampType} from 'common/time';
+import {UnitTestUtils} from 'test/unit/utils';
+import {ScreenRecordingTraceEntry} from 'trace/screen_recording';
+import {TraceFile} from 'trace/trace_file';
+import {TraceType} from 'trace/trace_type';
+import {ParserScreenshot} from './parser_screenshot';
+
+describe('ParserScreenshot', () => {
+  let parser: ParserScreenshot;
+
+  beforeAll(async () => {
+    const file = await UnitTestUtils.getFixtureFile('traces/screenshot.png');
+    parser = new ParserScreenshot(new TraceFile(file));
+    await parser.parse();
+  });
+
+  it('has expected trace type', () => {
+    expect(parser.getTraceType()).toEqual(TraceType.SCREENSHOT);
+  });
+
+  it('provides elapsed timestamps', () => {
+    const timestamps = assertDefined(parser.getTimestamps(TimestampType.ELAPSED));
+
+    const expected = new Timestamp(TimestampType.ELAPSED, 0n);
+    timestamps.forEach((timestamp) => expect(timestamp).toEqual(expected));
+  });
+
+  it('provides real timestamps', () => {
+    const timestamps = assertDefined(parser.getTimestamps(TimestampType.REAL));
+
+    const expected = new Timestamp(TimestampType.REAL, 0n);
+    timestamps.forEach((timestamp) => expect(timestamp).toEqual(expected));
+  });
+
+  it('retrieves entry', async () => {
+    const entry = await parser.getEntry(0, TimestampType.REAL);
+    expect(entry).toBeInstanceOf(ScreenRecordingTraceEntry);
+    expect(entry.isImage).toBeTrue();
+  });
+});
diff --git a/tools/winscope/src/parsers/view_capture/parser_view_capture.ts b/tools/winscope/src/parsers/view_capture/parser_view_capture.ts
index 1484005..df4c72f 100644
--- a/tools/winscope/src/parsers/view_capture/parser_view_capture.ts
+++ b/tools/winscope/src/parsers/view_capture/parser_view_capture.ts
@@ -31,7 +31,7 @@
 
   async parse() {
     const traceBuffer = new Uint8Array(await this.traceFile.file.arrayBuffer());
-    ParsingUtils.throwIfMagicNumberDoesntMatch(traceBuffer, ParserViewCapture.MAGIC_NUMBER);
+    ParsingUtils.throwIfMagicNumberDoesNotMatch(traceBuffer, ParserViewCapture.MAGIC_NUMBER);
 
     const exportedData = ExportedData.decode(
       traceBuffer
diff --git a/tools/winscope/src/test/fixtures/traces/screenshot.png b/tools/winscope/src/test/fixtures/traces/screenshot.png
new file mode 100644
index 0000000..4946b2d
--- /dev/null
+++ b/tools/winscope/src/test/fixtures/traces/screenshot.png
Binary files differ
diff --git a/tools/winscope/src/test/fixtures/winscope_homepage.jpg b/tools/winscope/src/test/fixtures/winscope_homepage.jpg
new file mode 100644
index 0000000..5432ba2
--- /dev/null
+++ b/tools/winscope/src/test/fixtures/winscope_homepage.jpg
Binary files differ
diff --git a/tools/winscope/src/test/fixtures/winscope_homepage.png b/tools/winscope/src/test/fixtures/winscope_homepage.png
deleted file mode 100644
index 67d30af..0000000
--- a/tools/winscope/src/test/fixtures/winscope_homepage.png
+++ /dev/null
Binary files differ
diff --git a/tools/winscope/src/trace/screen_recording.ts b/tools/winscope/src/trace/screen_recording.ts
index 342ab1a..b77b317 100644
--- a/tools/winscope/src/trace/screen_recording.ts
+++ b/tools/winscope/src/trace/screen_recording.ts
@@ -15,7 +15,11 @@
  */
 
 class ScreenRecordingTraceEntry {
-  constructor(public videoTimeSeconds: number, public videoData: Blob) {}
+  constructor(
+    public videoTimeSeconds: number,
+    public videoData: Blob,
+    public isImage: boolean = false
+  ) {}
 }
 
 export {ScreenRecordingTraceEntry};
diff --git a/tools/winscope/src/trace/trace_type.ts b/tools/winscope/src/trace/trace_type.ts
index 436ca4f..fff9f86 100644
--- a/tools/winscope/src/trace/trace_type.ts
+++ b/tools/winscope/src/trace/trace_type.ts
@@ -22,6 +22,7 @@
   WINDOW_MANAGER,
   SURFACE_FLINGER,
   SCREEN_RECORDING,
+  SCREENSHOT,
   TRANSACTIONS,
   TRANSACTIONS_LEGACY,
   WAYLAND,
@@ -58,6 +59,7 @@
   [TraceType.PROTO_LOG]: PropertyTreeNode;
   [TraceType.SURFACE_FLINGER]: HierarchyTreeNode;
   [TraceType.SCREEN_RECORDING]: ScreenRecordingTraceEntry;
+  [TraceType.SCREENSHOT]: ScreenRecordingTraceEntry;
   [TraceType.SYSTEM_UI]: object;
   [TraceType.TRANSACTIONS]: PropertyTreeNode;
   [TraceType.TRANSACTIONS_LEGACY]: object;
diff --git a/tools/winscope/src/viewers/viewer_factory.ts b/tools/winscope/src/viewers/viewer_factory.ts
index dae30ca..aa92fbd 100644
--- a/tools/winscope/src/viewers/viewer_factory.ts
+++ b/tools/winscope/src/viewers/viewer_factory.ts
@@ -21,6 +21,7 @@
 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 {ViewerProtoLog} from './viewer_protolog/viewer_protolog';
+import {ViewerScreenshot} from './viewer_screen_recording/viewer_screenshot';
 import {ViewerScreenRecording} from './viewer_screen_recording/viewer_screen_recording';
 import {ViewerSurfaceFlinger} from './viewer_surface_flinger/viewer_surface_flinger';
 import {ViewerTransactions} from './viewer_transactions/viewer_transactions';
@@ -45,6 +46,7 @@
     ViewerTransactions,
     ViewerProtoLog,
     ViewerScreenRecording,
+    ViewerScreenshot,
     ViewerTransitions,
     ViewerViewCaptureLauncherActivity,
     ViewerViewCaptureTaskbarDragLayer,
diff --git a/tools/winscope/src/viewers/viewer_screen_recording/viewer_screen_recording_component.ts b/tools/winscope/src/viewers/viewer_screen_recording/viewer_screen_recording_component.ts
index b750dd1..0d3cb88 100644
--- a/tools/winscope/src/viewers/viewer_screen_recording/viewer_screen_recording_component.ts
+++ b/tools/winscope/src/viewers/viewer_screen_recording/viewer_screen_recording_component.ts
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Component, ElementRef, Inject, Input} from '@angular/core';
+import {Component, Inject, Input, SimpleChanges} from '@angular/core';
 import {DomSanitizer, SafeUrl} from '@angular/platform-browser';
 import {ScreenRecordingTraceEntry} from 'trace/screen_recording';
 
@@ -24,7 +24,7 @@
       <mat-card-title class="header">
         <button mat-button class="button-drag" cdkDragHandle>
           <mat-icon class="drag-icon">drag_indicator</mat-icon>
-          <span class="mat-body-2">Screen recording</span>
+          <span class="mat-body-2">{{ title }}</span>
         </button>
 
         <button mat-button class="button-minimize" (click)="onMinimizeButtonClick()">
@@ -34,20 +34,22 @@
         </button>
       </mat-card-title>
       <div class="video-container" [style.height]="isMinimized ? '0px' : ''">
-        <ng-container *ngIf="hasFrameToShow; then video; else noVideo"> </ng-container>
+        <ng-container *ngIf="hasFrameToShow(); then video; else noVideo"> </ng-container>
       </div>
     </mat-card>
 
     <ng-template #video>
       <video
-        *ngIf="hasFrameToShow"
-        [currentTime]="videoCurrentTime"
-        [src]="videoUrl"
+        *ngIf="hasFrameToShow()"
+        [currentTime]="currentTraceEntry.videoTimeSeconds"
+        [src]="safeUrl"
         cdkDragHandle></video>
     </ng-template>
 
     <ng-template #noVideo>
-      <div class="no-video">
+      <img *ngIf="hasImage()" [src]="safeUrl" />
+
+      <div class="no-video" *ngIf="!hasImage()">
         <p class="mat-body-2">No screen recording frame to show.</p>
         <p class="mat-body-1">Current timestamp is still before first frame.</p>
       </div>
@@ -86,7 +88,8 @@
       }
 
       .video-container,
-      video {
+      video,
+      img {
         border: 1px solid var(--default-border);
         max-width: max(250px, 15vw);
         cursor: grab;
@@ -101,41 +104,42 @@
   ],
 })
 class ViewerScreenRecordingComponent {
-  constructor(
-    @Inject(ElementRef) elementRef: ElementRef,
-    @Inject(DomSanitizer) sanitizer: DomSanitizer
-  ) {
-    this.elementRef = elementRef;
-    this.sanitizer = sanitizer;
-  }
+  safeUrl: undefined | SafeUrl = undefined;
+  isMinimized = false;
 
-  @Input()
-  set currentTraceEntry(entry: undefined | ScreenRecordingTraceEntry) {
-    if (entry === undefined) {
-      this.videoCurrentTime = undefined;
+  constructor(@Inject(DomSanitizer) private sanitizer: DomSanitizer) {}
+
+  @Input() currentTraceEntry: ScreenRecordingTraceEntry | undefined;
+  @Input() title = 'Screen recording';
+
+  ngOnChanges(changes: SimpleChanges) {
+    if (this.currentTraceEntry === undefined) {
       return;
     }
 
-    if (this.videoUrl === undefined) {
-      this.videoUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(entry.videoData));
+    if (!changes['currentTraceEntry']) {
+      return;
     }
 
-    this.videoCurrentTime = entry.videoTimeSeconds;
+    if (this.safeUrl === undefined) {
+      this.safeUrl = this.sanitizer.bypassSecurityTrustUrl(
+        URL.createObjectURL(this.currentTraceEntry.videoData)
+      );
+    }
   }
 
   onMinimizeButtonClick() {
     this.isMinimized = !this.isMinimized;
   }
 
-  videoUrl: undefined | SafeUrl = undefined;
-  videoCurrentTime: number | undefined = undefined;
-  isMinimized = false;
+  hasFrameToShow() {
+    return (
+      !this.currentTraceEntry?.isImage && this.currentTraceEntry?.videoTimeSeconds !== undefined
+    );
+  }
 
-  private elementRef: ElementRef;
-  private sanitizer: DomSanitizer;
-
-  get hasFrameToShow() {
-    return this.videoCurrentTime !== undefined;
+  hasImage() {
+    return this.currentTraceEntry?.isImage ?? false;
   }
 }
 
diff --git a/tools/winscope/src/viewers/viewer_screen_recording/viewer_screen_recording_component_test.ts b/tools/winscope/src/viewers/viewer_screen_recording/viewer_screen_recording_component_test.ts
index 79baf12..d570b96 100644
--- a/tools/winscope/src/viewers/viewer_screen_recording/viewer_screen_recording_component_test.ts
+++ b/tools/winscope/src/viewers/viewer_screen_recording/viewer_screen_recording_component_test.ts
@@ -13,9 +13,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
 import {ComponentFixture, ComponentFixtureAutoDetect, TestBed} from '@angular/core/testing';
 import {MatCardModule} from '@angular/material/card';
+import {assertDefined} from 'common/assert_utils';
+import {UnitTestUtils} from 'test/unit/utils';
+import {ScreenRecordingTraceEntry} from 'trace/screen_recording';
 import {ViewerScreenRecordingComponent} from './viewer_screen_recording_component';
 
 describe('ViewerScreenRecordingComponent', () => {
@@ -34,7 +38,6 @@
     fixture = TestBed.createComponent(ViewerScreenRecordingComponent);
     component = fixture.componentInstance;
     htmlElement = fixture.nativeElement;
-
     fixture.detectChanges();
   });
 
@@ -42,19 +45,60 @@
     expect(component).toBeTruthy();
   });
 
+  it('renders title correctly', () => {
+    const title = assertDefined(htmlElement.querySelector('.header')) as HTMLElement;
+    expect(title.innerHTML).toContain('Screen recording');
+
+    component.title = 'Screenshot';
+    fixture.detectChanges();
+    expect(title.innerHTML).toContain('Screenshot');
+  });
+
   it('can be minimized and maximized', () => {
-    const buttonMinimize = htmlElement.querySelector('.button-minimize');
-    const videoContainer = htmlElement.querySelector('.video-container') as HTMLElement;
-    expect(buttonMinimize).toBeTruthy();
-    expect(videoContainer).toBeTruthy();
-    expect(videoContainer!.style.height).toEqual('');
+    const buttonMinimize = assertDefined(
+      htmlElement.querySelector('.button-minimize')
+    ) as HTMLButtonElement;
+    const videoContainer = assertDefined(
+      htmlElement.querySelector('.video-container')
+    ) as HTMLElement;
+    expect(videoContainer.style.height).toEqual('');
 
-    buttonMinimize!.dispatchEvent(new Event('click'));
+    buttonMinimize.click();
     fixture.detectChanges();
-    expect(videoContainer!.style.height).toEqual('0px');
+    expect(videoContainer.style.height).toEqual('0px');
 
-    buttonMinimize!.dispatchEvent(new Event('click'));
+    buttonMinimize.click();
     fixture.detectChanges();
-    expect(videoContainer!.style.height).toEqual('');
+    expect(videoContainer.style.height).toEqual('');
+  });
+
+  it('shows video', async () => {
+    const videoFile = await UnitTestUtils.getFixtureFile(
+      'traces/elapsed_and_real_timestamp/screen_recording_metadata_v2.mp4'
+    );
+    component.currentTraceEntry = new ScreenRecordingTraceEntry(1, videoFile);
+    fixture.detectChanges();
+    const videoContainer = assertDefined(
+      htmlElement.querySelector('.video-container')
+    ) as HTMLElement;
+    expect(videoContainer.querySelector('video')).toBeTruthy();
+    expect(videoContainer.querySelector('img')).toBeNull();
+  });
+
+  it('shows screenshot image', () => {
+    component.currentTraceEntry = new ScreenRecordingTraceEntry(0, new Blob(), true);
+    fixture.detectChanges();
+    const videoContainer = assertDefined(
+      htmlElement.querySelector('.video-container')
+    ) as HTMLElement;
+    expect(videoContainer.querySelector('img')).toBeTruthy();
+    expect(videoContainer.querySelector('video')).toBeNull();
+  });
+
+  it('shows no frame message', () => {
+    const videoContainer = assertDefined(
+      htmlElement.querySelector('.video-container')
+    ) as HTMLElement;
+    expect(videoContainer.innerHTML).toContain('No screen recording frame to show');
   });
 });
diff --git a/tools/winscope/src/viewers/viewer_screen_recording/viewer_screenshot.ts b/tools/winscope/src/viewers/viewer_screen_recording/viewer_screenshot.ts
new file mode 100644
index 0000000..72693b1
--- /dev/null
+++ b/tools/winscope/src/viewers/viewer_screen_recording/viewer_screenshot.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 {WinscopeEvent, WinscopeEventType} from 'messaging/winscope_event';
+import {ScreenRecordingTraceEntry} from 'trace/screen_recording';
+import {Trace} from 'trace/trace';
+import {Traces} from 'trace/traces';
+import {TraceEntryFinder} from 'trace/trace_entry_finder';
+import {TraceType} from 'trace/trace_type';
+import {View, Viewer, ViewType} from 'viewers/viewer';
+import {ViewerScreenRecordingComponent} from './viewer_screen_recording_component';
+
+class ViewerScreenshot implements Viewer {
+  static readonly DEPENDENCIES: TraceType[] = [TraceType.SCREENSHOT];
+
+  private readonly trace: Trace<ScreenRecordingTraceEntry>;
+  private readonly htmlElement: HTMLElement;
+  private readonly view: View;
+
+  constructor(traces: Traces) {
+    this.trace = assertDefined(traces.getTrace(TraceType.SCREENSHOT));
+    this.htmlElement = document.createElement('viewer-screen-recording');
+    this.view = new View(
+      ViewType.OVERLAY,
+      this.getDependencies(),
+      this.htmlElement,
+      'Screenshot',
+      TraceType.SCREENSHOT
+    );
+  }
+
+  async onWinscopeEvent(event: WinscopeEvent) {
+    await event.visit(WinscopeEventType.TRACE_POSITION_UPDATE, async (event) => {
+      const entry = TraceEntryFinder.findCorrespondingEntry(this.trace, event.position);
+      (this.htmlElement as unknown as ViewerScreenRecordingComponent).currentTraceEntry =
+        await entry?.getValue();
+      (this.htmlElement as unknown as ViewerScreenRecordingComponent).title = 'Screenshot';
+    });
+  }
+
+  setEmitEvent() {
+    // do nothing
+  }
+
+  getViews(): View[] {
+    return [this.view];
+  }
+
+  getDependencies(): TraceType[] {
+    return ViewerScreenshot.DEPENDENCIES;
+  }
+}
+
+export {ViewerScreenshot};