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};