ui: Support pinning tracks and drawing all flows for a selected app (#6655)
Previously, the Android Input Lifecycle plugin only allowed tracing the
lifecycle of a single selected input event at a time, and did not have a
way to automatically pin all input pipeline tracks for a specific app.
This PR adds two new capabilities to the Android Input Lifecycle plugin
to improve trace analysis:
1) Drawing all input flows for a selected app:
Adds commands "Android: Draw all input arrows (With speculative)" and
"Android: Draw all input arrows (No speculative)". These commands
display all input flow arrows for the currently selected app, helping
the user visualize the global flow of inputs at once.
2) Pinning tracks for a selected app:
Adds a command "Android: Pin input pipeline related tracks" which
prompts the user to select an app, and automatically pins all related
tracks in the input pipeline.
Additionally, this PR refactors index.ts to clean up internal state
management and extract reusable helper methods:
- extractConnectionsFromRows(): Extracts arrow connections from row
datasets.
- useRowState() & applyInitialSelection(): Simplifies rendering logic in
the Mithril component.
- getUniqueInputApps() & getAllInputTrackUris(): Handles SQL retrieval
of apps and tracks from trace processor.
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
index e69056a..106e696 100644
--- a/ui/src/plugins/com.android.AndroidInputLifecycle/android_input_event_source.ts
+++ b/ui/src/plugins/com.android.AndroidInputLifecycle/android_input_event_source.ts
@@ -19,6 +19,7 @@
STR_NULL,
type Row,
type SqlValue,
+ type QueryResult as DbQueryResult,
} from '../../trace_processor/query_result';
import {type duration, Time, Duration} from '../../base/time';
import {
@@ -123,6 +124,24 @@
return AndroidInputEventSource.getStageSpecs(this.activeExtensions);
}
+ /**
+ * Given a list of input lifecycle rows, extracts all track URIs and orders
+ * them chronologically according to stage sequence numbers.
+ */
+ getTrackUrisSortedByStage(rows: InputChainRow[]): string[] {
+ const specs = this.getStageSpecs();
+ const trackUris: string[] = [];
+ for (const spec of specs) {
+ for (const row of rows) {
+ const stageData = row.stagesData.get(spec.key);
+ if (stageData?.nav?.trackUri) {
+ trackUris.push(stageData.nav.trackUri);
+ }
+ }
+ }
+ return Array.from(new Set(trackUris));
+ }
+
private async fetchRows(sliceId: number): Promise<InputChainRow[]> {
const baseQuery = `SELECT * FROM _android_input_lifecycle_by_slice_id(${sliceId})`;
@@ -146,7 +165,87 @@
`;
const result = await this.trace.engine.query(sql);
+ return this.mapResultToRows(result);
+ }
+ async getRowsForApp(
+ processName?: string,
+ excludeSpeculative = false,
+ ): Promise<InputChainRow[]> {
+ const processFilter = processName ? `'${processName}'` : 'NULL';
+ const speculativeFilter = excludeSpeculative ? '1' : '0';
+
+ const baseQuery = `
+ SELECT
+ e.input_event_id AS input_id,
+ e.event_channel AS channel,
+ e.end_to_end_latency_dur AS total_latency,
+ e.event_time AS event_time,
+ e.read_time AS ts_reader,
+ e.dispatch_ts AS ts_dispatch,
+ e.receive_ts AS ts_receive,
+ s_cons.ts AS ts_consume,
+ s_frame.ts AS ts_frame,
+ s_read.id AS id_reader,
+ s_read.track_id AS track_reader,
+ s_read.dur AS dur_reader,
+ s_disp.id AS id_dispatch,
+ e.dispatch_track_id AS track_dispatch,
+ s_disp.dur AS dur_dispatch,
+ s_recv.id AS id_receive,
+ e.receive_track_id AS track_receive,
+ s_recv.dur AS dur_receive,
+ s_cons.id AS id_consume,
+ s_cons.track_id AS track_consume,
+ s_cons.dur AS dur_consume,
+ s_frame.id AS id_frame,
+ s_frame.track_id AS track_frame,
+ s_frame.dur AS dur_frame,
+ e.is_speculative_frame
+ FROM android_input_events AS e
+ LEFT JOIN slice AS s_read
+ ON s_read.ts = e.read_time
+ AND s_read.track_id != 0
+ AND s_read.name GLOB 'UnwantedInteractionBlocker::notifyMotion*'
+ LEFT JOIN slice AS s_disp
+ ON s_disp.ts = e.dispatch_ts
+ AND s_disp.track_id = e.dispatch_track_id
+ LEFT JOIN slice AS s_recv
+ ON s_recv.ts = e.receive_ts
+ AND s_recv.track_id = e.receive_track_id
+ LEFT JOIN _input_consumers_lookup AS s_cons
+ ON s_cons.cookie = e.event_seq
+ LEFT JOIN _frame_choreographer_lookup AS s_frame
+ ON s_frame.frame_id = CAST(e.frame_id AS LONG)
+ WHERE e.event_channel LIKE '%/%'
+ AND (e.process_name = ${processFilter} OR ${processFilter} IS NULL)
+ AND (${speculativeFilter} = 0 OR e.is_speculative_frame = 0 OR e.is_speculative_frame IS NULL)
+ ORDER BY e.event_time ASC
+ `;
+
+ let selectCols = 'a_evt.*';
+ let joinSql = '';
+
+ for (const ext of this.activeExtensions) {
+ const spec = ext.getSqlJoinSpec();
+ const alias = spec.tableAlias ?? spec.tableName;
+ selectCols += `, ${alias}.*`;
+ joinSql += ` LEFT JOIN ${spec.tableName}${spec.tableAlias ? ` AS ${spec.tableAlias}` : ''} ON ${spec.joinOn}`;
+ }
+
+ const sql = `
+ SELECT ${selectCols}
+ FROM (${baseQuery}) a_evt
+ ${joinSql}
+ `;
+
+ const result = await this.trace.engine.query(sql);
+ const rows = this.mapResultToRows(result);
+ await this.enrichAllDepths(rows);
+ return rows;
+ }
+
+ private mapResultToRows(result: DbQueryResult): InputChainRow[] {
const rows: InputChainRow[] = [];
let index = 0;
diff --git a/ui/src/plugins/com.android.AndroidInputLifecycle/index.ts b/ui/src/plugins/com.android.AndroidInputLifecycle/index.ts
index 4232e15..e58c594 100644
--- a/ui/src/plugins/com.android.AndroidInputLifecycle/index.ts
+++ b/ui/src/plugins/com.android.AndroidInputLifecycle/index.ts
@@ -17,6 +17,10 @@
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';
@@ -38,6 +42,10 @@
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;');
@@ -102,6 +110,95 @@
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
@@ -149,15 +246,55 @@
trace: Trace,
source: AndroidInputEventSource,
): ArrowConnection[] {
+ const connections: ArrowConnection[] = [...this.allEventConnections];
const {data: rows} = this.useRowState(trace, source);
- if (!rows) return [];
+ 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) {
- if (!this.visibleRowIds.has(row.uiRowId)) continue;
-
const steps: NavTarget[] = [];
for (const spec of specs) {
const stageData = row.stagesData.get(spec.key);