Add lock contention plugin
diff --git a/ui/src/plugins/com.android.AndroidLockContention/OWNERS b/ui/src/plugins/com.android.AndroidLockContention/OWNERS
new file mode 100644
index 0000000..d523cb6
--- /dev/null
+++ b/ui/src/plugins/com.android.AndroidLockContention/OWNERS
@@ -0,0 +1 @@
+file://depot/google3/third_party/perfetto/OWNERS
diff --git a/ui/src/plugins/com.android.AndroidLockContention/android_lock_contention_event_source.ts b/ui/src/plugins/com.android.AndroidLockContention/android_lock_contention_event_source.ts
new file mode 100644
index 0000000..b895818
--- /dev/null
+++ b/ui/src/plugins/com.android.AndroidLockContention/android_lock_contention_event_source.ts
@@ -0,0 +1,162 @@
+// 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,
+  RelatedEvent,
+  RelatedEventData,
+  Relation,
+  getTrackUriForTrackId,
+} from '../dev.perfetto.RelatedEvents';
+import {time, duration} from '../../base/time';
+import {STR, NUM_NULL, LONG_NULL} from '../../trace_processor/query_result';
+import {enrichDepths} from '../dev.perfetto.RelatedEvents/utils';
+
+export type OnDataLoadedCallback = (data: RelatedEventData) => void;
+
+export class AndroidLockContentionEventSource implements EventSource {
+  private onDataLoadedCallback?: OnDataLoadedCallback;
+
+  constructor(private trace: Trace) {}
+
+  setOnDataLoadedCallback(callback: OnDataLoadedCallback) {
+    this.onDataLoadedCallback = callback;
+  }
+
+  async getRelatedEventData(eventId: number): Promise<RelatedEventData> {
+    const query = `
+      SELECT
+        amc.id AS contention_id,
+        amc.ts AS contention_ts,
+        amc.dur AS contention_dur,
+        amc.track_id AS blocked_track_id,
+        amc.blocked_utid,
+        amc.blocking_utid,
+        amc.short_blocked_method,
+        amc.short_blocking_method,
+        amc.blocking_thread_name,
+        s.id AS blocking_slice_id,
+        s.track_id AS blocking_track_id,
+        s.ts AS blocking_ts,
+        s.dur AS blocking_dur
+      FROM android_monitor_contention amc
+      LEFT JOIN thread_or_process_slice s ON s.utid = amc.blocking_utid
+        AND amc.ts >= s.ts
+        AND amc.ts < s.ts + s.dur
+      WHERE amc.id = ${eventId}
+      ORDER BY s.depth DESC, s.id DESC
+      LIMIT 1
+    `;
+
+    const result = await this.trace.engine.query(query);
+    const it = result.iter({
+      contention_id: NUM_NULL,
+      contention_ts: LONG_NULL,
+      contention_dur: LONG_NULL,
+      blocked_track_id: NUM_NULL,
+      blocked_utid: NUM_NULL,
+      blocking_utid: NUM_NULL,
+      short_blocked_method: STR,
+      short_blocking_method: STR,
+      blocking_thread_name: STR,
+      blocking_slice_id: NUM_NULL,
+      blocking_track_id: NUM_NULL,
+      blocking_ts: LONG_NULL,
+      blocking_dur: LONG_NULL,
+    });
+
+    const events: RelatedEvent[] = [];
+    const overlayEvents: RelatedEvent[] = [];
+    const overlayRelations: Relation[] = [];
+
+    if (!it.valid()) {
+      const data = {events: [], relations: [], overlayEvents, overlayRelations};
+      this.onDataLoadedCallback?.(data);
+      return data;
+    }
+
+    const blockedEventId = it.contention_id!;
+    const blockedTs = BigInt(it.contention_ts!) as time;
+    const blockedDur = BigInt(it.contention_dur!) as duration;
+    const blockedTrackId = it.blocked_track_id!;
+    const blockingThreadName = it.blocking_thread_name;
+
+    const blockedTrackUri = getTrackUriForTrackId(this.trace, blockedTrackId);
+    const blockingTrackId = it.blocking_track_id;
+    const blockingTrackUri =
+      typeof blockingTrackId === 'number'
+        ? getTrackUriForTrackId(this.trace, blockingTrackId)
+        : undefined;
+
+    const tabEvent: RelatedEvent = {
+      id: blockedEventId,
+      ts: blockedTs,
+      dur: blockedDur,
+      trackUri: blockedTrackUri,
+      type: 'Lock Contention',
+      customArgs: {
+        short_blocked_method: it.short_blocked_method,
+        short_blocking_method: it.short_blocking_method,
+        blocking_thread_name: blockingThreadName,
+        blockingTrackUri: blockingTrackUri,
+        blockingSliceId: it.blocking_slice_id,
+      },
+    };
+    events.push(tabEvent);
+
+    // Event for the selected contention slice itself
+    const blockedOverlayEvent: RelatedEvent = {
+      id: blockedEventId,
+      ts: blockedTs,
+      dur: blockedDur,
+      trackUri: blockedTrackUri,
+      type: 'Lock Contention',
+      customArgs: {
+        Blocked: it.short_blocked_method,
+        Blocking: it.short_blocking_method,
+      },
+    };
+    overlayEvents.push(blockedOverlayEvent);
+
+    const blockingSliceId = it.blocking_slice_id;
+    if (blockingSliceId !== null && blockingTrackUri) {
+      const blockingEvent: RelatedEvent = {
+        id: blockingSliceId,
+        ts: BigInt(it.blocking_ts!) as time,
+        dur: BigInt(it.blocking_dur!) as duration,
+        trackUri: blockingTrackUri,
+        type: 'Blocking Slice',
+        customArgs: {
+          name: it.short_blocking_method,
+        },
+      };
+      overlayEvents.push(blockingEvent);
+
+      const relation: Relation = {
+        sourceId: blockedEventId,
+        targetId: blockingSliceId,
+        type: 'blocked_on',
+        customArgs: {color: 'red'},
+      };
+      overlayRelations.push(relation);
+    }
+
+    await enrichDepths(this.trace, overlayEvents);
+
+    const data = {events, relations: [], overlayEvents, overlayRelations};
+    this.onDataLoadedCallback?.(data);
+    return data;
+  }
+}
diff --git a/ui/src/plugins/com.android.AndroidLockContention/index.ts b/ui/src/plugins/com.android.AndroidLockContention/index.ts
new file mode 100644
index 0000000..92fcc8e
--- /dev/null
+++ b/ui/src/plugins/com.android.AndroidLockContention/index.ts
@@ -0,0 +1,60 @@
+// 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';
+import {Trace} from '../../public/trace';
+import RelatedEventsPlugin from '../dev.perfetto.RelatedEvents';
+import {GenericRelatedEventsOverlay} from '../dev.perfetto.RelatedEvents/generic_overlay';
+import {AndroidLockContentionEventSource} from './android_lock_contention_event_source';
+import {AndroidLockContentionTab} from './tab';
+
+export default class AndroidLockContentionPlugin implements PerfettoPlugin {
+  static readonly id = 'com.android.AndroidLockContention';
+  static readonly dependencies = [RelatedEventsPlugin];
+
+  async onTraceLoad(trace: Trace): Promise<void> {
+    trace.engine.query('INCLUDE PERFETTO MODULE android.monitor_contention');
+
+    const overlay = new GenericRelatedEventsOverlay(trace);
+    trace.tracks.registerOverlay(overlay);
+
+    const source = new AndroidLockContentionEventSource(trace);
+
+    const tab = new AndroidLockContentionTab({trace, source});
+    source.setOnDataLoadedCallback((data) => {
+      overlay.update(data);
+    });
+
+    trace.tabs.registerTab({
+      uri: 'com.android.AndroidLockContentionTab',
+      isEphemeral: false,
+      content: tab,
+      onHide() {
+        overlay.update({
+          events: [],
+          relations: [],
+        });
+      },
+    });
+
+    trace.commands.registerCommand({
+      id: 'openAndroidLockContentionTab',
+      name: 'Show Android Lock Contention',
+      callback: () => {
+        trace.tabs.showTab('com.android.AndroidLockContentionTab');
+        tab.syncSelection();
+      },
+    });
+  }
+}
diff --git a/ui/src/plugins/com.android.AndroidLockContention/tab.ts b/ui/src/plugins/com.android.AndroidLockContention/tab.ts
new file mode 100644
index 0000000..f447759
--- /dev/null
+++ b/ui/src/plugins/com.android.AndroidLockContention/tab.ts
@@ -0,0 +1,170 @@
+// 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 {Tab} from '../../public/tab';
+import {Trace} from '../../public/trace';
+import {DetailsShell} from '../../widgets/details_shell';
+import {Section} from '../../widgets/section';
+import {AndroidLockContentionEventSource} from './android_lock_contention_event_source';
+import {Anchor} from '../../widgets/anchor';
+import {Icons} from '../../base/semantic_icons';
+import {Spinner} from '../../widgets/spinner';
+import {time} from '../../base/time';
+import {RelatedEvent} from '../dev.perfetto.RelatedEvents/interface';
+
+interface LockContentionArgs {
+  short_blocked_method: string;
+  short_blocking_method: string;
+  blocking_thread_name: string;
+  blockingTrackUri: string;
+  blockingSliceId?: number;
+  allTrackUris?: string[];
+}
+
+function isLockContentionArgs(args: unknown): args is LockContentionArgs {
+  if (typeof args !== 'object' || args === null) return false;
+  const obj = args as Record<string, unknown>;
+  return (
+    typeof obj.short_blocked_method === 'string' &&
+    typeof obj.short_blocking_method === 'string'
+  );
+}
+
+interface LockContentionTabConfig {
+  trace: Trace;
+  source: AndroidLockContentionEventSource;
+}
+
+export class AndroidLockContentionTab implements Tab {
+  private event: RelatedEvent | null = null;
+  private isLoading = false;
+
+  constructor(private config: LockContentionTabConfig) {}
+
+  syncSelection() {
+    const selection = this.config.trace.selection.selection;
+    if (selection.kind === 'track_event') {
+      this.loadData(selection.eventId);
+    } else {
+      this.event = null;
+    }
+  }
+
+  async loadData(eventId: number) {
+    if (this.isLoading) return;
+    this.isLoading = true;
+    this.event = null;
+    try {
+      const data = await this.config.source.getRelatedEventData(eventId);
+      this.event = data.events.length > 0 ? data.events[0] : null;
+    } finally {
+      this.isLoading = false;
+    }
+  }
+
+  getTitle() {
+    return 'Lock Contention';
+  }
+
+  private goTo(trackUri: string, eventId: number) {
+    this.config.trace.selection.selectTrackEvent(trackUri, eventId, {
+      scrollToSelection: true,
+      switchToCurrentSelectionTab: false,
+    });
+  }
+
+  private scrollToTime(trackUri: string, ts: time) {
+    this.config.trace.scrollTo({
+      time: {
+        start: ts,
+        behavior: 'pan',
+      },
+      track: {
+        uri: trackUri,
+        expandGroup: true,
+      },
+    });
+  }
+
+  render(): m.Children {
+    if (this.isLoading) {
+      return m(DetailsShell, {title: this.getTitle()}, m(Spinner, {}));
+    }
+
+    if (!this.event || !this.event.customArgs) {
+      return m(
+        DetailsShell,
+        {title: this.getTitle()},
+        m('.note', 'Select a lock contention event.'),
+      );
+    }
+
+    const args = this.event.customArgs;
+    if (!isLockContentionArgs(args)) {
+      console.error('Invalid customArgs for LockContention event', args);
+      return m(
+        DetailsShell,
+        {title: this.getTitle()},
+        m('.note', 'Error: Invalid event data.'),
+      );
+    }
+
+    return m(
+      DetailsShell,
+      {title: this.getTitle()},
+      m(
+        Section,
+        {
+          title: 'Contention Details',
+        },
+        m('div', `Blocked Method: ${args.short_blocked_method}`),
+        m('div', `Blocking Method: ${args.short_blocking_method}`),
+        m(
+          Anchor,
+          {
+            icon: Icons.GoTo,
+            onclick: () => this.goTo(this.event!.trackUri, this.event!.id),
+            title: 'Go to Blocked Event',
+          },
+          'Go to Blocked',
+        ),
+        args.blockingTrackUri &&
+          args.blockingSliceId !== undefined &&
+          m(
+            Anchor,
+            {
+              icon: Icons.GoTo,
+              onclick: () =>
+                this.goTo(args.blockingTrackUri!, args.blockingSliceId!),
+              title: 'Go to Blocking Slice',
+            },
+            'Go to Blocking Slice',
+          ),
+        args.blockingTrackUri &&
+          args.blockingSliceId === undefined &&
+          m(
+            Anchor,
+            {
+              icon: Icons.GoTo,
+              onclick: () =>
+                this.scrollToTime(args.blockingTrackUri!, this.event!.ts),
+              title: 'Scroll to Blocking Thread at Contention Time',
+            },
+            'Scroll to Blocking Thread',
+          ),
+      ),
+    );
+  }
+}