Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 218 additions & 2 deletions ui/src/plugins/com.android.InputEvents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,34 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
import {getTimeSpanOfSelectionOrVisibleWindow} from '../../public/utils';
import {uuidv4} from '../../base/uuid';
import {LONG, LONG_NULL, STR} from '../../trace_processor/query_result';
import {PerfettoPlugin} from '../../public/plugin';
import {Trace} from '../../public/trace';
import {SliceTrack} from '../../components/tracks/slice_track';
import {SourceDataset} from '../../trace_processor/dataset';
import {TrackNode} from '../../public/workspace';
import StandardGroupsPlugin from '../dev.perfetto.StandardGroups';
import {TimeSpan} from '../../base/time';

export default class implements PerfettoPlugin {
export default class AndroidInputEvents implements PerfettoPlugin {
static readonly id = 'com.android.InputEvents';
static readonly dependencies = [StandardGroupsPlugin];

async onTraceLoad(ctx: Trace): Promise<void> {
await ctx.engine.query(`
INCLUDE PERFETTO MODULE android.input;
INCLUDE PERFETTO MODULE intervals.overlap;
`);

ctx.commands.registerCommand({
id: 'com.android.InputEvents.visualizeOverlaps',
name: 'Input Events: Visualize event overlaps (over selection)',
callback: () => this.visualizeOverlaps(ctx),
});

const cnt = await ctx.engine.query(`
SELECT
COUNT(*) AS cnt
Expand All @@ -35,7 +50,6 @@ export default class implements PerfettoPlugin {
return;
}

await ctx.engine.query('INCLUDE PERFETTO MODULE android.input;');
const uri = 'com.android.InputEvents#InputEventsTrack';
const track = await SliceTrack.createMaterialized({
trace: ctx,
Expand Down Expand Up @@ -66,4 +80,206 @@ export default class implements PerfettoPlugin {
.getOrCreateStandardGroup(ctx.defaultWorkspace, 'USER_INTERACTION');
group.addChildInOrder(node);
}

async visualizeOverlaps(ctx: Trace): Promise<void> {
const window = await getTimeSpanOfSelectionOrVisibleWindow(ctx);
const rootNode = await this.createRootTrack(ctx, window);
ctx.defaultWorkspace.pinnedTracksNode.addChildLast(rootNode);
Copy link
Member

@stevegolton stevegolton Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a track directly to the pinned track node is unusual. You usually want to put the track in the normal workspace and the pin it, which means that when the user unpins it then it is still available in the normal track tree. Having said that there's nothing technically wrong with this I don't think, it's just unexpected. What's your intent here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a command and I don't think creating the tracks in the workspace is a good thing to do, considering we can call this command multiple times (over different selections)


const processes = await this.getProcesses(ctx, window);
const processTrackPromises: Promise<TrackNode>[] = [];

for (
const it = processes.iter({upid: LONG, process_name: STR});
it.valid();
it.next()
) {
const upid = Number(it.upid);
const processName = it.process_name;
processTrackPromises.push(
this.createProcessTrack(ctx, window, upid, processName),
);
}

const processTracks = await Promise.all(processTrackPromises);
for (const processTrack of processTracks) {
rootNode.addChildLast(processTrack);
}
}

private async createRootTrack(
ctx: Trace,
window: TimeSpan,
): Promise<TrackNode> {
const uri = `com.android.InputEvents.event_overlaps_parent.${uuidv4()}`;
const sqlSource = this.getOverlapSqlSource(window);
return this.createTrack(
ctx,
uri,
sqlSource,
'Input Events',
'Number of concurrent input events (from input dispatch to input ACK received).',
);
}

private async getProcesses(ctx: Trace, window: TimeSpan) {
return ctx.engine.query(`
WITH
process_peaks AS (
SELECT
group_name AS upid,
MAX(value) AS peak
FROM intervals_overlap_count_by_group!((${this.getEventsSubquery(window)}), dispatch_ts, total_latency_dur, upid)
GROUP BY upid
HAVING MAX(value) > 0
)
SELECT
pp.upid,
p.name AS process_name
FROM process_peaks pp
JOIN process p USING (upid)
ORDER BY pp.peak DESC
`);
}

private async createProcessTrack(
ctx: Trace,
window: TimeSpan,
upid: number,
processName: string,
): Promise<TrackNode> {
const uri = `com.android.InputEvents.event_overlaps.proc_${upid}.${uuidv4()}`;

const channels = await this.getChannels(ctx, window, upid);
const numberOfChannels = channels.numRows();
const plural = numberOfChannels === 1 ? '' : 's';
const name = `${processName} ${upid} (${numberOfChannels} channel${plural})`;

const sqlSource = this.getOverlapSqlSource(window, [`upid = ${upid}`]);
const processNode = await this.createTrack(
ctx,
uri,
sqlSource,
name,
`Number of concurrent input events received by process ${processName} ${upid} (from input dispatch to input ACK received).`,
);

const channelTrackPromises: Promise<TrackNode>[] = [];
for (
const it = channels.iter({event_channel: STR});
it.valid();
it.next()
) {
const channel = it.event_channel;
channelTrackPromises.push(
this.createChannelTrack(ctx, window, channel, upid),
);
}

const channelTracks = await Promise.all(channelTrackPromises);
for (const channelTrack of channelTracks) {
processNode.addChildLast(channelTrack);
}
return processNode;
}

private async getChannels(ctx: Trace, window: TimeSpan, upid: number) {
return ctx.engine.query(`
SELECT
group_name AS event_channel
FROM intervals_overlap_count_by_group!((${this.getEventsSubquery(window, [`upid = ${upid}`])}), dispatch_ts, total_latency_dur, event_channel)
GROUP BY event_channel
HAVING MAX(value) > 0
ORDER BY MAX(value) DESC
`);
}

private async createChannelTrack(
ctx: Trace,
window: TimeSpan,
channel: string,
upid: number,
): Promise<TrackNode> {
const uri = `com.android.InputEvents.event_overlaps.proc_${upid}.${channel}.${uuidv4()}`;
const sqlSource = this.getOverlapSqlSource(window, [
`upid = ${upid}`,
`event_channel = '${channel}'`,
]);
return this.createTrack(
ctx,
uri,
sqlSource,
`Channel: ${channel}`,
`Number of concurrent input events on the ${channel} channel (from input dispatch to input ACK received).`,
);
}

private async createTrack(
ctx: Trace,
uri: string,
sqlSource: string,
name: string,
description: string,
removable = true,
): Promise<TrackNode> {
const track = await createQueryCounterTrack({
trace: ctx,
uri,
materialize: false,
data: {
sqlSource,
},
columns: {
ts: 'ts',
value: 'value',
},
});
ctx.tracks.registerTrack({
uri,
renderer: track,
description,
});

return new TrackNode({
uri,
name,
removable,
});
}

private getOverlapSqlSource(
window: TimeSpan,
whereClauses: string[] = [],
): string {
const subquery = this.getEventsSubquery(window, whereClauses);
return `
SELECT *
FROM intervals_overlap_count!(
(${subquery}),
dispatch_ts,
total_latency_dur
)
`;
}

private getEventsSubquery(
window: TimeSpan,
whereClauses: string[] = [],
): string {
const whereClause =
whereClauses.length > 0 ? `AND ${whereClauses.join(' AND ')}` : '';
return `
SELECT
upid,
process_name,
event_channel,
MAX(dispatch_ts, ${window.start}) AS dispatch_ts,
MIN(dispatch_ts + total_latency_dur, ${window.end}) - MAX(dispatch_ts, ${window.start}) AS total_latency_dur
FROM android_input_events
WHERE
total_latency_dur IS NOT NULL AND
dispatch_ts < ${window.end} AND dispatch_ts + total_latency_dur > ${window.start}
${whereClause}
`;
}
}