diff --git a/ui/src/plugins/com.android.AndroidInputLifecycle/android_input_event_source.ts b/ui/src/plugins/com.android.AndroidInputLifecycle/android_input_event_source.ts new file mode 100644 index 00000000000..9a73ed3682f --- /dev/null +++ b/ui/src/plugins/com.android.AndroidInputLifecycle/android_input_event_source.ts @@ -0,0 +1,318 @@ +// Copyright (C) 2026 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 {Trace} from '../../public/trace'; +import { + EventSource, + RelatedEventData, + RelatedEvent, + Relation, + getTrackUriForTrackId, + NavTarget, +} from '../dev.perfetto.RelatedEvents'; +import {time, duration} from '../../base/time'; +import {enrichDepths} from '../dev.perfetto.RelatedEvents/utils'; +import {STR, NUM_NULL} from '../../trace_processor/query_result'; + +export type OnDataLoadedCallback = (data: RelatedEventData) => void; + +export class AndroidInputEventSource implements EventSource { + private onDataLoadedCallback?: OnDataLoadedCallback; + + constructor(private trace: Trace) {} + + setOnDataLoadedCallback(callback: OnDataLoadedCallback) { + this.onDataLoadedCallback = callback; + } + + async getRelatedEventData(eventId: number): Promise { + const result = await this.trace.engine.query( + `SELECT * FROM _android_input_lifecycle_by_slice_id(${eventId})`, + ); + + const events: RelatedEvent[] = []; + const relations: Relation[] = []; + const overlayEvents: RelatedEvent[] = []; + const overlayRelations: Relation[] = []; + + const it = result.iter({ + input_id: STR, + channel: STR, + total_latency: NUM_NULL, + + ts_reader: NUM_NULL, + id_reader: NUM_NULL, + track_reader: NUM_NULL, + dur_reader: NUM_NULL, + + ts_dispatch: NUM_NULL, + id_dispatch: NUM_NULL, + track_dispatch: NUM_NULL, + dur_dispatch: NUM_NULL, + + ts_receive: NUM_NULL, + id_receive: NUM_NULL, + track_receive: NUM_NULL, + dur_receive: NUM_NULL, + + ts_consume: NUM_NULL, + id_consume: NUM_NULL, + track_consume: NUM_NULL, + dur_consume: NUM_NULL, + + ts_frame: NUM_NULL, + id_frame: NUM_NULL, + track_frame: NUM_NULL, + dur_frame: NUM_NULL, + }); + if (!it.valid()) { + const data = {events: [], relations: [], overlayEvents, overlayRelations}; + this.onDataLoadedCallback?.(data); + return data; + } + + const channel = it.channel; + const totalLatency = + it.total_latency !== null ? (BigInt(it.total_latency) as duration) : null; + + let readerEvent: RelatedEvent | undefined; + if ( + it.id_reader !== null && + it.ts_reader !== null && + it.track_reader !== null && + it.dur_reader !== null + ) { + const trackUri = getTrackUriForTrackId(this.trace, it.track_reader); + if (trackUri) { + readerEvent = { + id: it.id_reader, + ts: BigInt(it.ts_reader) as time, + dur: BigInt(it.dur_reader) as duration, + trackUri, + type: 'InputReader', + customArgs: { + channel, + totalLatency, + stageDur: BigInt(it.dur_reader) as duration, + }, + }; + overlayEvents.push(readerEvent); + } + } + + let dispatchEvent: RelatedEvent | undefined; + if ( + it.id_dispatch !== null && + it.ts_dispatch !== null && + it.track_dispatch !== null && + it.dur_dispatch !== null + ) { + const trackUri = getTrackUriForTrackId(this.trace, it.track_dispatch); + if (trackUri) { + dispatchEvent = { + id: it.id_dispatch, + ts: BigInt(it.ts_dispatch) as time, + dur: BigInt(it.dur_dispatch) as duration, + trackUri, + type: 'InputDispatcher', + customArgs: { + channel, + totalLatency, + stageDur: BigInt(it.dur_dispatch) as duration, + }, + }; + overlayEvents.push(dispatchEvent); + } + } + + let receiveEvent: RelatedEvent | undefined; + if ( + it.id_receive !== null && + it.ts_receive !== null && + it.track_receive !== null && + it.dur_receive !== null + ) { + const trackUri = getTrackUriForTrackId(this.trace, it.track_receive); + if (trackUri) { + receiveEvent = { + id: it.id_receive, + ts: BigInt(it.ts_receive) as time, + dur: BigInt(it.dur_receive) as duration, + trackUri, + type: 'AppReceive', + customArgs: { + channel, + totalLatency, + stageDur: BigInt(it.dur_receive) as duration, + }, + }; + overlayEvents.push(receiveEvent); + } + } + + let consumeEvent: RelatedEvent | undefined; + if ( + it.id_consume !== null && + it.ts_consume !== null && + it.track_consume !== null && + it.dur_consume !== null + ) { + const trackUri = getTrackUriForTrackId(this.trace, it.track_consume); + if (trackUri) { + consumeEvent = { + id: it.id_consume, + ts: BigInt(it.ts_consume) as time, + dur: BigInt(it.dur_consume) as duration, + trackUri, + type: 'AppConsume', + customArgs: { + channel, + totalLatency, + stageDur: BigInt(it.dur_consume) as duration, + }, + }; + overlayEvents.push(consumeEvent); + } + } + + let frameEvent: RelatedEvent | undefined; + if ( + it.id_frame !== null && + it.ts_frame !== null && + it.track_frame !== null && + it.dur_frame !== null + ) { + const trackUri = getTrackUriForTrackId(this.trace, it.track_frame); + if (trackUri) { + frameEvent = { + id: it.id_frame, + ts: BigInt(it.ts_frame) as time, + dur: BigInt(it.dur_frame) as duration, + trackUri, + type: 'AppFrame', + customArgs: { + channel, + totalLatency, + stageDur: BigInt(it.dur_frame) as duration, + }, + }; + overlayEvents.push(frameEvent); + } + } + + const stages = [ + readerEvent, + dispatchEvent, + receiveEvent, + consumeEvent, + frameEvent, + ]; + for (let i = 0; i < stages.length - 1; i++) { + const source = stages[i]; + const target = stages[i + 1]; + if (source && target) { + const relation: Relation = { + sourceId: source.id, + targetId: target.id, + type: 'lifecycle_step', + }; + overlayRelations.push(relation); + } + } + + await enrichDepths(this.trace, overlayEvents); + + const getDelta = ( + start: RelatedEvent | undefined, + end: RelatedEvent | undefined, + ): duration | null => { + if (!start || !end) return null; + return (end.ts - (start.ts + start.dur)) as duration; + }; + + // This is for the tab view, which shows a single row per channel + const tabEvent: RelatedEvent = { + id: eventId, + ts: (readerEvent?.ts ?? + dispatchEvent?.ts ?? + receiveEvent?.ts ?? + 0n) as time, + dur: (totalLatency ?? 0n) as duration, + trackUri: '', + type: 'InputLifecycle', + customArgs: { + channel, + totalLatency, + reader: readerEvent + ? { + dur: readerEvent.customArgs?.stageDur, + nav: this.createNavTarget(readerEvent), + } + : null, + dispatcher: dispatchEvent + ? { + delta: getDelta(readerEvent, dispatchEvent), + dur: dispatchEvent.customArgs?.stageDur, + nav: this.createNavTarget(dispatchEvent), + } + : null, + receiver: receiveEvent + ? { + delta: getDelta(dispatchEvent, receiveEvent), + dur: receiveEvent.customArgs?.stageDur, + nav: this.createNavTarget(receiveEvent), + } + : null, + consumer: consumeEvent + ? { + delta: getDelta(receiveEvent, consumeEvent), + dur: consumeEvent.customArgs?.stageDur, + nav: this.createNavTarget(consumeEvent), + } + : null, + frame: frameEvent + ? { + delta: getDelta(consumeEvent, frameEvent), + dur: frameEvent.customArgs?.stageDur, + nav: this.createNavTarget(frameEvent), + } + : null, + allTrackIds: [ + it.track_reader, + it.track_dispatch, + it.track_receive, + it.track_consume, + it.track_frame, + ].filter((t) => t !== null) as number[], + }, + }; + + events.push(tabEvent); + + const data = {events, relations, overlayEvents, overlayRelations}; + this.onDataLoadedCallback?.(data); + return data; + } + + private createNavTarget(event: RelatedEvent): NavTarget | undefined { + if (event == undefined) return undefined; + return { + id: event.id, + trackUri: event.trackUri, + ts: event.ts, + dur: event.dur, + depth: event.depth !== undefined ? event.depth : 0, + }; + } +} diff --git a/ui/src/plugins/com.android.AndroidInputLifecycle/index.ts b/ui/src/plugins/com.android.AndroidInputLifecycle/index.ts index 6f5dba73f95..07ee7dfdda4 100644 --- a/ui/src/plugins/com.android.AndroidInputLifecycle/index.ts +++ b/ui/src/plugins/com.android.AndroidInputLifecycle/index.ts @@ -14,38 +14,48 @@ import {PerfettoPlugin} from '../../public/plugin'; import {Trace} from '../../public/trace'; -import {LifecycleOverlay} from './overlay'; -import {AndroidInputTab} from './tab'; +import RelatedEventsPlugin, { + TrackPinningManager, +} from '../dev.perfetto.RelatedEvents'; +import {GenericRelatedEventsOverlay} from '../dev.perfetto.RelatedEvents/generic_overlay'; +import {AndroidInputEventSource} from './android_input_event_source'; +import {AndroidInputLifecycleTab} from './tab'; export default class AndroidInputLifecyclePlugin implements PerfettoPlugin { static readonly id = 'com.android.AndroidInputLifecycle'; static readonly description = ` Visualise connected input events in the lifecycle from touch to frame, with latencies for the various input stages. - Activate by running the command 'Android: View Input Flow' + Activate by running the command 'Android: View Input Lifecycle' `; + static readonly dependencies = [RelatedEventsPlugin]; async onTraceLoad(trace: Trace): Promise { await trace.engine.query('INCLUDE PERFETTO MODULE android.input;'); - const overlay = new LifecycleOverlay(trace); + const overlay = new GenericRelatedEventsOverlay(trace); trace.tracks.registerOverlay(overlay); - const tab = new AndroidInputTab(trace, overlay); - const tabUri = 'com.android.InputLifecycles'; + const source = new AndroidInputEventSource(trace); + source.setOnDataLoadedCallback((data) => { + overlay.update(data); + }); + + const pinningManager = new TrackPinningManager(); + + const tab = new AndroidInputLifecycleTab(trace, source, pinningManager); trace.tabs.registerTab({ - uri: tabUri, + uri: 'com.android.AndroidInputLifecycleTab', isEphemeral: false, content: tab, - onHide() { - tab.onHide(); - }, }); trace.commands.registerCommand({ - id: 'com.android.AndroidInputLifecycle#ViewFlow', - name: 'Android: View Input Flow', - callback: () => trace.tabs.showTab(tabUri), + id: 'openAndroidInputLifecycleTab', + name: 'Android: View Input Lifecycle', + callback: () => { + trace.tabs.showTab('com.android.AndroidInputLifecycleTab'); + }, }); } } diff --git a/ui/src/plugins/com.android.AndroidInputLifecycle/overlay.ts b/ui/src/plugins/com.android.AndroidInputLifecycle/overlay.ts deleted file mode 100644 index 2744ac34a51..00000000000 --- a/ui/src/plugins/com.android.AndroidInputLifecycle/overlay.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (C) 2026 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 {Size2D} from '../../base/geom'; -import {TimeScale} from '../../base/time_scale'; -import {Trace} from '../../public/trace'; -import {Overlay, TrackBounds} from '../../public/track'; -import {ArrowConnection, ArrowVisualiser} from './arrow_visualiser'; - -export class LifecycleOverlay implements Overlay { - private readonly arrowVisualiser: ArrowVisualiser; - private connections: ArrowConnection[] = []; - - constructor(trace: Trace) { - this.arrowVisualiser = new ArrowVisualiser(trace); - } - - update(connections: ArrowConnection[]) { - this.connections = connections; - } - - render( - ctx: CanvasRenderingContext2D, - ts: TimeScale, - _size: Size2D, - tracks: ReadonlyArray, - ): void { - if (this.connections.length > 0) { - this.arrowVisualiser.draw(ctx, ts, tracks, this.connections); - } - } -} diff --git a/ui/src/plugins/com.android.AndroidInputLifecycle/tab.ts b/ui/src/plugins/com.android.AndroidInputLifecycle/tab.ts index 88232db2990..1f752adab10 100644 --- a/ui/src/plugins/com.android.AndroidInputLifecycle/tab.ts +++ b/ui/src/plugins/com.android.AndroidInputLifecycle/tab.ts @@ -13,39 +13,54 @@ // limitations under the License. import m from 'mithril'; -import {Time, Duration, duration, time} from '../../base/time'; -import {Trace} from '../../public/trace'; -import {DetailsShell} from '../../widgets/details_shell'; -import {Grid, GridHeaderCell, GridCell, GridColumn} from '../../widgets/grid'; -import {EmptyState} from '../../widgets/empty_state'; +import z from 'zod'; +import {GridColumn, GridHeaderCell, Grid, GridCell} from '../../widgets/grid'; import {Spinner} from '../../widgets/spinner'; +import {AndroidInputEventSource} from './android_input_event_source'; import { - NUM, - STR, - LONG, - LONG_NULL, - NUM_NULL, - UNKNOWN, -} from '../../trace_processor/query_result'; -import {Dataset, UnionDatasetWithLineage} from '../../trace_processor/dataset'; -import {Anchor} from '../../widgets/anchor'; + getTrackUriForTrackId, + TrackPinningManager, + NavTarget, + durationSchema, + NavTargetSchema, +} from '../dev.perfetto.RelatedEvents'; import {Icons} from '../../base/semantic_icons'; -import {Checkbox} from '../../widgets/checkbox'; -import {LifecycleOverlay} from './overlay'; -import {ArrowConnection} from './arrow_visualiser'; +import {duration} from '../../base/time'; import {DurationWidget} from '../../components/widgets/duration'; import {Tab} from '../../public/tab'; +import {Trace} from '../../public/trace'; +import {Anchor} from '../../widgets/anchor'; +import {Checkbox} from '../../widgets/checkbox'; +import {DetailsShell} from '../../widgets/details_shell'; +import {EmptyState} from '../../widgets/empty_state'; -// --- Interfaces --- +// --- Zod Schemas --- + +const StageSchema = z + .object({ + delta: durationSchema.nullable(), + dur: durationSchema, + nav: NavTargetSchema, + }) + .nullable(); + +const InputLifecycleArgsSchema = z.object({ + channel: z.string(), + totalLatency: durationSchema.nullable(), + reader: z + .object({ + dur: durationSchema, + nav: NavTargetSchema, + }) + .nullable(), + dispatcher: StageSchema, + receiver: StageSchema, + consumer: StageSchema, + frame: StageSchema, + allTrackIds: z.array(z.number()), +}); -interface NavTarget { - id: number; - trackId: number; - trackUri: string; - ts: time; - dur: duration; - depth: number; -} +// --- Interfaces --- interface InputChainRow { uiRowId: string; @@ -66,42 +81,24 @@ interface InputChainRow { navFrame?: NavTarget; allTrackIds: number[]; - input_id_val: string; + allTrackUris: string[]; } -function getTrackUriForTrackId(trace: Trace, trackId: number): string { - const track = trace.tracks.findTrack((t) => - t.tags?.trackIds?.includes(trackId), - ); - return track?.uri || `/slice_${trackId}`; -} - -const RELATION_SCHEMA = { - id: NUM, - name: STR, - ts: LONG, - dur: LONG, - track_id: NUM, - depth: NUM, -}; - -export class AndroidInputTab implements Tab { +export class AndroidInputLifecycleTab implements Tab { private rows: InputChainRow[] = []; private visibleRowIds = new Set(); - private pinnedRowIds = new Set(); private currentSelectionId?: number; private isLoading = false; constructor( private trace: Trace, - private overlay: LifecycleOverlay, + private source: AndroidInputEventSource, + private pinningManager: TrackPinningManager, ) {} onHide() { - this.overlay.update([]); this.rows = []; this.visibleRowIds.clear(); - this.pinnedRowIds.clear(); this.currentSelectionId = undefined; this.isLoading = false; } @@ -121,242 +118,71 @@ export class AndroidInputTab implements Tab { private async loadData(clickedEventId: number) { this.isLoading = true; - - const query = ` - SELECT * FROM _android_input_lifecycle_by_slice_id(${clickedEventId}) - `; + this.rows = []; + this.visibleRowIds.clear(); try { - const result = await this.trace.engine.query(query); - - const it = result.iter({ - input_id: STR, - channel: STR, - total_latency: LONG_NULL, - - ts_reader: LONG_NULL, - ts_dispatch: LONG_NULL, - ts_receive: LONG_NULL, - ts_consume: LONG_NULL, - ts_frame: LONG_NULL, - - id_reader: NUM_NULL, - track_reader: NUM_NULL, - dur_reader: LONG_NULL, - id_dispatch: NUM_NULL, - track_dispatch: NUM_NULL, - dur_dispatch: LONG_NULL, - id_receive: NUM_NULL, - track_receive: NUM_NULL, - dur_receive: LONG_NULL, - id_consume: NUM_NULL, - track_consume: NUM_NULL, - dur_consume: LONG_NULL, - id_frame: NUM_NULL, - track_frame: NUM_NULL, - dur_frame: LONG_NULL, - }); - - this.rows = []; - this.visibleRowIds.clear(); - this.pinnedRowIds.clear(); - + const data = await this.source.getRelatedEventData(clickedEventId); let index = 0; - let rowToHighlight: string | undefined; - - while (it.valid()) { - const uniqueId = `row-${index++}`; - - // 1. Create Nav Targets - const navReader = this.makeNav( - it.id_reader, - it.track_reader, - it.ts_reader, - it.dur_reader, - ); - const navDispatch = this.makeNav( - it.id_dispatch, - it.track_dispatch, - it.ts_dispatch, - it.dur_dispatch, - ); - const navReceive = this.makeNav( - it.id_receive, - it.track_receive, - it.ts_receive, - it.dur_receive, - ); - const navConsume = this.makeNav( - it.id_consume, - it.track_consume, - it.ts_consume, - it.dur_consume, - ); - const navFrame = this.makeNav( - it.id_frame, - it.track_frame, - it.ts_frame, - it.dur_frame, - ); - - // 2. Calculate Deltas - const durReader = - it.dur_reader !== null ? Duration.fromRaw(it.dur_reader) : null; - const deltaDispatch = - it.ts_dispatch !== null && it.ts_reader !== null - ? Duration.fromRaw(it.ts_dispatch - it.ts_reader) - : null; - const deltaReceive = - it.ts_receive !== null && it.ts_dispatch !== null - ? Duration.fromRaw(it.ts_receive - it.ts_dispatch) - : null; - const deltaConsume = - it.ts_consume !== null && it.ts_receive !== null - ? Duration.fromRaw(it.ts_consume - it.ts_receive) - : null; - const deltaFrame = - it.ts_frame !== null && it.ts_consume !== null - ? Duration.fromRaw(it.ts_frame - it.ts_consume) - : null; - - const tracks = new Set(); - [navReader, navDispatch, navConsume, navReceive, navFrame].forEach( - (n) => { - if (n) tracks.add(n.trackId); - }, - ); - - // 3. Check if this row matches the clicked event - const matchesClickedEvent = [ - it.id_reader, - it.id_dispatch, - it.id_receive, - it.id_consume, - it.id_frame, - ].includes(clickedEventId); - - if (matchesClickedEvent && rowToHighlight === undefined) { - rowToHighlight = uniqueId; + for (const event of data.events) { + if (event.type === 'InputLifecycle') { + const parsedArgs = InputLifecycleArgsSchema.safeParse( + event.customArgs, + ); + if (parsedArgs.success) { + const args = parsedArgs.data; + const uniqueId = `row-${index++}`; + const allTrackIds = args.allTrackIds; + const allTrackUris = allTrackIds.map((id: number) => + getTrackUriForTrackId(this.trace, id), + ); + this.rows.push({ + uiRowId: uniqueId, + channel: args.channel, + totalLatency: args.totalLatency, + durReader: args.reader?.dur ?? null, + deltaDispatch: args.dispatcher?.delta ?? null, + deltaReceive: args.receiver?.delta ?? null, + deltaConsume: args.consumer?.delta ?? null, + deltaFrame: args.frame?.delta ?? null, + navReader: args.reader?.nav, + navDispatch: args.dispatcher?.nav, + navReceive: args.receiver?.nav, + navConsume: args.consumer?.nav, + navFrame: args.frame?.nav, + allTrackIds, + allTrackUris, + }); + this.visibleRowIds.add(uniqueId); + } else { + console.error( + 'Invalid customArgs for InputLifecycle event', + parsedArgs.error, + ); + } } - - this.rows.push({ - uiRowId: uniqueId, - channel: it.channel, - totalLatency: - it.total_latency !== null - ? Duration.fromRaw(it.total_latency) - : null, - durReader, - deltaDispatch, - deltaReceive, - deltaConsume, - deltaFrame, - navReader, - navDispatch, - navConsume, - navReceive, - navFrame, - allTrackIds: Array.from(tracks), - input_id_val: it.input_id, - }); - - it.next(); - } - - if (rowToHighlight) { - this.visibleRowIds.add(rowToHighlight); } - - this.updateWorkspacePinning(); - - if (this.rows.length > 0) { - await this.enrichDepths(); - } - this.updateOverlay(); + this.pinningManager.applyPinning(this.trace); } finally { this.isLoading = false; } } - private makeNav( - id: number | null, - trackId: number | null, - ts: bigint | null, - dur: bigint | null, - ): NavTarget | undefined { - if (id === null || trackId === null || ts === null) return undefined; - return { - id, - trackId, - trackUri: getTrackUriForTrackId(this.trace, trackId), - ts: Time.fromRaw(ts), - dur: Duration.fromRaw(dur ?? 0n), - depth: 0, - }; + private getRowTrackUris(row: InputChainRow): string[] { + return row.allTrackUris; } - private async enrichDepths(): Promise { - const trackIds = new Set(); - const eventIds = new Set(); - const nodeMap = new Map(); - - for (const row of this.rows) { - [ - row.navReader, - row.navDispatch, - row.navConsume, - row.navReceive, - row.navFrame, - ].forEach((nav) => { - if (!nav) return; - trackIds.add(nav.trackId); - eventIds.add(nav.id); - if (!nodeMap.has(nav.id)) nodeMap.set(nav.id, []); - nodeMap.get(nav.id)!.push(nav); - }); - } - - const trackDatasets: Dataset[] = []; - for (const trackId of trackIds) { - const trackUri = getTrackUriForTrackId(this.trace, trackId); - const track = this.trace.tracks.getTrack(trackUri); - if (track?.renderer?.getDataset) { - const ds = track.renderer.getDataset(); - if (ds) trackDatasets.push(ds); - } - } - - if (trackDatasets.length === 0) return; - - const unionDataset = UnionDatasetWithLineage.create(trackDatasets); - const idsArray = Array.from(eventIds); - const querySchema = { - ...RELATION_SCHEMA, - __groupid: NUM, - __partition: UNKNOWN, - }; - - const sql = `SELECT * FROM (${unionDataset.query(querySchema)}) WHERE id IN (${idsArray.join(',')})`; - - try { - const result = await this.trace.engine.query(sql); - const it = result.iter(querySchema); - while (it.valid()) { - const nodes = nodeMap.get(it.id); - if (nodes) { - nodes.forEach((n) => (n.depth = Number(it.depth))); - } - it.next(); - } - } catch (e) { - console.error(`Error fetching depths:`, e); - } + private isRowPinned(row: InputChainRow): boolean { + const trackUris = this.getRowTrackUris(row); + return ( + trackUris.length > 0 && + trackUris.every((uri) => this.pinningManager.isTrackPinned(uri)) + ); } private toggleVisibility(rowId: string) { if (this.visibleRowIds.has(rowId)) this.visibleRowIds.delete(rowId); else this.visibleRowIds.add(rowId); - this.updateOverlay(); } private toggleAllVisibility() { @@ -365,74 +191,18 @@ export class AndroidInputTab implements Tab { ); if (allVisible) this.visibleRowIds.clear(); else this.rows.forEach((r) => this.visibleRowIds.add(r.uiRowId)); - this.updateOverlay(); } private togglePinning(row: InputChainRow) { - if (this.pinnedRowIds.has(row.uiRowId)) { - this.pinnedRowIds.delete(row.uiRowId); - } else this.pinnedRowIds.add(row.uiRowId); - this.updateWorkspacePinning(); - } - - private updateWorkspacePinning() { - const tracksToPin = new Set(); - this.rows.forEach((row) => { - if (this.pinnedRowIds.has(row.uiRowId)) { - row.allTrackIds.forEach((tid) => tracksToPin.add(tid)); - } - }); - - const allManagedTracks = new Set(); - this.rows.forEach((row) => - row.allTrackIds.forEach((tid) => allManagedTracks.add(tid)), - ); - - this.trace.currentWorkspace.flatTracks.forEach((trackNode) => { - if (!trackNode.uri) return; - const descriptor = this.trace.tracks.getTrack(trackNode.uri); - const trackSqlIds = descriptor?.tags?.trackIds; - if (!trackSqlIds || trackSqlIds.length === 0) return; - - const isManaged = trackSqlIds.some((id) => allManagedTracks.has(id)); - if (!isManaged) return; + const trackUris = this.getRowTrackUris(row); + const currentlyPinned = this.isRowPinned(row); - const shouldBePinned = trackSqlIds.some((id) => tracksToPin.has(id)); - if (shouldBePinned && !trackNode.isPinned) trackNode.pin(); - else if (!shouldBePinned && trackNode.isPinned) trackNode.unpin(); - }); - } - - private updateOverlay() { - const arrows: ArrowConnection[] = []; - const visibleRows = this.rows.filter((r) => - this.visibleRowIds.has(r.uiRowId), - ); - - for (const row of visibleRows) { - const steps = [ - row.navReader, - row.navDispatch, - row.navReceive, - row.navConsume, - row.navFrame, - ]; - const presentSteps = steps.filter((s): s is NavTarget => s !== undefined); - - for (let i = 0; i < presentSteps.length - 1; i++) { - const start = presentSteps[i]; - const end = presentSteps[i + 1]; - arrows.push({ - start: { - trackUri: start.trackUri, - ts: Time.add(start.ts, start.dur), - depth: start.depth, - }, - end: {trackUri: end.trackUri, ts: end.ts, depth: end.depth}, - }); - } + if (currentlyPinned) { + this.pinningManager.unpinTracks(trackUris); + } else { + this.pinningManager.pinTracks(trackUris); } - this.overlay.update(arrows); + this.pinningManager.applyPinning(this.trace); } private goTo(nav?: NavTarget) { @@ -534,7 +304,7 @@ export class AndroidInputTab implements Tab { GridCell, {}, m(Checkbox, { - checked: this.pinnedRowIds.has(row.uiRowId), + checked: this.isRowPinned(row), onchange: () => this.togglePinning(row), }), ), @@ -571,7 +341,7 @@ export class AndroidInputTab implements Tab { dur !== null ? m(DurationWidget, {dur, trace: this.trace}) : m('span', '-'), - nav && + nav !== undefined && m(Anchor, { icon: Icons.GoTo, onclick: () => this.goTo(nav), diff --git a/ui/src/plugins/dev.perfetto.RelatedEvents/OWNERS b/ui/src/plugins/dev.perfetto.RelatedEvents/OWNERS new file mode 100644 index 00000000000..9ffa38e0fba --- /dev/null +++ b/ui/src/plugins/dev.perfetto.RelatedEvents/OWNERS @@ -0,0 +1 @@ +ivankc@google.com \ No newline at end of file diff --git a/ui/src/plugins/com.android.AndroidInputLifecycle/arrow_visualiser.ts b/ui/src/plugins/dev.perfetto.RelatedEvents/arrow_visualiser.ts similarity index 61% rename from ui/src/plugins/com.android.AndroidInputLifecycle/arrow_visualiser.ts rename to ui/src/plugins/dev.perfetto.RelatedEvents/arrow_visualiser.ts index 749944947ac..028e80aa5e1 100644 --- a/ui/src/plugins/com.android.AndroidInputLifecycle/arrow_visualiser.ts +++ b/ui/src/plugins/dev.perfetto.RelatedEvents/arrow_visualiser.ts @@ -17,6 +17,7 @@ import {time, Time} from '../../base/time'; import {TimeScale} from '../../base/time_scale'; import {Trace} from '../../public/trace'; import {TrackBounds} from '../../public/track'; +import {RelatedEventData, Relation} from './interface'; /** * Represents a specific point in time and space (track + vertical depth) @@ -38,23 +39,27 @@ export interface ArrowPoint { export interface ArrowConnection { start: ArrowPoint; end: ArrowPoint; + color?: string; } +// Class responsible for the core logic of drawing bezier arrows between tracks. +// It calculates screen coordinates based on event times, track bounds, and depths. export class ArrowVisualiser { - private static readonly LINE_COLOR = `hsla(0, 100%, 60%, 1.00)`; + private static readonly DEFAULT_LINE_COLOR = `hsla(0, 100%, 60%, 1.00)`; private static readonly LINE_WIDTH = 2; constructor(private trace: Trace) {} + // Draws a set of arrows defined by ArrowConnection objects. draw( canvasCtx: CanvasRenderingContext2D, timescale: TimeScale, renderedTracks: ReadonlyArray, connections: ArrowConnection[], ): void { - canvasCtx.strokeStyle = ArrowVisualiser.LINE_COLOR; canvasCtx.lineWidth = ArrowVisualiser.LINE_WIDTH; + // Create a map for quick lookup of track bounds by URI. const trackBoundsMap = new Map(); for (const track of renderedTracks) { if (track.node.uri) { @@ -68,11 +73,16 @@ export class ArrowVisualiser { // We can only draw if both source and dest tracks are currently rendered (visible) if (leftTrackBounds && rightTrackBounds) { + canvasCtx.strokeStyle = + connection.color || ArrowVisualiser.DEFAULT_LINE_COLOR; + + // Convert timestamps to pixel coordinates. const arrowStartX = timescale.timeToPx( Time.fromRaw(connection.start.ts), ); const arrowEndX = timescale.timeToPx(Time.fromRaw(connection.end.ts)); + // Calculate the Y coordinates for the start and end points. const arrowStartY = this.getYCoordinate( leftTrackBounds, connection.start.trackUri, @@ -84,6 +94,7 @@ export class ArrowVisualiser { connection.end.depth, ); + // Draw the bezier arrow. drawBezierArrow( canvasCtx, {x: arrowStartX, y: arrowStartY}, @@ -123,3 +134,50 @@ export class ArrowVisualiser { return trackRect.top + (trackRect.bottom - trackRect.top) / 2; } } + +// Determines the color of the arrow based on the relation type or custom arguments. +function getColorForRelation(relation: Relation): string | undefined { + const customArgs = relation.customArgs as {color?: string} | undefined; + if (customArgs?.color) return customArgs.color; + return undefined; // Default color +} + +// Main function to draw arrows based on RelatedEventData. +// This function converts RelatedEvents and Relations into ArrowConnections +// and uses an ArrowVisualiser instance to draw them. +export function drawRelatedEvents( + canvasCtx: CanvasRenderingContext2D, + trace: Trace, + timescale: TimeScale, + renderedTracks: ReadonlyArray, + data: RelatedEventData, +) { + const visualiser = new ArrowVisualiser(trace); + const eventMap = new Map(data.events.map((e) => [e.id, e])); + const connections: ArrowConnection[] = []; + + // Create ArrowConnection objects from the relations. + for (const relation of data.relations) { + const sourceEvent = eventMap.get(relation.sourceId); + const targetEvent = eventMap.get(relation.targetId); + + if (sourceEvent && targetEvent) { + connections.push({ + start: { + trackUri: sourceEvent.trackUri, + ts: Time.add(sourceEvent.ts, sourceEvent.dur), // Arrow starts at the end of the source event + depth: sourceEvent.depth, + }, + end: { + trackUri: targetEvent.trackUri, + ts: targetEvent.ts, // Arrow ends at the start of the target event + depth: targetEvent.depth, + }, + color: getColorForRelation(relation), + }); + } + } + + // Draw the connections. + visualiser.draw(canvasCtx, timescale, renderedTracks, connections); +} diff --git a/ui/src/plugins/dev.perfetto.RelatedEvents/generic_overlay.ts b/ui/src/plugins/dev.perfetto.RelatedEvents/generic_overlay.ts new file mode 100644 index 00000000000..92c81731d0e --- /dev/null +++ b/ui/src/plugins/dev.perfetto.RelatedEvents/generic_overlay.ts @@ -0,0 +1,49 @@ +// Copyright (C) 2026 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 {Size2D} from '../../base/geom'; +import {TimeScale} from '../../base/time_scale'; +import {Trace} from '../../public/trace'; +import {Overlay, TrackBounds} from '../../public/track'; +import {drawRelatedEvents} from './arrow_visualiser'; +import {RelatedEventData} from './interface'; + +// A ready-to-use overlay for rendering related event arrows on the timeline. +// Update the overlay with RelatedEventData using the `update` method. +export class GenericRelatedEventsOverlay implements Overlay { + private data: RelatedEventData = {events: [], relations: []}; + + constructor(public trace: Trace) {} + + // Call this method to provide the event and relationship data to be visualized. + update(data: RelatedEventData) { + this.data = data; + } + + // Called by the rendering engine to draw the overlay. + render( + ctx: CanvasRenderingContext2D, + ts: TimeScale, + _size: Size2D, + tracks: ReadonlyArray, + ): void { + // Uses the drawRelatedEvents utility to render arrows based on + // the `overlayEvents` and `overlayRelations` from the provided data. + const overlayData: RelatedEventData = { + events: this.data.overlayEvents || [], + relations: this.data.overlayRelations || [], + }; + drawRelatedEvents(ctx, this.trace, ts, tracks, overlayData); + } +} diff --git a/ui/src/plugins/dev.perfetto.RelatedEvents/index.ts b/ui/src/plugins/dev.perfetto.RelatedEvents/index.ts new file mode 100644 index 00000000000..d96ec858b69 --- /dev/null +++ b/ui/src/plugins/dev.perfetto.RelatedEvents/index.ts @@ -0,0 +1,80 @@ +// Copyright (C) 2026 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 {PerfettoPlugin} from '../../public/plugin'; + +export * from './interface'; +export {drawRelatedEvents, ArrowVisualiser} from './arrow_visualiser'; +export * from './utils'; + +// This plugin provides a framework for visualizing relationships between events. +// Key exports for consumers: +// - Interfaces from './interface' (e.g., RelatedEventData, EventSource) +// to structure your event relationship data. +// - 'drawRelatedEvents' from './arrow_visualiser' to render arrows on the timeline. +// - 'GenericRelatedEventsOverlay' from './generic_overlay' for a quick overlay implementation. +// - Utility functions from './utils' for common tasks like track URI lookups. +// +// Example usage: +// +// // 1. Implement EventSource +// class SimpleEventSource implements EventSource { +// async getRelatedEventData(eventId: number): Promise { +// // Dummy data for illustration +// const events: RelatedEvent[] = [ +// {id: 1, ts: 100n, dur: 10n, trackUri: 'track_a', type: 'A'}, +// {id: 2, ts: 120n, dur: 10n, trackUri: 'track_b', type: 'B'}, +// ]; +// const relations: Relation[] = [{sourceId: 1, targetId: 2, type: 'flow'}]; +// return { events, relations, overlayEvents: events, overlayRelations: relations }; +// } +// } +// +// // 2. In your plugin's onTraceLoad or a command callback: +// async function setupOverlay(trace: Trace) { +// const overlay = new GenericRelatedEventsOverlay(trace); +// trace.tracks.registerOverlay(overlay); +// const source = new SimpleEventSource(); +// +// // Load data for a specific event ID and update the overlay +// const data = await source.getRelatedEventData(someEventId); +// overlay.update(data); +// } +// +// // 3. Example of using the data in a custom Tab +// class MyRelatedEventsTab implements Tab { +// constructor(private trace: Trace, private source: SimpleEventSource) {} +// +// getTitle() { return 'My Related Events'; } +// +// async onDetailsPanelSelectionChange(selection: Selection) { +// if (selection.kind === 'track_event') { +// this.data = await this.source.getRelatedEventData(selection.eventId); +// } +// } +// +// render(): m.Children { +// if (!this.data) return m('div', 'Select an event'); +// // Use this.data.events and this.data.relations to render a table or list +// return m('div', `${this.data.events.length} related events found.`); +// } +// } +// +// // In onTraceLoad: +// // const tab = new MyRelatedEventsTab(trace, source); +// // trace.tabs.registerTab({ uri: 'my.related.tab', content: tab }); + +export default class RelatedEventsPlugin implements PerfettoPlugin { + static readonly id = 'dev.perfetto.RelatedEvents'; +} diff --git a/ui/src/plugins/dev.perfetto.RelatedEvents/interface.ts b/ui/src/plugins/dev.perfetto.RelatedEvents/interface.ts new file mode 100644 index 00000000000..8b9935924b4 --- /dev/null +++ b/ui/src/plugins/dev.perfetto.RelatedEvents/interface.ts @@ -0,0 +1,93 @@ +// Copyright (C) 2026 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 m from 'mithril'; +import {duration, time} from '../../base/time'; +import {Trace} from '../../public/trace'; +import z from 'zod'; + +export const durationSchema = z.custom( + (val) => typeof val === 'bigint', +); +export const timeSchema = z.custom