blob: e58c5947ab5dd107c77c811c1e46c035f10f47bc [file]
// 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 {RelatedEventsOverlay} from '../../components/related_events/related_events_overlay';
import type {ArrowConnection} from '../../components/related_events/arrow_visualiser';
import {TrackPinningManager} from '../../components/related_events/utils';
import {showModal} from '../../widgets/modal';
import {Select} from '../../widgets/select';
import {Form, FormLabel} from '../../widgets/form';
import {STR} from '../../trace_processor/query_result';
import {Time} from '../../base/time';
import type {PerfettoPlugin} from '../../public/plugin';
import type {Trace} from '../../public/trace';
import {
AndroidInputEventSource,
type InputChainRow,
} from './android_input_event_source';
import {AndroidInputLifecycleTab} from './tab';
import type {AsyncMemoResult} from '../../base/async_memo';
import type {InputLifecycleExtension, NavTarget} from './extensions/interface';
import {PixelInputLifecycleExtension} from './extensions/pixel_extension';
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 Lifecycle'.";
private visibleRowIds = new Set<string>();
private lastAppliedEventId?: number;
private allEventConnections: ArrowConnection[] = [];
private activeExcludeSpeculative?: boolean;
private pinnedApp?: string;
private pinnedTrackUris: string[] = [];
async onTraceLoad(trace: Trace): Promise<void> {
await trace.engine.query('INCLUDE PERFETTO MODULE android.input;');
const extensions: InputLifecycleExtension[] = [
new PixelInputLifecycleExtension(),
];
const activeExtensions: InputLifecycleExtension[] = [];
for (const ext of extensions) {
if (await ext.isEligible(trace)) {
if (ext.requiredModules !== undefined) {
for (const mod of ext.requiredModules) {
await trace.engine.query(`INCLUDE PERFETTO MODULE ${mod};`);
}
}
activeExtensions.push(ext);
}
}
const source = new AndroidInputEventSource(trace, activeExtensions);
const pinningManager = new TrackPinningManager(trace);
trace.tracks.registerOverlay(
new RelatedEventsOverlay(trace, () => this.getConnections(trace, source)),
);
trace.tabs.registerTab({
uri: 'com.android.AndroidInputLifecycleTab',
isEphemeral: false,
content: {
getTitle: () => 'Android Input Lifecycle',
render: () => {
const {data: rows, isPending} = this.useRowState(trace, source);
if (rows) {
this.applyInitialSelection(trace, rows);
}
return m(AndroidInputLifecycleTab, {
trace,
rows: rows ?? [],
visibleRowIds: this.visibleRowIds,
loading: isPending,
pinningManager,
onToggleVisibility: (rowId) => this.toggleVisibility(rowId),
onToggleAllVisibility: () => this.toggleAllVisibility(rows ?? []),
activeExtensions,
});
},
},
onHide: () => {
this.visibleRowIds.clear();
this.lastAppliedEventId = undefined;
},
});
trace.commands.registerCommand({
id: 'com.android.openAndroidInputLifecycleTab',
name: 'Android: View Input Lifecycle',
callback: () => {
trace.tabs.showTab('com.android.AndroidInputLifecycleTab');
},
});
trace.commands.registerCommand({
id: 'com.android.AndroidInputLifecycle.drawAllArrowsWithSpeculative',
name: 'Android: Draw all input arrows (With speculative)',
callback: async () => {
await this.toggleDrawAllArrows(source, false);
},
});
trace.commands.registerCommand({
id: 'com.android.AndroidInputLifecycle.drawAllArrowsNoSpeculative',
name: 'Android: Draw all input arrows (No speculative)',
callback: async () => {
await this.toggleDrawAllArrows(source, true);
},
});
trace.commands.registerCommand({
id: 'com.android.AndroidInputLifecycle.pinTracks',
name: 'Android: Pin input pipeline related tracks',
callback: async () => {
const apps = await this.getUniqueInputApps(trace);
if (apps.length === 0) {
await showModal({
title: 'Pin input pipeline related tracks',
icon: 'warning',
content: m(
'p',
'No Android apps were found receiving input in this trace.',
),
buttons: [{text: 'OK', primary: true}],
});
return;
}
let selectedApp = apps[0];
await showModal({
title: 'Pin input pipeline tracks for app',
icon: 'help',
content: () =>
m(
Form,
m(
FormLabel,
{for: 'app-select'},
'Select the Android app you want to check:',
),
m(
Select,
{
id: 'app-select',
onchange: (e: Event) => {
const target = e.target as HTMLSelectElement;
selectedApp = target.value;
},
},
apps.map((app) =>
m('option', {value: app, selected: app === selectedApp}, app),
),
),
),
buttons: [
{
text: 'Pin Tracks',
primary: true,
action: async () => {
this.pinnedApp = selectedApp;
pinningManager.unpinTracks(this.pinnedTrackUris);
const rows = await source.getRowsForApp(selectedApp);
this.pinnedTrackUris = source.getTrackUrisSortedByStage(rows);
pinningManager.pinTracks(this.pinnedTrackUris);
if (this.activeExcludeSpeculative !== undefined) {
this.allEventConnections = this.extractConnectionsFromRows(
source,
rows,
);
}
},
},
{
text: 'Cancel',
},
],
});
},
});
}
// Fetch or reuse cached row data for the currently selected slice. Can call
// this function every render cycle without performance concerns, as the
// underlying data slot will ensure the query is only executed once per
// sliceId.
private useRowState(
trace: Trace,
source: AndroidInputEventSource,
): AsyncMemoResult<InputChainRow[]> {
const selection = trace.selection.selection;
if (selection.kind !== 'track_event') {
return {data: [], isPending: false};
}
return source.use(selection.eventId);
}
private applyInitialSelection(trace: Trace, rows: InputChainRow[]) {
const selection = trace.selection.selection;
if (selection.kind !== 'track_event') return;
const eventId = selection.eventId;
if (this.lastAppliedEventId === eventId) return;
this.lastAppliedEventId = eventId;
this.visibleRowIds.clear();
for (const row of rows) {
const ids: number[] = [];
for (const stageData of row.stagesData.values()) {
if (stageData.nav) {
ids.push(stageData.nav.id);
}
}
if (ids.includes(eventId)) {
this.visibleRowIds.add(row.uiRowId);
break;
}
}
}
private getConnections(
trace: Trace,
source: AndroidInputEventSource,
): ArrowConnection[] {
const connections: ArrowConnection[] = [...this.allEventConnections];
const {data: rows} = this.useRowState(trace, source);
if (!rows) return connections;
const visibleRows = rows.filter((r) => this.visibleRowIds.has(r.uiRowId));
connections.push(...this.extractConnectionsFromRows(source, visibleRows));
return connections;
}
private async getUniqueInputApps(trace: Trace): Promise<string[]> {
const query = `
SELECT DISTINCT process_name
FROM android_input_events
-- Filter for actual app window channels (e.g. "process/activity" containing a "/")
-- and exclude system channels (like "PointerEventDispatcher" or "[Gesture Monitor]").
WHERE process_name IS NOT NULL AND event_channel LIKE '%/%'
ORDER BY process_name;
`;
const result = await trace.engine.query(query);
const apps: string[] = [];
const it = result.iter({process_name: STR});
for (; it.valid(); it.next()) {
apps.push(it.process_name);
}
return apps;
}
private async toggleDrawAllArrows(
source: AndroidInputEventSource,
excludeSpeculative: boolean,
): Promise<void> {
if (this.activeExcludeSpeculative === excludeSpeculative) {
this.allEventConnections = [];
this.activeExcludeSpeculative = undefined;
return;
}
const rows = await source.getRowsForApp(this.pinnedApp, excludeSpeculative);
this.allEventConnections = this.extractConnectionsFromRows(source, rows);
this.activeExcludeSpeculative = excludeSpeculative;
}
private extractConnectionsFromRows(
source: AndroidInputEventSource,
rows: InputChainRow[],
): ArrowConnection[] {
const specs = source.getStageSpecs();
const connections: ArrowConnection[] = [];
for (const row of rows) {
const steps: NavTarget[] = [];
for (const spec of specs) {
const stageData = row.stagesData.get(spec.key);
if (stageData?.nav) {
steps.push(stageData.nav);
}
}
for (let i = 0; i < steps.length - 1; i++) {
const start = steps[i];
const end = steps[i + 1];
connections.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,
},
});
}
}
return connections;
}
private toggleVisibility(rowId: string) {
if (this.visibleRowIds.has(rowId)) {
this.visibleRowIds.delete(rowId);
} else {
this.visibleRowIds.add(rowId);
}
}
private toggleAllVisibility(rows: InputChainRow[]) {
const allVisible = rows.every((r) => this.visibleRowIds.has(r.uiRowId));
if (allVisible) {
this.visibleRowIds.clear();
} else {
rows.forEach((r) => this.visibleRowIds.add(r.uiRowId));
}
}
}