ui: Add SmapsExplorer plugin

Add the com.android.SmapsExplorer plugin for live exploration of Android
process memory maps via WebUSB ADB. Provides a two-pane view: Process
View for per-process VMA drill-down with aggregated mappings, and VMA
View for cross-process analysis of shared mappings. Supports string
extraction, duplicate detection, hex dump inspection, VMA type/permission
filters, and bulk scanning. Uses DataGrid with fillHeight for virtual
scrolling of large datasets.

Change-Id: I8139e11cd5596d6afe27e1a40ce91d07d43d497f
diff --git a/ui/src/plugins/com.android.SmapsExplorer/capture_page.ts b/ui/src/plugins/com.android.SmapsExplorer/capture_page.ts
new file mode 100644
index 0000000..33f5aed
--- /dev/null
+++ b/ui/src/plugins/com.android.SmapsExplorer/capture_page.ts
@@ -0,0 +1,664 @@
+// 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 {App} from '../../public/app';
+import {Button} from '../../widgets/button';
+import {DetailsShell} from '../../widgets/details_shell';
+import {SegmentedButtons} from '../../widgets/segmented_buttons';
+import {
+  PINNED_PROCESSES,
+  aggregateSmaps,
+  type ProcessInfo,
+  type SmapsEntry,
+  type SmapsRollup,
+  type ProcessStringsResult,
+} from './smaps_connection';
+import {
+  TAB_PROCESSES,
+  TAB_VMAS,
+  TAB_STRINGS_DUPS,
+  TAB_INSPECT,
+  procTabKey,
+  mapTabKey,
+  vmapTabKey,
+  getStore,
+  newProcessTabState,
+  newMappingTabState,
+  newVmaMappingTabState,
+  type StringsState,
+  type MappingTabState,
+  type ProcessTabState,
+  type VmaMappingTabState,
+  type PageContext,
+} from './state';
+import {renderProcessView} from './process_view';
+import {renderVmaView} from './vma_view';
+
+// ── Page component ──────────────────────────────────────────────────────────
+
+interface SmapsExplorerPageAttrs {
+  app: App;
+}
+
+export class SmapsExplorerPage
+  implements m.ClassComponent<SmapsExplorerPageAttrs>
+{
+  private readonly s = getStore();
+  private connectStatus: string | null = null;
+  private error: string | null = null;
+  private loadingPid: number | null = null;
+  private enriching = false;
+  private enrichProgress: {done: number; total: number} | null = null;
+  private scanningAllSmaps = false;
+  private scanAllProgress: {done: number; total: number} | null = null;
+  private enrichGeneration = 0;
+  private smapsScanGeneration = 0;
+
+  // PageContext for extracted view modules — all properties are live
+  // getters that read from the class instance or the shared store.
+  private readonly ctx: PageContext = this.buildContext();
+
+  private buildContext(): PageContext {
+    const self = this;
+    return {
+      get processes() {
+        return self.s.processes;
+      },
+      get smapsData() {
+        return self.s.smapsData;
+      },
+      get rollups() {
+        return self.s.rollups;
+      },
+      get vmaFilters() {
+        return self.s.vmaFilters;
+      },
+      get isRoot() {
+        return self.s.conn.isRoot;
+      },
+      get loadingPid() {
+        return self.loadingPid;
+      },
+      get enrichGeneration() {
+        return self.enrichGeneration;
+      },
+      get smapsScanGeneration() {
+        return self.smapsScanGeneration;
+      },
+      get scanningAllSmaps() {
+        return self.scanningAllSmaps;
+      },
+      s: self.s,
+      inspectProcess: (pid) => self.inspectProcess(pid),
+      openMapping: (ps, name) => self.openMapping(ps, name),
+      openVmaProcesses: (name) => self.openVmaProcesses(name),
+      openVmaProcDetail: (vs, pid) => self.openVmaProcDetail(vs, pid),
+      scanSingleVma: (pid, ms, a, b, p) => self.scanSingleVma(pid, ms, a, b, p),
+      startStringsScan: (pid, n, ps) => self.startStringsScan(pid, n, ps),
+      captureHeap: (pid, n, app) => self.captureHeap(pid, n, app),
+      scanAllSmaps: () => self.scanAllSmaps(),
+      setVmaFilters: (f) => {
+        self.s.vmaFilters = f;
+      },
+      getProcessStringsState: (ps) => self.getProcessStringsState(ps),
+    };
+  }
+
+  // Convenience accessors
+  private get conn() {
+    return this.s.conn;
+  }
+  private get processes() {
+    return this.s.processes;
+  }
+  private set processes(v: ProcessInfo[] | null) {
+    this.s.processes = v;
+  }
+  private get rollups() {
+    return this.s.rollups;
+  }
+  private set rollups(v: Map<number, SmapsRollup>) {
+    this.s.rollups = v;
+  }
+  private get smapsData() {
+    return this.s.smapsData;
+  }
+
+  /** Get or create per-process tab state */
+  private getProcessState(pid: number): ProcessTabState {
+    let ps = this.s.openProcesses.get(pid);
+    if (ps === undefined) {
+      ps = newProcessTabState();
+      this.s.openProcesses.set(pid, ps);
+      if (!this.s.openProcessOrder.includes(pid)) {
+        this.s.openProcessOrder.push(pid);
+      }
+    }
+    return ps;
+  }
+
+  private processStringsStates = new WeakMap<ProcessTabState, StringsState>();
+  private getProcessStringsState(ps: ProcessTabState): StringsState {
+    let ss = this.processStringsStates.get(ps);
+    if (ss === undefined) {
+      ss = {
+        get stringsData() {
+          return ps.processStringsData;
+        },
+        set stringsData(v) {
+          ps.processStringsData = v;
+        },
+        get stringsFilterKey() {
+          return ps.processStringsFilterKey;
+        },
+        set stringsFilterKey(v) {
+          ps.processStringsFilterKey = v;
+        },
+        get stringsInitialFilters() {
+          return ps.processStringsInitialFilters;
+        },
+        set stringsInitialFilters(v) {
+          ps.processStringsInitialFilters = v;
+        },
+        get cachedDups() {
+          return ps.processStringsDups;
+        },
+        set cachedDups(v) {
+          ps.processStringsDups = v;
+        },
+        get cachedDupsStrings() {
+          return ps.processStringsDupsStrings;
+        },
+        set cachedDupsStrings(v) {
+          ps.processStringsDupsStrings = v;
+        },
+      };
+      this.processStringsStates.set(ps, ss);
+    }
+    return ss;
+  }
+
+  // ── View ────────────────────────────────────────────────────────────────
+
+  view(vnode: m.Vnode<SmapsExplorerPageAttrs>) {
+    const {app} = vnode.attrs;
+
+    return m(
+      DetailsShell,
+      {
+        title: 'Smaps Explorer',
+        description: this.renderHeaderDescription(),
+        buttons: this.renderHeaderButtons(),
+      },
+      m('.pf-smaps-explorer__panel', [
+        // Error banner
+        this.error !== null &&
+          m('.pf-smaps-explorer__error-banner', this.error),
+
+        // Connect screen
+        !this.conn.connected && this.processes === null && this.renderConnect(),
+
+        // Connected content
+        this.processes !== null && this.renderContent(app),
+      ]),
+    );
+  }
+
+  // ── Header ──────────────────────────────────────────────────────────────
+
+  private renderHeaderDescription(): m.Children {
+    if (!this.conn.connected) return undefined;
+    const parts: m.Children[] = [];
+    parts.push(`${this.processes?.length ?? 0} processes`);
+    if (!this.conn.isRoot) {
+      parts.push(m('span.pf-smaps-explorer__badge--warning', 'Not rooted'));
+    }
+    if (this.enriching && this.enrichProgress !== null) {
+      parts.push(
+        ` \u2014 Fetching rollups: ${this.enrichProgress.done}/${this.enrichProgress.total}`,
+      );
+    }
+    if (this.scanningAllSmaps && this.scanAllProgress !== null) {
+      parts.push(
+        ` \u2014 Scanning smaps: ${this.scanAllProgress.done}/${this.scanAllProgress.total}`,
+      );
+    }
+    if (this.smapsData.size > 0) {
+      parts.push(` \u2014 ${this.smapsData.size} scanned`);
+    }
+    return parts;
+  }
+
+  private renderHeaderButtons(): m.Children {
+    if (!this.conn.connected) return undefined;
+    return [
+      m(Button, {
+        label: 'Refresh',
+        icon: 'refresh',
+        compact: true,
+        onclick: () => this.refreshProcesses(),
+      }),
+      this.conn.isRoot &&
+        !this.enriching &&
+        !this.scanningAllSmaps &&
+        m(Button, {
+          label: 'Scan All Processes',
+          icon: 'speed',
+          compact: true,
+          onclick: () => this.enrichAll(),
+        }),
+      this.conn.isRoot &&
+        !this.scanningAllSmaps &&
+        m(Button, {
+          label: 'Scan All VMAs',
+          icon: 'memory',
+          compact: true,
+          onclick: () => this.scanAllSmaps(),
+        }),
+      m(Button, {
+        label: 'Disconnect',
+        icon: 'link_off',
+        compact: true,
+        onclick: () => {
+          this.conn.disconnect();
+          this.processes = null;
+          this.smapsData.clear();
+          this.rollups.clear();
+          this.s.openProcesses.clear();
+          this.s.openProcessOrder.length = 0;
+          this.s.activeProcessPid = null;
+          this.s.openVmaMappings.clear();
+          this.s.openVmaMappingOrder.length = 0;
+          this.s.activeVmaMapping = null;
+          this.s.topView = 0;
+          this.s.processTab = TAB_PROCESSES;
+          this.s.vmaTab = TAB_VMAS;
+          m.redraw();
+        },
+      }),
+    ];
+  }
+
+  // ── Connection ──────────────────────────────────────────────────────────
+
+  private renderConnect(): m.Children {
+    return m('.pf-smaps-explorer__connect', [
+      m(Button, {
+        label: this.connectStatus ?? 'Connect USB Device',
+        icon: 'usb',
+        disabled: this.connectStatus !== null,
+        onclick: () => this.handleConnect(),
+      }),
+      m(
+        'p.pf-smaps-explorer__connect-hint',
+        'Enable USB debugging. Stop adb first: ',
+        m('code', 'adb kill-server'),
+      ),
+    ]);
+  }
+
+  private async handleConnect(): Promise<void> {
+    try {
+      this.connectStatus = 'Connecting\u2026';
+      this.error = null;
+      m.redraw();
+      await this.conn.connect((msg) => {
+        this.connectStatus = msg;
+        m.redraw();
+      });
+      this.connectStatus = null;
+      await this.refreshProcesses();
+    } catch (e) {
+      this.connectStatus = null;
+      this.error = e instanceof Error ? e.message : 'Connection failed';
+      m.redraw();
+    }
+  }
+
+  private async refreshProcesses(): Promise<void> {
+    try {
+      this.smapsData.clear();
+      this.rollups.clear();
+      for (const pid of this.s.openProcessOrder) {
+        this.loadSmaps(pid);
+      }
+      this.processes = await this.conn.getProcessList();
+      this.processes.sort((a, b) => {
+        const aPin = PINNED_PROCESSES.has(a.name) ? 0 : 1;
+        const bPin = PINNED_PROCESSES.has(b.name) ? 0 : 1;
+        if (aPin !== bPin) return aPin - bPin;
+        return a.name.localeCompare(b.name);
+      });
+      m.redraw();
+      if (this.conn.isRoot && !this.enriching) {
+        this.enrichAll();
+      }
+    } catch (e) {
+      this.error = e instanceof Error ? e.message : 'Failed to get processes';
+      m.redraw();
+    }
+  }
+
+  private async enrichAll(): Promise<void> {
+    if (this.processes === null || this.enriching) return;
+    this.enriching = true;
+    m.redraw();
+    try {
+      this.rollups = await this.conn.enrichProcesses(
+        this.processes,
+        (done, total) => {
+          this.enrichProgress = {done, total};
+          m.redraw();
+        },
+      );
+    } catch {
+      // Ignore enrichment failures
+    } finally {
+      this.enriching = false;
+      this.enrichProgress = null;
+      this.enrichGeneration++;
+      m.redraw();
+    }
+    if (!this.scanningAllSmaps) {
+      this.scanAllSmaps();
+    }
+  }
+
+  private async scanAllSmaps(): Promise<void> {
+    if (this.processes === null || this.scanningAllSmaps) return;
+    this.scanningAllSmaps = true;
+    const total = this.processes.length;
+    let done = 0;
+    this.scanAllProgress = {done, total};
+    m.redraw();
+    try {
+      for (const p of this.processes) {
+        if (this.smapsData.has(p.pid)) {
+          done++;
+          this.scanAllProgress = {done, total};
+          m.redraw();
+          continue;
+        }
+        try {
+          const entries = await this.conn.getSmapsForPid(p.pid);
+          this.smapsData.set(p.pid, aggregateSmaps(entries));
+        } catch {
+          // Skip processes that fail (zombie, permission, etc.)
+        }
+        done++;
+        this.scanAllProgress = {done, total};
+        m.redraw();
+      }
+    } finally {
+      this.scanningAllSmaps = false;
+      this.scanAllProgress = null;
+      this.smapsScanGeneration++;
+      m.redraw();
+    }
+  }
+
+  // ── Main content ────────────────────────────────────────────────────────
+
+  private renderContent(app: App): m.Children {
+    return m('.pf-smaps-explorer__content', [
+      this.conn.isRoot &&
+        m(
+          '.pf-smaps-explorer__view-selector',
+          m(SegmentedButtons, {
+            options: [
+              {label: 'Process View', icon: 'apps'},
+              {label: 'VMA View', icon: 'memory'},
+            ],
+            selectedOption: this.s.topView,
+            onOptionSelected: (idx) => {
+              this.s.topView = idx as 0 | 1;
+            },
+          }),
+        ),
+      m(
+        '.pf-smaps-explorer__grid-container',
+        this.s.topView === 0
+          ? renderProcessView(this.ctx, app)
+          : renderVmaView(this.ctx),
+      ),
+    ]);
+  }
+
+  // ── Navigation actions ──────────────────────────────────────────────────
+
+  private inspectProcess(pid: number) {
+    const ps = this.getProcessState(pid);
+    ps.subTab = TAB_INSPECT;
+    this.s.activeProcessPid = pid;
+    this.s.processTab = procTabKey(pid);
+    this.loadSmaps(pid);
+  }
+
+  private openMapping(ps: ProcessTabState, name: string) {
+    if (!ps.openMappings.has(name)) {
+      ps.openMappings.set(name, newMappingTabState());
+      if (!ps.openMappingOrder.includes(name)) {
+        ps.openMappingOrder.push(name);
+      }
+    }
+    ps.activeMapping = name;
+    ps.subTab = mapTabKey(name);
+  }
+
+  private openVmaProcesses(name: string) {
+    if (!this.s.openVmaMappings.has(name)) {
+      this.s.openVmaMappings.set(name, newVmaMappingTabState());
+      if (!this.s.openVmaMappingOrder.includes(name)) {
+        this.s.openVmaMappingOrder.push(name);
+      }
+    }
+    this.s.activeVmaMapping = name;
+    this.s.vmaTab = vmapTabKey(name);
+  }
+
+  private openVmaProcDetail(vs: VmaMappingTabState, pid: number) {
+    if (!vs.openProcs.has(pid)) {
+      vs.openProcs.set(pid, newMappingTabState());
+      if (!vs.openProcOrder.includes(pid)) {
+        vs.openProcOrder.push(pid);
+      }
+    }
+    vs.activeProc = pid;
+    vs.subTab = procTabKey(pid);
+    this.loadSmaps(pid);
+  }
+
+  // ── Data loading ────────────────────────────────────────────────────────
+
+  private async loadSmaps(pid: number): Promise<void> {
+    if (this.loadingPid === pid || this.smapsData.has(pid)) return;
+    this.loadingPid = pid;
+    m.redraw();
+    try {
+      const entries = await this.conn.getSmapsForPid(pid);
+      this.smapsData.set(pid, aggregateSmaps(entries));
+    } catch (e) {
+      this.error = e instanceof Error ? e.message : 'Failed to load smaps';
+    } finally {
+      if (this.loadingPid === pid) this.loadingPid = null;
+      m.redraw();
+    }
+  }
+
+  private async scanSingleVma(
+    pid: number,
+    ms: MappingTabState,
+    addrStart: string,
+    addrEnd: string,
+    perms: string,
+  ): Promise<void> {
+    if (!this.conn.isRoot || perms[0] !== 'r') {
+      this.error = perms[0] !== 'r' ? 'VMA is not readable' : 'Root required';
+      m.redraw();
+      return;
+    }
+
+    const rawAgg = this.smapsData.get(pid);
+    if (rawAgg === undefined) return;
+    let targetEntry: SmapsEntry | undefined;
+    for (const g of rawAgg) {
+      for (const e of g.entries) {
+        if (e.addrStart === addrStart && e.addrEnd === addrEnd) {
+          targetEntry = e;
+          break;
+        }
+      }
+      if (targetEntry) break;
+    }
+    if (targetEntry === undefined) return;
+
+    const entryName = targetEntry.name || '[anonymous]';
+    const liveData: ProcessStringsResult = {
+      pid,
+      processName: `${addrStart}-${addrEnd} ${entryName}`,
+      regions: [
+        {
+          addrStart: targetEntry.addrStart,
+          addrEnd: targetEntry.addrEnd,
+          perms: targetEntry.perms,
+          name: targetEntry.name,
+          sizeKb: targetEntry.sizeKb,
+          stringCount: 0,
+        },
+      ],
+      strings: [],
+      scanning: true,
+      scannedVmas: 0,
+      totalVmas: 1,
+    };
+    ms.stringsData = liveData;
+    ms.subTab = TAB_STRINGS_DUPS;
+    m.redraw();
+
+    try {
+      await this.conn.grepVmaStrings(
+        pid,
+        [targetEntry],
+        (newStrings, regions, completed, total) => {
+          for (const s of newStrings) liveData.strings.push(s);
+          liveData.regions = regions;
+          liveData.scannedVmas = completed;
+          liveData.totalVmas = total;
+          ms.stringsData = {...liveData, strings: [...liveData.strings]};
+          m.redraw();
+        },
+      );
+      liveData.scanning = false;
+      ms.stringsData = {...liveData, strings: [...liveData.strings]};
+      m.redraw();
+    } catch (e) {
+      this.error = e instanceof Error ? e.message : 'String scan failed';
+      m.redraw();
+    }
+  }
+
+  private async captureHeap(
+    pid: number,
+    name: string,
+    app: App,
+  ): Promise<void> {
+    try {
+      this.error = null;
+      const data = await this.conn.captureHeapDump(pid, (status) => {
+        this.error = status;
+        m.redraw();
+      });
+      this.error = null;
+      m.redraw();
+      await app.openTraceFromBuffer({
+        buffer: data.buffer as ArrayBuffer,
+        title: `${name} (${pid})`,
+        fileName: `${name}_${pid}.hprof`,
+      });
+    } catch (e) {
+      this.error = e instanceof Error ? e.message : 'Heap dump failed';
+      m.redraw();
+    }
+  }
+
+  private async startStringsScan(
+    pid: number,
+    processName: string,
+    ps: ProcessTabState,
+  ): Promise<void> {
+    if (!this.conn.isRoot) return;
+    try {
+      let aggregated = this.smapsData.get(pid);
+      if (aggregated === undefined) {
+        this.error = 'Fetching smaps\u2026';
+        m.redraw();
+        const entries = await this.conn.getSmapsForPid(pid);
+        aggregated = aggregateSmaps(entries);
+        this.smapsData.set(pid, aggregated);
+        this.error = null;
+      }
+
+      const allEntries = aggregated.flatMap((a) => a.entries);
+      const readable = allEntries.filter((e) => e.perms[0] === 'r');
+
+      const liveData: ProcessStringsResult = {
+        pid,
+        processName,
+        regions: readable.map((e) => ({
+          addrStart: e.addrStart,
+          addrEnd: e.addrEnd,
+          perms: e.perms,
+          name: e.name,
+          sizeKb: e.sizeKb,
+          stringCount: 0,
+        })),
+        strings: [],
+        scanning: true,
+        scannedVmas: 0,
+        totalVmas: readable.length,
+      };
+      ps.processStringsData = liveData;
+      ps.activeMapping = null;
+      ps.subTab = TAB_STRINGS_DUPS;
+      m.redraw();
+
+      await this.conn.grepVmaStrings(
+        pid,
+        allEntries,
+        (newStrings, regions, completed, total) => {
+          for (const s of newStrings) liveData.strings.push(s);
+          liveData.regions = regions;
+          liveData.scannedVmas = completed;
+          liveData.totalVmas = total;
+          ps.processStringsData = {
+            ...liveData,
+            strings: [...liveData.strings],
+          };
+          m.redraw();
+        },
+      );
+
+      liveData.scanning = false;
+      ps.processStringsData = {
+        ...liveData,
+        strings: [...liveData.strings],
+      };
+      m.redraw();
+    } catch (e) {
+      this.error = e instanceof Error ? e.message : 'String scan failed';
+      m.redraw();
+    }
+  }
+}
diff --git a/ui/src/plugins/com.android.SmapsExplorer/data.ts b/ui/src/plugins/com.android.SmapsExplorer/data.ts
new file mode 100644
index 0000000..f673862
--- /dev/null
+++ b/ui/src/plugins/com.android.SmapsExplorer/data.ts
@@ -0,0 +1,303 @@
+// 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 {
+  type CellRenderResult,
+  type SchemaRegistry,
+} from '../../components/widgets/datagrid/datagrid_schema';
+import {type SqlValue} from '../../trace_processor/query_result';
+import {
+  type SmapsAggregated,
+  type SmapsEntry,
+  type VmaString,
+} from './smaps_connection';
+
+// ── Formatters ──────────────────────────────────────────────────────────────
+
+export function fmtSize(bytes: number): string {
+  if (bytes === 0) return '\u2014';
+  if (bytes < 1024) return `${bytes} B`;
+  const kb = bytes / 1024;
+  if (kb < 1024) return `${kb.toFixed(kb < 10 ? 1 : 0)} KiB`;
+  const mb = kb / 1024;
+  if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MiB`;
+  const gb = mb / 1024;
+  return `${gb.toFixed(1)} GiB`;
+}
+
+export function sizeRenderer(value: SqlValue): CellRenderResult {
+  const n = Number(value);
+  return {
+    content: n > 0 ? fmtSize(n * 1024) : '\u2014',
+    align: 'right',
+    nullish: n === 0,
+  };
+}
+
+export function hexAddrRenderer(value: SqlValue): CellRenderResult {
+  const n = Number(value);
+  return {
+    content: n.toString(16).padStart(n > 0xffffffff ? 12 : 8, '0'),
+    align: 'left',
+  };
+}
+
+// ── VMA filters ─────────────────────────────────────────────────────────────
+
+export type VmaType = 'all' | 'file' | 'anon';
+
+export function classifyEntry(e: SmapsEntry): 'file' | 'anon' {
+  if (e.dev !== '00:00' && e.inode !== 0) return 'file';
+  return 'anon';
+}
+
+export interface VmaFilters {
+  type: VmaType;
+  r: boolean | null;
+  w: boolean | null;
+  x: boolean | null;
+}
+
+export function matchesFilters(e: SmapsEntry, f: VmaFilters): boolean {
+  if (f.type !== 'all' && classifyEntry(e) !== f.type) return false;
+  if (f.r !== null && (e.perms[0] === 'r') !== f.r) return false;
+  if (f.w !== null && (e.perms[1] === 'w') !== f.w) return false;
+  if (f.x !== null && (e.perms[2] === 'x') !== f.x) return false;
+  return true;
+}
+
+export function filterAggregated(
+  aggregated: SmapsAggregated[],
+  filters: VmaFilters,
+): SmapsAggregated[] {
+  if (
+    filters.type === 'all' &&
+    filters.r === null &&
+    filters.w === null &&
+    filters.x === null
+  ) {
+    return aggregated;
+  }
+  return aggregated
+    .map((g) => {
+      const entries = g.entries.filter((e) => matchesFilters(e, filters));
+      if (entries.length === 0) return null;
+      if (entries.length === g.entries.length) return g;
+      const agg: SmapsAggregated = {
+        name: g.name,
+        count: entries.length,
+        sizeKb: 0,
+        rssKb: 0,
+        pssKb: 0,
+        sharedCleanKb: 0,
+        sharedDirtyKb: 0,
+        privateCleanKb: 0,
+        privateDirtyKb: 0,
+        swapKb: 0,
+        swapPssKb: 0,
+        entries,
+      };
+      for (const e of entries) {
+        agg.sizeKb += e.sizeKb;
+        agg.rssKb += e.rssKb;
+        agg.pssKb += e.pssKb;
+        agg.sharedCleanKb += e.sharedCleanKb;
+        agg.sharedDirtyKb += e.sharedDirtyKb;
+        agg.privateCleanKb += e.privateCleanKb;
+        agg.privateDirtyKb += e.privateDirtyKb;
+        agg.swapKb += e.swapKb;
+        agg.swapPssKb += e.swapPssKb;
+      }
+      return agg;
+    })
+    .filter((g): g is SmapsAggregated => g !== null);
+}
+
+// ── Duplicate string computation ────────────────────────────────────────────
+
+export interface DuplicateGroup {
+  value: string;
+  count: number;
+  totalBytes: number;
+  vmaCount: number;
+}
+
+export function computeDuplicates(strings: VmaString[]): DuplicateGroup[] {
+  const groups = new Map<
+    string,
+    {count: number; totalBytes: number; vmaIndices: Set<number>}
+  >();
+  for (const s of strings) {
+    const existing = groups.get(s.str);
+    if (existing !== undefined) {
+      existing.count++;
+      existing.totalBytes += s.str.length;
+      existing.vmaIndices.add(s.vmaIndex);
+    } else {
+      groups.set(s.str, {
+        count: 1,
+        totalBytes: s.str.length,
+        vmaIndices: new Set([s.vmaIndex]),
+      });
+    }
+  }
+  const result: DuplicateGroup[] = [];
+  for (const [value, g] of groups) {
+    if (g.count < 2) continue;
+    result.push({
+      value,
+      count: g.count,
+      totalBytes: g.totalBytes,
+      vmaCount: g.vmaIndices.size,
+    });
+  }
+  result.sort((a, b) => b.totalBytes - a.totalBytes);
+  return result;
+}
+
+// ── VMA-centric aggregation ─────────────────────────────────────────────────
+
+export interface VmaCrossProcess {
+  name: string;
+  perms: string;
+  processCount: number;
+  totalPssKb: number;
+  totalRssKb: number;
+  totalSizeKb: number;
+  totalPrivDirtyKb: number;
+  totalPrivCleanKb: number;
+  totalSwapKb: number;
+  pids: number[];
+}
+
+export function aggregateVmasCrossProcess(
+  smapsData: Map<number, SmapsAggregated[]>,
+  filters: VmaFilters,
+): VmaCrossProcess[] {
+  const byKey = new Map<
+    string,
+    {
+      perms: string;
+      pids: Set<number>;
+      pss: number;
+      rss: number;
+      size: number;
+      privDirty: number;
+      privClean: number;
+      swap: number;
+    }
+  >();
+  for (const [pid, aggregated] of smapsData) {
+    for (const g of aggregated) {
+      for (const e of g.entries) {
+        if (!matchesFilters(e, filters)) continue;
+        const key = `${e.name}|${e.perms}`;
+        const existing = byKey.get(key);
+        if (existing !== undefined) {
+          existing.pids.add(pid);
+          existing.pss += e.pssKb;
+          existing.rss += e.rssKb;
+          existing.size += e.sizeKb;
+          existing.privDirty += e.privateDirtyKb;
+          existing.privClean += e.privateCleanKb;
+          existing.swap += e.swapKb;
+        } else {
+          byKey.set(key, {
+            perms: e.perms,
+            pids: new Set([pid]),
+            pss: e.pssKb,
+            rss: e.rssKb,
+            size: e.sizeKb,
+            privDirty: e.privateDirtyKb,
+            privClean: e.privateCleanKb,
+            swap: e.swapKb,
+          });
+        }
+      }
+    }
+  }
+  const result: VmaCrossProcess[] = [];
+  for (const [key, data] of byKey) {
+    const name = key.split('|')[0];
+    result.push({
+      name: name || '[anonymous]',
+      perms: data.perms,
+      processCount: data.pids.size,
+      totalPssKb: data.pss,
+      totalRssKb: data.rss,
+      totalSizeKb: data.size,
+      totalPrivDirtyKb: data.privDirty,
+      totalPrivCleanKb: data.privClean,
+      totalSwapKb: data.swap,
+      pids: [...data.pids],
+    });
+  }
+  result.sort((a, b) => b.totalPssKb - a.totalPssKb);
+  return result;
+}
+
+// ── Static DataGrid schemas ─────────────────────────────────────────────────
+
+export const ALL_STRINGS_SCHEMA: SchemaRegistry = {
+  string: {
+    vmaAddr: {
+      title: 'Address',
+      columnType: 'quantitative',
+      cellRenderer: (v: SqlValue) => ({
+        content: Number(v).toString(16).padStart(8, '0'),
+        align: 'left' as const,
+      }),
+    },
+    vmaName: {title: 'VMA', columnType: 'text'},
+    str: {title: 'String', columnType: 'text'},
+  },
+};
+
+export const VMAS_CROSS_SCHEMA: SchemaRegistry = {
+  vma: {
+    name: {title: 'Mapping', columnType: 'text'},
+    perms: {title: 'Perms', columnType: 'text'},
+    processCount: {title: 'Processes', columnType: 'quantitative'},
+    totalPssKb: {
+      title: 'PSS',
+      columnType: 'quantitative',
+      cellRenderer: sizeRenderer,
+    },
+    totalRssKb: {
+      title: 'RSS',
+      columnType: 'quantitative',
+      cellRenderer: sizeRenderer,
+    },
+    totalPrivDirtyKb: {
+      title: 'Priv Dirty',
+      columnType: 'quantitative',
+      cellRenderer: sizeRenderer,
+    },
+    totalPrivCleanKb: {
+      title: 'Priv Clean',
+      columnType: 'quantitative',
+      cellRenderer: sizeRenderer,
+    },
+    totalSwapKb: {
+      title: 'Swap',
+      columnType: 'quantitative',
+      cellRenderer: sizeRenderer,
+    },
+    totalSizeKb: {
+      title: 'VSS',
+      columnType: 'quantitative',
+      cellRenderer: sizeRenderer,
+    },
+  },
+};
diff --git a/ui/src/plugins/com.android.SmapsExplorer/index.ts b/ui/src/plugins/com.android.SmapsExplorer/index.ts
new file mode 100644
index 0000000..56a7fef
--- /dev/null
+++ b/ui/src/plugins/com.android.SmapsExplorer/index.ts
@@ -0,0 +1,41 @@
+// 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 {App} from '../../public/app';
+import {PerfettoPlugin} from '../../public/plugin';
+import {SmapsExplorerPage} from './capture_page';
+import RecordTraceV2Plugin from '../dev.perfetto.RecordTraceV2';
+
+export default class implements PerfettoPlugin {
+  static readonly id = 'com.android.SmapsExplorer';
+  static readonly description =
+    'Explore Android process memory maps (smaps) via ADB';
+  static readonly dependencies = [RecordTraceV2Plugin];
+
+  static onActivate(app: App) {
+    app.sidebar.addMenuItem({
+      section: 'trace_files',
+      text: 'Smaps Explorer',
+      href: '#!/smaps',
+      icon: 'memory',
+      sortOrder: 3,
+    });
+
+    app.pages.registerPage({
+      route: '/smaps',
+      render: () => m(SmapsExplorerPage, {app}),
+    });
+  }
+}
diff --git a/ui/src/plugins/com.android.SmapsExplorer/mapping_view.ts b/ui/src/plugins/com.android.SmapsExplorer/mapping_view.ts
new file mode 100644
index 0000000..d491316
--- /dev/null
+++ b/ui/src/plugins/com.android.SmapsExplorer/mapping_view.ts
@@ -0,0 +1,230 @@
+// 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 {Anchor} from '../../widgets/anchor';
+import {Button} from '../../widgets/button';
+import {EmptyState} from '../../widgets/empty_state';
+import {DataGrid} from '../../components/widgets/datagrid/datagrid';
+import {type SchemaRegistry} from '../../components/widgets/datagrid/datagrid_schema';
+import {type Row, type SqlValue} from '../../trace_processor/query_result';
+import {
+  sizeRenderer,
+  hexAddrRenderer,
+  filterAggregated,
+  type VmaType,
+} from './data';
+import {type MappingTabState, type PageContext} from './state';
+import {type SmapsEntry} from './smaps_connection';
+
+// ── VMA filter toolbar (shared between process and VMA views) ───────────────
+
+// Cycle: null (unfiltered) → true (required) → false (excluded) → null.
+function nextPermState(val: boolean | null): boolean | null {
+  if (val === null) return true;
+  if (val === true) return false;
+  return null;
+}
+
+export function renderVmaFilterToolbar(ctx: PageContext): m.Children {
+  const f = ctx.vmaFilters;
+  const typeBtn = (type: VmaType, label: string) =>
+    m(Button, {
+      label,
+      compact: true,
+      active: f.type === type,
+      onclick: () => ctx.setVmaFilters({...f, type}),
+    });
+  const permBtn = (perm: 'r' | 'w' | 'x', val: boolean | null) =>
+    m(Button, {
+      label: perm,
+      compact: true,
+      active: val !== null,
+      className: val === false ? 'pf-smaps-explorer__perm-deselected' : '',
+      onclick: () => {
+        ctx.setVmaFilters({...f, [perm]: nextPermState(val)});
+      },
+    });
+
+  return m('.pf-smaps-explorer__filter-toolbar', [
+    m('.pf-smaps-explorer__btn-group', [
+      typeBtn('all', 'All'),
+      typeBtn('file', 'File'),
+      typeBtn('anon', 'Anon'),
+    ]),
+    m('.pf-smaps-explorer__btn-group', [
+      permBtn('r', f.r),
+      permBtn('w', f.w),
+      permBtn('x', f.x),
+    ]),
+  ]);
+}
+
+// ── Individual VMA schema for a mapping ─────────────────────────────────────
+
+function buildVmaSchema(
+  ctx: PageContext,
+  pid: number,
+  ms: MappingTabState,
+  entries: SmapsEntry[],
+): SchemaRegistry {
+  // Index entries by numeric start address for O(1) lookup in cell renderer.
+  const byAddr = new Map<number, SmapsEntry>();
+  for (const e of entries) {
+    byAddr.set(parseInt(e.addrStart, 16), e);
+  }
+
+  return {
+    vma: {
+      addrStart: {
+        title: 'Start',
+        columnType: 'quantitative',
+        cellRenderer: (value: SqlValue) => {
+          const entry = byAddr.get(Number(value));
+          if (entry === undefined) return hexAddrRenderer(value);
+          return m(
+            Anchor,
+            {
+              onclick: () => {
+                ctx.scanSingleVma(
+                  pid,
+                  ms,
+                  entry.addrStart,
+                  entry.addrEnd,
+                  entry.perms,
+                );
+              },
+              title: 'Scan strings in this VMA',
+            },
+            hexAddrRenderer(value).content,
+          );
+        },
+      },
+      addrEnd: {
+        title: 'End',
+        columnType: 'quantitative',
+        cellRenderer: hexAddrRenderer,
+      },
+      perms: {title: 'Perms', columnType: 'text'},
+      pssKb: {
+        title: 'PSS',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      rssKb: {
+        title: 'RSS',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      privateDirtyKb: {
+        title: 'Priv Dirty',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      privateCleanKb: {
+        title: 'Priv Clean',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      sharedDirtyKb: {
+        title: 'Shared Dirty',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      sharedCleanKb: {
+        title: 'Shared Clean',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      swapKb: {
+        title: 'Swap',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      sizeKb: {
+        title: 'VSS',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+    },
+  };
+}
+
+// ── Mapping tab: individual VMAs for a specific mapping ─────────────────────
+
+export function renderMappingTab(
+  ctx: PageContext,
+  pid: number,
+  mappingName: string,
+  ms: MappingTabState,
+): m.Children {
+  const rawAgg = ctx.smapsData.get(pid);
+  if (rawAgg === undefined) return null;
+
+  const aggregated = filterAggregated(rawAgg, ctx.vmaFilters);
+  const entries = aggregated
+    .filter((g) => (g.name || '[anonymous]') === mappingName)
+    .flatMap((g) => g.entries);
+
+  const rows: Row[] = entries.map((e) => ({
+    addrStart: parseInt(e.addrStart, 16),
+    addrEnd: parseInt(e.addrEnd, 16),
+    perms: e.perms,
+    sizeKb: e.sizeKb,
+    rssKb: e.rssKb,
+    pssKb: e.pssKb,
+    privateCleanKb: e.privateCleanKb,
+    privateDirtyKb: e.privateDirtyKb,
+    sharedCleanKb: e.sharedCleanKb,
+    sharedDirtyKb: e.sharedDirtyKb,
+    swapKb: e.swapKb,
+  }));
+
+  return m('.pf-smaps-explorer__panel', [
+    m('.pf-smaps-explorer__toolbar', [
+      m('span.pf-smaps-explorer__label', `${entries.length} VMAs`),
+      m('.pf-smaps-explorer__spacer'),
+      renderVmaFilterToolbar(ctx),
+    ]),
+    entries.length === 0
+      ? m(EmptyState, {
+          icon: 'filter_alt',
+          title: 'No VMAs match the current filters',
+          fillHeight: true,
+        })
+      : m(
+          '.pf-smaps-explorer__grid-container',
+          m(DataGrid, {
+            key: ctx.smapsScanGeneration,
+            schema: buildVmaSchema(ctx, pid, ms, entries),
+            rootSchema: 'vma',
+            data: rows,
+            fillHeight: true,
+            initialColumns: [
+              {id: 'addrStart', field: 'addrStart'},
+              {id: 'addrEnd', field: 'addrEnd'},
+              {id: 'perms', field: 'perms'},
+              {id: 'pssKb', field: 'pssKb', sort: 'DESC' as const},
+              {id: 'rssKb', field: 'rssKb'},
+              {id: 'privateDirtyKb', field: 'privateDirtyKb'},
+              {id: 'privateCleanKb', field: 'privateCleanKb'},
+              {id: 'sharedDirtyKb', field: 'sharedDirtyKb'},
+              {id: 'sharedCleanKb', field: 'sharedCleanKb'},
+              {id: 'swapKb', field: 'swapKb'},
+              {id: 'sizeKb', field: 'sizeKb'},
+            ],
+          }),
+        ),
+  ]);
+}
diff --git a/ui/src/plugins/com.android.SmapsExplorer/process_view.ts b/ui/src/plugins/com.android.SmapsExplorer/process_view.ts
new file mode 100644
index 0000000..beade7e
--- /dev/null
+++ b/ui/src/plugins/com.android.SmapsExplorer/process_view.ts
@@ -0,0 +1,486 @@
+// 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 {type App} from '../../public/app';
+import {Anchor} from '../../widgets/anchor';
+import {Button} from '../../widgets/button';
+import {Spinner} from '../../widgets/spinner';
+import {DataGrid} from '../../components/widgets/datagrid/datagrid';
+import {type SchemaRegistry} from '../../components/widgets/datagrid/datagrid_schema';
+import {type Row, type SqlValue} from '../../trace_processor/query_result';
+import {Tabs, type TabsTab} from '../../widgets/tabs';
+import {sizeRenderer, filterAggregated} from './data';
+import {
+  TAB_PROCESSES,
+  TAB_INSPECT,
+  TAB_MAPPING,
+  TAB_STRINGS_ALL,
+  TAB_STRINGS_DUPS,
+  TAB_STRINGS_VMA,
+  procTabKey,
+  parseProcTabKey,
+  mapTabKey,
+  parseMapTabKey,
+  closeTab,
+  type ProcessTabState,
+  type MappingTabState,
+  type PageContext,
+} from './state';
+import {getDupsFor, buildStringsTabs, closeStringsTab} from './strings_view';
+import {renderVmaFilterToolbar, renderMappingTab} from './mapping_view';
+
+// ── Process View (outer tabs) ───────────────────────────────────────────────
+
+export function renderProcessView(ctx: PageContext, app: App): m.Children {
+  const outerTabs: TabsTab[] = [];
+
+  outerTabs.push({
+    key: TAB_PROCESSES,
+    title: `Processes (${ctx.processes?.length ?? 0})`,
+    content: renderProcessesTab(ctx),
+  });
+
+  for (const pid of ctx.s.openProcessOrder) {
+    const ps = ctx.s.openProcesses.get(pid);
+    if (ps === undefined) continue;
+    const process = ctx.processes?.find((p) => p.pid === pid);
+    const pname = process?.name ?? `PID ${pid}`;
+    outerTabs.push({
+      key: procTabKey(pid),
+      title: pname,
+      closeButton: true,
+      content: renderProcessSubTabs(ctx, pid, ps, app),
+    });
+  }
+
+  const activeKey =
+    ctx.s.activeProcessPid !== null
+      ? procTabKey(ctx.s.activeProcessPid)
+      : ctx.s.processTab;
+
+  return m(Tabs, {
+    tabs: outerTabs,
+    activeTabKey: activeKey,
+    onTabChange: (key) => {
+      if (key === TAB_PROCESSES) {
+        ctx.s.activeProcessPid = null;
+        ctx.s.processTab = TAB_PROCESSES;
+      } else {
+        const pid = parseProcTabKey(key);
+        if (pid !== undefined) {
+          ctx.s.activeProcessPid = pid;
+          ctx.s.processTab = key;
+        }
+      }
+    },
+    onTabClose: (key) => {
+      const pid = parseProcTabKey(key);
+      if (pid !== undefined) {
+        const next = closeTab(
+          ctx.s.openProcesses,
+          ctx.s.openProcessOrder,
+          ctx.s.activeProcessPid,
+          pid,
+          null,
+        );
+        ctx.s.activeProcessPid = next;
+        ctx.s.processTab = next !== null ? procTabKey(next) : TAB_PROCESSES;
+      }
+    },
+  });
+}
+
+// ── Per-process sub-tabs ────────────────────────────────────────────────────
+
+function renderProcessSubTabs(
+  ctx: PageContext,
+  pid: number,
+  ps: ProcessTabState,
+  app: App,
+): m.Children {
+  const subTabs: TabsTab[] = [];
+
+  subTabs.push({
+    key: TAB_INSPECT,
+    title: 'Mappings',
+    content: renderSmapsGrid(ctx, pid, ps, app),
+  });
+
+  for (const name of ps.openMappingOrder) {
+    const ms = ps.openMappings.get(name);
+    if (ms === undefined) continue;
+    subTabs.push({
+      key: mapTabKey(name),
+      title: name,
+      closeButton: true,
+      content: renderMappingSubTabs(ctx, pid, name, ms),
+    });
+  }
+
+  // Process-level strings tabs (from "All Strings" button)
+  if (ps.processStringsData !== null) {
+    const pss = ctx.getProcessStringsState(ps);
+    const dups = getDupsFor(pss, ps.processStringsData.strings);
+    subTabs.push(
+      ...buildStringsTabs(
+        pss,
+        dups,
+        () => {
+          ps.activeMapping = null;
+          ps.subTab = TAB_STRINGS_ALL;
+        },
+        'All ',
+        '',
+      ),
+    );
+  }
+
+  const activeKey =
+    ps.activeMapping !== null ? mapTabKey(ps.activeMapping) : ps.subTab;
+
+  return m(Tabs, {
+    tabs: subTabs,
+    activeTabKey: activeKey,
+    onTabChange: (key) => {
+      if (key === TAB_INSPECT) {
+        ps.activeMapping = null;
+        ps.subTab = TAB_INSPECT;
+      } else {
+        const name = parseMapTabKey(key);
+        if (name !== undefined) {
+          ps.activeMapping = name;
+          ps.subTab = key;
+        } else {
+          ps.activeMapping = null;
+          ps.subTab = key;
+        }
+      }
+    },
+    onTabClose: (key) => {
+      const name = parseMapTabKey(key);
+      if (name !== undefined) {
+        const next = closeTab(
+          ps.openMappings,
+          ps.openMappingOrder,
+          ps.activeMapping,
+          name,
+          null,
+        );
+        ps.activeMapping = next;
+        ps.subTab = next !== null ? mapTabKey(next) : TAB_INSPECT;
+      } else if (
+        key === TAB_STRINGS_ALL ||
+        key === TAB_STRINGS_DUPS ||
+        key === TAB_STRINGS_VMA
+      ) {
+        const pss = ctx.getProcessStringsState(ps);
+        ps.subTab = closeStringsTab(pss, key, ps.subTab, TAB_INSPECT);
+      }
+    },
+  });
+}
+
+// ── Per-mapping sub-tabs ────────────────────────────────────────────────────
+
+function renderMappingSubTabs(
+  ctx: PageContext,
+  pid: number,
+  mappingName: string,
+  ms: MappingTabState,
+): m.Children {
+  const subTabs: TabsTab[] = [];
+
+  subTabs.push({
+    key: TAB_MAPPING,
+    title: 'VMAs',
+    content: renderMappingTab(ctx, pid, mappingName, ms),
+  });
+
+  if (ms.stringsData !== null) {
+    const dups = getDupsFor(ms, ms.stringsData.strings);
+    subTabs.push(
+      ...buildStringsTabs(
+        ms,
+        dups,
+        () => {
+          ms.subTab = TAB_STRINGS_ALL;
+        },
+        '',
+        ms.stringsData.processName,
+      ),
+    );
+  }
+
+  return m(Tabs, {
+    tabs: subTabs,
+    activeTabKey: ms.subTab,
+    onTabChange: (key) => {
+      ms.subTab = key;
+    },
+    onTabClose: (key) => {
+      if (
+        key === TAB_STRINGS_ALL ||
+        key === TAB_STRINGS_DUPS ||
+        key === TAB_STRINGS_VMA
+      ) {
+        ms.subTab = closeStringsTab(ms, key, ms.subTab, TAB_MAPPING);
+      }
+    },
+  });
+}
+
+// ── Processes list DataGrid ─────────────────────────────────────────────────
+
+function buildProcessSchema(ctx: PageContext): SchemaRegistry {
+  return {
+    process: {
+      pid: {title: 'PID', columnType: 'quantitative'},
+      name: {
+        title: 'Process',
+        columnType: 'text',
+        cellRenderer: (value: SqlValue, row: Row) => {
+          if (!ctx.isRoot) return String(value);
+          const pid = Number(row.pid);
+          return m(
+            Anchor,
+            {
+              onclick: () => ctx.inspectProcess(pid),
+              title: 'Inspect smaps',
+            },
+            String(value),
+          );
+        },
+      },
+      oomLabel: {title: 'State', columnType: 'text'},
+      pssKb: {
+        title: 'PSS',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      rssKb: {
+        title: 'RSS',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      privateDirtyKb: {
+        title: 'Priv Dirty',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      privateCleanKb: {
+        title: 'Priv Clean',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      swapKb: {
+        title: 'Swap',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      sizeKb: {
+        title: 'VSS',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+    },
+  };
+}
+
+function renderProcessesTab(ctx: PageContext): m.Children {
+  if (ctx.processes === null) return null;
+
+  const rows: Row[] = ctx.processes.map((p) => {
+    const r = ctx.rollups.get(p.pid);
+    return {
+      pid: p.pid,
+      name: p.name,
+      oomLabel: p.oomLabel,
+      pssKb: r?.pssKb ?? 0,
+      rssKb: r?.rssKb ?? 0,
+      privateDirtyKb: r?.privateDirtyKb ?? 0,
+      privateCleanKb: r?.privateCleanKb ?? 0,
+      swapKb: r?.swapKb ?? 0,
+      sizeKb: r?.sizeKb ?? 0,
+    };
+  });
+
+  return m(DataGrid, {
+    key: ctx.enrichGeneration,
+    schema: buildProcessSchema(ctx),
+    rootSchema: 'process',
+    data: rows,
+    fillHeight: true,
+    initialColumns: [
+      {id: 'pid', field: 'pid'},
+      {id: 'name', field: 'name'},
+      {id: 'oomLabel', field: 'oomLabel'},
+      {id: 'pssKb', field: 'pssKb', sort: 'DESC' as const},
+      {id: 'rssKb', field: 'rssKb'},
+      {id: 'privateDirtyKb', field: 'privateDirtyKb'},
+      {id: 'privateCleanKb', field: 'privateCleanKb'},
+      {id: 'swapKb', field: 'swapKb'},
+      {id: 'sizeKb', field: 'sizeKb'},
+    ],
+  });
+}
+
+// ── Aggregated mappings DataGrid (per-process) ──────────────────────────────
+
+function buildAggMappingSchema(
+  ctx: PageContext,
+  ps: ProcessTabState,
+): SchemaRegistry {
+  return {
+    mapping: {
+      name: {
+        title: 'Mapping',
+        columnType: 'text',
+        cellRenderer: (value: SqlValue) => {
+          const name = String(value);
+          return m(
+            Anchor,
+            {
+              onclick: () => ctx.openMapping(ps, name),
+              title: 'Show individual VMAs',
+            },
+            name,
+          );
+        },
+      },
+      count: {title: 'Count', columnType: 'quantitative'},
+      pssKb: {
+        title: 'PSS',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      rssKb: {
+        title: 'RSS',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      privateDirtyKb: {
+        title: 'Priv Dirty',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      privateCleanKb: {
+        title: 'Priv Clean',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      sharedDirtyKb: {
+        title: 'Shared Dirty',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      sharedCleanKb: {
+        title: 'Shared Clean',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      swapKb: {
+        title: 'Swap',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      sizeKb: {
+        title: 'VSS',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+    },
+  };
+}
+
+function renderSmapsGrid(
+  ctx: PageContext,
+  pid: number,
+  ps: ProcessTabState,
+  app: App,
+): m.Children {
+  if (ctx.loadingPid === pid) {
+    return m('.pf-smaps-explorer__loading', m(Spinner));
+  }
+  const rawAgg = ctx.smapsData.get(pid);
+  if (rawAgg === undefined) {
+    return m('.pf-smaps-explorer__loading--muted', 'Loading smaps\u2026');
+  }
+
+  const aggregated = filterAggregated(rawAgg, ctx.vmaFilters);
+  const process = ctx.processes?.find((p) => p.pid === pid);
+  const processName = process?.name ?? '';
+  const totalEntries = aggregated.reduce((n, g) => n + g.entries.length, 0);
+
+  const aggRows: Row[] = aggregated.map((g) => ({
+    name: g.name || '[anonymous]',
+    count: g.count,
+    sizeKb: g.sizeKb,
+    rssKb: g.rssKb,
+    pssKb: g.pssKb,
+    privateCleanKb: g.privateCleanKb,
+    privateDirtyKb: g.privateDirtyKb,
+    sharedCleanKb: g.sharedCleanKb,
+    sharedDirtyKb: g.sharedDirtyKb,
+    swapKb: g.swapKb,
+  }));
+
+  return m('.pf-smaps-explorer__panel', [
+    // Action bar
+    m('.pf-smaps-explorer__toolbar', [
+      m(
+        'span.pf-smaps-explorer__label',
+        `${aggregated.length} mappings \u00b7 ${totalEntries} VMAs`,
+      ),
+      m('.pf-smaps-explorer__spacer'),
+      renderVmaFilterToolbar(ctx),
+      m(Button, {
+        label: 'All Strings',
+        icon: 'text_fields',
+        compact: true,
+        onclick: () => ctx.startStringsScan(pid, processName, ps),
+      }),
+      m(Button, {
+        label: 'Heap Dump',
+        icon: 'download',
+        compact: true,
+        onclick: () => ctx.captureHeap(pid, processName, app),
+      }),
+    ]),
+
+    // Aggregated mappings DataGrid
+    m(
+      '.pf-smaps-explorer__grid-container',
+      m(DataGrid, {
+        key: ctx.smapsScanGeneration,
+        schema: buildAggMappingSchema(ctx, ps),
+        rootSchema: 'mapping',
+        data: aggRows,
+        fillHeight: true,
+        initialColumns: [
+          {id: 'name', field: 'name'},
+          {id: 'count', field: 'count'},
+          {id: 'pssKb', field: 'pssKb', sort: 'DESC' as const},
+          {id: 'rssKb', field: 'rssKb'},
+          {id: 'privateDirtyKb', field: 'privateDirtyKb'},
+          {id: 'privateCleanKb', field: 'privateCleanKb'},
+          {id: 'sharedDirtyKb', field: 'sharedDirtyKb'},
+          {id: 'sharedCleanKb', field: 'sharedCleanKb'},
+          {id: 'swapKb', field: 'swapKb'},
+          {id: 'sizeKb', field: 'sizeKb'},
+        ],
+      }),
+    ),
+  ]);
+}
diff --git a/ui/src/plugins/com.android.SmapsExplorer/smaps_connection.ts b/ui/src/plugins/com.android.SmapsExplorer/smaps_connection.ts
new file mode 100644
index 0000000..3848429
--- /dev/null
+++ b/ui/src/plugins/com.android.SmapsExplorer/smaps_connection.ts
@@ -0,0 +1,729 @@
+// 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 {AdbWebusbDevice} from '../dev.perfetto.RecordTraceV2/adb/webusb/adb_webusb_device';
+import {AdbKeyManager} from '../dev.perfetto.RecordTraceV2/adb/webusb/adb_key_manager';
+import {ADB_DEVICE_FILTER} from '../dev.perfetto.RecordTraceV2/adb/webusb/adb_webusb_utils';
+import {utf8Encode} from '../../base/string_utils';
+
+// ── Types ───────────────────────────────────────────────────────────────────
+
+export interface ProcessInfo {
+  pid: number;
+  name: string;
+  oomLabel: string;
+  pssKb: number;
+  rssKb: number;
+}
+
+export interface SmapsEntry {
+  addrStart: string;
+  addrEnd: string;
+  perms: string;
+  name: string;
+  dev: string;
+  inode: number;
+  sizeKb: number;
+  rssKb: number;
+  pssKb: number;
+  sharedCleanKb: number;
+  sharedDirtyKb: number;
+  privateCleanKb: number;
+  privateDirtyKb: number;
+  swapKb: number;
+  swapPssKb: number;
+}
+
+export interface VmaString {
+  offset: number;
+  vmaAddr: number;
+  str: string;
+  vmaIndex: number;
+}
+
+export interface VmaRegionInfo {
+  addrStart: string;
+  addrEnd: string;
+  perms: string;
+  name: string;
+  sizeKb: number;
+  stringCount: number;
+}
+
+export interface ProcessStringsResult {
+  pid: number;
+  processName: string;
+  regions: VmaRegionInfo[];
+  strings: VmaString[];
+  scanning?: boolean;
+  scannedVmas?: number;
+  totalVmas?: number;
+}
+
+function parseGrepOutput(output: string): {offset: number; str: string}[] {
+  const results: {offset: number; str: string}[] = [];
+  for (const line of output.split('\n')) {
+    const trimmed = line.replace(/\r$/, '');
+    if (trimmed === '') continue;
+    const colonIdx = trimmed.indexOf(':');
+    if (colonIdx < 0) continue;
+    const offset = parseInt(trimmed.substring(0, colonIdx), 10);
+    if (!isFinite(offset)) continue;
+    const str = trimmed.substring(colonIdx + 1);
+    if (str.length >= 4) results.push({offset, str});
+  }
+  return results;
+}
+
+export interface SmapsAggregated {
+  name: string;
+  count: number;
+  sizeKb: number;
+  rssKb: number;
+  pssKb: number;
+  sharedCleanKb: number;
+  sharedDirtyKb: number;
+  privateCleanKb: number;
+  privateDirtyKb: number;
+  swapKb: number;
+  swapPssKb: number;
+  entries: SmapsEntry[];
+}
+
+// ── Shell helper ────────────────────────────────────────────────────────────
+
+/** Run a shell command via Perfetto's ADB device and return stdout as string. */
+async function shell(device: AdbWebusbDevice, cmd: string): Promise<string> {
+  const result = await device.shell(cmd);
+  if (!result.ok) throw new Error(result.error);
+  return result.value;
+}
+
+// ── Smaps parsing ───────────────────────────────────────────────────────────
+
+const VMA_HEADER =
+  /^([0-9a-f]+)-([0-9a-f]+)\s+(\S+)\s+\S+\s+(\S+)\s+(\d+)\s*(.*)/;
+
+export function parseSmaps(output: string): SmapsEntry[] {
+  const entries: SmapsEntry[] = [];
+  let cur: SmapsEntry | null = null;
+  for (const line of output.split('\n')) {
+    const hdr = VMA_HEADER.exec(line);
+    if (hdr) {
+      if (cur) entries.push(cur);
+      cur = {
+        addrStart: hdr[1],
+        addrEnd: hdr[2],
+        perms: hdr[3],
+        dev: hdr[4],
+        inode: parseInt(hdr[5], 10) || 0,
+        name: (hdr[6] ?? '').trim(),
+        sizeKb: 0,
+        rssKb: 0,
+        pssKb: 0,
+        sharedCleanKb: 0,
+        sharedDirtyKb: 0,
+        privateCleanKb: 0,
+        privateDirtyKb: 0,
+        swapKb: 0,
+        swapPssKb: 0,
+      };
+      continue;
+    }
+    if (!cur) continue;
+    const m = /^(\w[\w_]*):\s+(\d+)\s+kB/.exec(line);
+    if (!m) continue;
+    const val = parseInt(m[2], 10);
+    switch (m[1]) {
+      case 'Size':
+        cur.sizeKb = val;
+        break;
+      case 'Rss':
+        cur.rssKb = val;
+        break;
+      case 'Pss':
+        cur.pssKb = val;
+        break;
+      case 'Shared_Clean':
+        cur.sharedCleanKb = val;
+        break;
+      case 'Shared_Dirty':
+        cur.sharedDirtyKb = val;
+        break;
+      case 'Private_Clean':
+        cur.privateCleanKb = val;
+        break;
+      case 'Private_Dirty':
+        cur.privateDirtyKb = val;
+        break;
+      case 'Swap':
+        cur.swapKb = val;
+        break;
+      case 'SwapPss':
+        cur.swapPssKb = val;
+        break;
+    }
+  }
+  if (cur) entries.push(cur);
+  return entries;
+}
+
+export function aggregateSmaps(entries: SmapsEntry[]): SmapsAggregated[] {
+  const groups = new Map<string, SmapsAggregated>();
+  for (const e of entries) {
+    const existing = groups.get(e.name);
+    if (existing) {
+      existing.count++;
+      existing.sizeKb += e.sizeKb;
+      existing.rssKb += e.rssKb;
+      existing.pssKb += e.pssKb;
+      existing.sharedCleanKb += e.sharedCleanKb;
+      existing.sharedDirtyKb += e.sharedDirtyKb;
+      existing.privateCleanKb += e.privateCleanKb;
+      existing.privateDirtyKb += e.privateDirtyKb;
+      existing.swapKb += e.swapKb;
+      existing.swapPssKb += e.swapPssKb;
+      existing.entries.push(e);
+    } else {
+      groups.set(e.name, {
+        name: e.name,
+        count: 1,
+        sizeKb: e.sizeKb,
+        rssKb: e.rssKb,
+        pssKb: e.pssKb,
+        sharedCleanKb: e.sharedCleanKb,
+        sharedDirtyKb: e.sharedDirtyKb,
+        privateCleanKb: e.privateCleanKb,
+        privateDirtyKb: e.privateDirtyKb,
+        swapKb: e.swapKb,
+        swapPssKb: e.swapPssKb,
+        entries: [e],
+      });
+    }
+  }
+  const result = [...groups.values()];
+  result.sort((a, b) => b.pssKb - a.pssKb);
+  return result;
+}
+
+// ── Smaps rollup ────────────────────────────────────────────────────────────
+
+export interface SmapsRollup {
+  sizeKb: number;
+  rssKb: number;
+  pssKb: number;
+  sharedCleanKb: number;
+  sharedDirtyKb: number;
+  privateCleanKb: number;
+  privateDirtyKb: number;
+  swapKb: number;
+}
+
+// ── OOM label mapping ────────────────────────────────────────────────────────
+
+const OOM_LABEL_MAP: Record<string, string> = {
+  'pers': 'Persistent',
+  'top': 'Top',
+  'bfgs': 'Bound FG Service',
+  'btop': 'Bound Top',
+  'fgs': 'FG Service',
+  'fg': 'Foreground',
+  'impfg': 'Important Foreground',
+  'impbg': 'Important Background',
+  'backup': 'Backup',
+  'service': 'Service',
+  'service-rs': 'Service Restarting',
+  'receiver': 'Receiver',
+  'heavy': 'Heavy Weight',
+  'home': 'Home',
+  'lastact': 'Last Activity',
+  'cached': 'Cached',
+  'cch': 'Cached',
+  'frzn': 'Frozen',
+  'native': 'Native',
+  'sys': 'System',
+  'fore': 'Foreground',
+  'foreground': 'Foreground',
+  'vis': 'Visible',
+  'visible': 'Visible',
+  'percep': 'Perceptible',
+  'perceptible': 'Perceptible',
+  'svcb': 'Service B',
+  'svcrst': 'Service Restarting',
+  'prev': 'Previous',
+  'lstact': 'Last Activity',
+};
+
+function mapOomLabel(raw: string): string {
+  const base = raw.replace(/\d+$/, '');
+  return OOM_LABEL_MAP[base] ?? raw;
+}
+
+// ── LRU process list parsing ────────────────────────────────────────────────
+
+// Matches: "  #0: fg     TOP  LCM 1234:com.android.systemui/u0a45 act:activities"
+// Also:    "  #15: cch+75 CEM 9012:com.google.android.gms/u0a67"
+const LRU_LINE = /^\s*#\d+:\s+(\S+)\s+.*?\s(\d+):([^\s/]+)/;
+
+export const PINNED_PROCESSES = new Set([
+  'system_server',
+  'com.android.systemui',
+]);
+
+export function parseLruProcesses(output: string): ProcessInfo[] {
+  const results: ProcessInfo[] = [];
+  const seen = new Set<number>();
+  for (const line of output.split('\n')) {
+    const m = LRU_LINE.exec(line);
+    if (!m) continue;
+    const pid = parseInt(m[2], 10);
+    if (!isFinite(pid) || seen.has(pid)) continue;
+    seen.add(pid);
+    const oomRaw = m[1].replace(/\+\d+$/, '');
+    results.push({
+      pid,
+      name: m[3],
+      oomLabel: mapOomLabel(oomRaw),
+      pssKb: 0,
+      rssKb: 0,
+    });
+  }
+  return results;
+}
+
+// ── ADB sync pull ───────────────────────────────────────────────────────────
+
+function encodeSyncCmd(cmd: string, length: number): Uint8Array {
+  const buf = new Uint8Array(8);
+  const dv = new DataView(buf.buffer);
+  for (let i = 0; i < 4; i++) dv.setUint8(i, cmd.charCodeAt(i));
+  dv.setUint32(4, length, true);
+  return buf;
+}
+
+async function pullFile(
+  device: AdbWebusbDevice,
+  remotePath: string,
+  onProgress?: (received: number, total: number) => void,
+): Promise<Uint8Array> {
+  // Get file size
+  let fileSize = -1;
+  try {
+    const statResult = await shell(device, `stat -c %s '${remotePath}'`);
+    const parsed = parseInt(statResult.trim(), 10);
+    if (isFinite(parsed) && parsed > 0) fileSize = parsed;
+  } catch {
+    // stat failed
+  }
+
+  // Open sync stream
+  const streamResult = await device.createStream('sync:');
+  if (!streamResult.ok) throw new Error(streamResult.error);
+  const stream = streamResult.value;
+
+  return new Promise<Uint8Array>((resolve, reject) => {
+    const chunks: Uint8Array[] = [];
+    let received = 0;
+    let headerBuf = new Uint8Array(0);
+
+    stream.onData = (raw: Uint8Array) => {
+      let buf: Uint8Array;
+      if (headerBuf.length > 0) {
+        buf = new Uint8Array(headerBuf.length + raw.length);
+        buf.set(headerBuf, 0);
+        buf.set(raw, headerBuf.length);
+        headerBuf = new Uint8Array(0);
+      } else {
+        buf = raw;
+      }
+
+      let offset = 0;
+      while (offset < buf.length) {
+        if (buf.length - offset < 8) {
+          headerBuf = buf.slice(offset);
+          return;
+        }
+        const dv = new DataView(buf.buffer, buf.byteOffset + offset, 8);
+        const cmd = String.fromCharCode(
+          dv.getUint8(0),
+          dv.getUint8(1),
+          dv.getUint8(2),
+          dv.getUint8(3),
+        );
+        const length = dv.getUint32(4, true);
+
+        if (cmd === 'DATA') {
+          offset += 8;
+          const dataEnd = offset + length;
+          if (dataEnd > buf.length) {
+            headerBuf = buf.slice(offset - 8);
+            return;
+          }
+          const chunk = buf.slice(offset, dataEnd);
+          chunks.push(chunk);
+          received += chunk.length;
+          onProgress?.(received, fileSize);
+          offset = dataEnd;
+        } else if (cmd === 'DONE') {
+          stream.close();
+          const total = chunks.reduce((s, c) => s + c.length, 0);
+          const result = new Uint8Array(total);
+          let off = 0;
+          for (const c of chunks) {
+            result.set(c, off);
+            off += c.length;
+          }
+          resolve(result);
+          return;
+        } else if (cmd === 'FAIL') {
+          offset += 8;
+          const msgEnd = offset + length;
+          const decoder = new TextDecoder();
+          const msg = decoder.decode(
+            buf.subarray(offset, Math.min(msgEnd, buf.length)),
+          );
+          stream.close();
+          reject(new Error(`ADB sync FAIL: ${msg}`));
+          return;
+        } else {
+          stream.close();
+          reject(new Error(`Unexpected sync response: ${cmd}`));
+          return;
+        }
+      }
+    };
+
+    stream.onClose = () => {
+      if (chunks.length > 0) {
+        const total = chunks.reduce((s, c) => s + c.length, 0);
+        const result = new Uint8Array(total);
+        let off = 0;
+        for (const c of chunks) {
+          result.set(c, off);
+          off += c.length;
+        }
+        resolve(result);
+      } else {
+        reject(new Error('Sync stream closed before receiving data'));
+      }
+    };
+
+    // Send RECV command
+    const pathBytes = utf8Encode(remotePath);
+    const recvCmd = encodeSyncCmd('RECV', pathBytes.length);
+    const sendBuf = new Uint8Array(recvCmd.length + pathBytes.length);
+    sendBuf.set(recvCmd, 0);
+    sendBuf.set(pathBytes, recvCmd.length);
+    stream.write(sendBuf).catch(reject);
+  });
+}
+
+// ── SmapsConnection ─────────────────────────────────────────────────────────
+
+export class SmapsConnection {
+  private device: AdbWebusbDevice | null = null;
+  private keyMgr = new AdbKeyManager();
+  private suPrefix = '';
+  private _isRoot = false;
+
+  get connected(): boolean {
+    return this.device !== null;
+  }
+  get isRoot(): boolean {
+    return this._isRoot;
+  }
+
+  async connect(onStatus?: (msg: string) => void): Promise<void> {
+    if (navigator.usb === undefined) throw new Error('WebUSB not supported');
+    const usbDev = await navigator.usb.requestDevice({
+      filters: [ADB_DEVICE_FILTER],
+    });
+    onStatus?.('Authorize on device\u2026');
+    const result = await AdbWebusbDevice.connect(usbDev, this.keyMgr);
+    if (!result.ok) throw new Error(result.error);
+    this.device = result.value;
+
+    // Try to get root
+    this._isRoot = false;
+    this.suPrefix = '';
+    for (const prefix of ['su 0', 'su -c']) {
+      try {
+        const out = await shell(this.device, `${prefix} id`);
+        if (out.includes('uid=0')) {
+          this._isRoot = true;
+          this.suPrefix = prefix;
+          break;
+        }
+      } catch {
+        // Not rooted or wrong su variant
+      }
+    }
+  }
+
+  disconnect(): void {
+    this.device?.close();
+    this.device = null;
+    this._isRoot = false;
+    this.suPrefix = '';
+  }
+
+  async getProcessList(): Promise<ProcessInfo[]> {
+    if (!this.device) throw new Error('Not connected');
+    const output = await shell(this.device, 'dumpsys activity lru');
+    const results = parseLruProcesses(output);
+    // Add pinned system processes not already in the LRU list
+    const seenPids = new Set(results.map((p) => p.pid));
+    for (const name of PINNED_PROCESSES) {
+      try {
+        const pidStr = (await shell(this.device, `pidof ${name}`)).trim();
+        const pid = parseInt(pidStr, 10);
+        if (!isFinite(pid) || seenPids.has(pid)) continue;
+        seenPids.add(pid);
+        results.push({pid, name, oomLabel: 'System', pssKb: 0, rssKb: 0});
+      } catch {
+        // Process may not exist
+      }
+    }
+    return results;
+  }
+
+  async getSmapsForPid(pid: number): Promise<SmapsEntry[]> {
+    if (!this.device) throw new Error('Not connected');
+    if (!this._isRoot) throw new Error('Root required');
+    const cmd =
+      this.suPrefix === 'su -c'
+        ? `su -c 'cat /proc/${pid}/smaps'`
+        : `su 0 cat /proc/${pid}/smaps`;
+    const output = await shell(this.device, cmd);
+    return parseSmaps(output);
+  }
+
+  async getSmapsRollupForPid(pid: number): Promise<SmapsRollup> {
+    if (!this.device) throw new Error('Not connected');
+    if (!this._isRoot) throw new Error('Root required');
+    const cmd =
+      this.suPrefix === 'su -c'
+        ? `su -c 'cat /proc/${pid}/smaps_rollup'`
+        : `su 0 cat /proc/${pid}/smaps_rollup`;
+    const output = await shell(this.device, cmd);
+    const r: SmapsRollup = {
+      sizeKb: 0,
+      rssKb: 0,
+      pssKb: 0,
+      sharedCleanKb: 0,
+      sharedDirtyKb: 0,
+      privateCleanKb: 0,
+      privateDirtyKb: 0,
+      swapKb: 0,
+    };
+    for (const line of output.split('\n')) {
+      const match = /^(\w[\w_]*):\s+(\d+)\s+kB/.exec(line);
+      if (match === null) continue;
+      const val = parseInt(match[2], 10);
+      switch (match[1]) {
+        case 'Rss':
+          r.rssKb = val;
+          break;
+        case 'Pss':
+          r.pssKb = val;
+          break;
+        case 'Shared_Clean':
+          r.sharedCleanKb = val;
+          break;
+        case 'Shared_Dirty':
+          r.sharedDirtyKb = val;
+          break;
+        case 'Private_Clean':
+          r.privateCleanKb = val;
+          break;
+        case 'Private_Dirty':
+          r.privateDirtyKb = val;
+          break;
+        case 'Swap':
+          r.swapKb = val;
+          break;
+      }
+    }
+    return r;
+  }
+
+  async enrichProcesses(
+    processes: ProcessInfo[],
+    onProgress?: (done: number, total: number) => void,
+  ): Promise<Map<number, SmapsRollup>> {
+    if (!this.device || !this._isRoot) return new Map();
+    const rollups = new Map<number, SmapsRollup>();
+    for (let i = 0; i < processes.length; i++) {
+      try {
+        const r = await this.getSmapsRollupForPid(processes[i].pid);
+        rollups.set(processes[i].pid, r);
+      } catch {
+        // Process may have died
+      }
+      onProgress?.(i + 1, processes.length);
+    }
+    return rollups;
+  }
+
+  async dumpVmaMemory(
+    pid: number,
+    regions: {addrStart: string; addrEnd: string}[],
+    onProgress: (status: string) => void,
+  ): Promise<Uint8Array> {
+    if (!this.device) throw new Error('Not connected');
+    if (!this._isRoot) throw new Error('Root required');
+    if (regions.length === 0) throw new Error('No regions');
+
+    const tmpPath = `/data/local/tmp/vma_${pid}_${Date.now()}.bin`;
+    const ddCmds = regions.map((r, i) => {
+      const startByte = parseInt(r.addrStart, 16);
+      const endByte = parseInt(r.addrEnd, 16);
+      const startPage = Math.floor(startByte / 4096);
+      const numPages = Math.ceil((endByte - startByte) / 4096);
+      const redir = i === 0 ? '>' : '>>';
+      return `dd if=/proc/${pid}/mem bs=4096 skip=${startPage} count=${numPages} ${redir} ${tmpPath} 2>/dev/null`;
+    });
+
+    try {
+      onProgress('Reading memory\u2026');
+      const shellCmd =
+        this.suPrefix === 'su -c'
+          ? `su -c '${ddCmds.join(' && ')}'`
+          : `su 0 sh -c '${ddCmds.join(' && ')}'`;
+      await shell(this.device, shellCmd);
+
+      onProgress('Pulling\u2026');
+      const data = await pullFile(this.device, tmpPath, (received, total) => {
+        const mb = (received / 1_048_576).toFixed(1);
+        const pct = total > 0 ? Math.round((100 * received) / total) : 0;
+        onProgress(`Pulling: ${mb} MiB (${pct}%)`);
+      });
+      return data;
+    } finally {
+      try {
+        await shell(this.device, `rm -f ${tmpPath}`);
+      } catch {
+        // ignore
+      }
+    }
+  }
+
+  async captureHeapDump(
+    pid: number,
+    onProgress: (status: string) => void,
+  ): Promise<Uint8Array> {
+    if (!this.device) throw new Error('Not connected');
+    const tmpPath = `/data/local/tmp/heap_${pid}_${Date.now()}.hprof`;
+    try {
+      onProgress('Dumping heap\u2026');
+      await shell(this.device, `am dumpheap ${pid} ${tmpPath}`);
+      // Wait for dump to complete
+      await new Promise((resolve) => setTimeout(resolve, 2000));
+      onProgress('Pulling\u2026');
+      const data = await pullFile(this.device, tmpPath, (received, total) => {
+        const mb = (received / 1_048_576).toFixed(1);
+        const pct = total > 0 ? Math.round((100 * received) / total) : 0;
+        onProgress(`Pulling heap: ${mb} MiB (${pct}%)`);
+      });
+      return data;
+    } finally {
+      try {
+        await shell(this.device, `rm -f ${tmpPath}`);
+      } catch {
+        // ignore
+      }
+    }
+  }
+
+  async grepVmaStrings(
+    pid: number,
+    entries: SmapsEntry[],
+    onBatch: (
+      newStrings: VmaString[],
+      regions: VmaRegionInfo[],
+      completed: number,
+      total: number,
+    ) => void,
+  ): Promise<{regions: VmaRegionInfo[]; strings: VmaString[]}> {
+    if (!this.device) throw new Error('Not connected');
+    if (!this._isRoot) throw new Error('Root required');
+
+    const BATCH_SIZE = 8;
+    const MARKER = '___AHAT_VMA_BOUNDARY___';
+    const readable = entries.filter((e) => e.perms[0] === 'r');
+    const regions: VmaRegionInfo[] = readable.map((e) => ({
+      addrStart: e.addrStart,
+      addrEnd: e.addrEnd,
+      perms: e.perms,
+      name: e.name,
+      sizeKb: e.sizeKb,
+      stringCount: 0,
+    }));
+    const strings: VmaString[] = [];
+
+    for (
+      let batchStart = 0;
+      batchStart < readable.length;
+      batchStart += BATCH_SIZE
+    ) {
+      const batchEnd = Math.min(batchStart + BATCH_SIZE, readable.length);
+      const cmds: string[] = [];
+      const batchMeta: {index: number; startByte: number}[] = [];
+      for (let i = batchStart; i < batchEnd; i++) {
+        const e = readable[i];
+        const startByte = parseInt(e.addrStart, 16);
+        const endByte = parseInt(e.addrEnd, 16);
+        const startPage = Math.floor(startByte / 4096);
+        const numPages = Math.ceil((endByte - startByte) / 4096);
+        batchMeta.push({index: i, startByte});
+        cmds.push(`echo ${MARKER}`);
+        cmds.push(
+          `dd if=/proc/${pid}/mem bs=4096 skip=${startPage} count=${numPages} 2>/dev/null | grep -baoE "[ -~]{4,}"`,
+        );
+      }
+
+      try {
+        const innerCmd = cmds.join(';');
+        const shellCmd =
+          this.suPrefix === 'su -c'
+            ? `su -c '${innerCmd}'`
+            : `su 0 sh -c '${innerCmd}'`;
+        const output = await shell(this.device, shellCmd);
+        const sections = output.split(MARKER);
+        const batchStrings: VmaString[] = [];
+        for (let si = 0; si < batchMeta.length; si++) {
+          const section = sections[si + 1] ?? '';
+          const {index, startByte} = batchMeta[si];
+          const parsed = parseGrepOutput(section);
+          regions[index].stringCount = parsed.length;
+          for (const p of parsed) {
+            const vs: VmaString = {
+              offset: p.offset,
+              vmaAddr: startByte + p.offset,
+              str: p.str,
+              vmaIndex: index,
+            };
+            strings.push(vs);
+            batchStrings.push(vs);
+          }
+        }
+        onBatch(batchStrings, regions, batchEnd, readable.length);
+      } catch {
+        onBatch([], regions, batchEnd, readable.length);
+      }
+    }
+
+    return {regions, strings};
+  }
+}
diff --git a/ui/src/plugins/com.android.SmapsExplorer/state.ts b/ui/src/plugins/com.android.SmapsExplorer/state.ts
new file mode 100644
index 0000000..81fe46d
--- /dev/null
+++ b/ui/src/plugins/com.android.SmapsExplorer/state.ts
@@ -0,0 +1,245 @@
+// 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 {type Filter} from '../../components/widgets/datagrid/model';
+import {type App} from '../../public/app';
+import {
+  SmapsConnection,
+  type ProcessInfo,
+  type SmapsAggregated,
+  type SmapsRollup,
+  type ProcessStringsResult,
+  type VmaString,
+} from './smaps_connection';
+import {type DuplicateGroup, type VmaFilters} from './data';
+
+// ── Tab keys ────────────────────────────────────────────────────────────────
+
+export const TAB_PROCESSES = 'processes';
+export const TAB_VMAS = 'vmas';
+export const TAB_INSPECT = 'inspect';
+export const TAB_MAPPING = 'mapping';
+export const TAB_STRINGS_ALL = 'strings_all';
+export const TAB_STRINGS_DUPS = 'strings_dups';
+export const TAB_STRINGS_VMA = 'strings_vma';
+
+// Prefixed tab key helpers — centralises encoding/decoding of composite keys
+// like `proc_123` or `map_libc.so` that appear throughout the tab system.
+const PROC_PREFIX = 'proc_';
+const MAP_PREFIX = 'map_';
+const VMAP_PREFIX = 'vmap_';
+
+export function procTabKey(pid: number): string {
+  return `${PROC_PREFIX}${pid}`;
+}
+export function mapTabKey(name: string): string {
+  return `${MAP_PREFIX}${name}`;
+}
+export function vmapTabKey(name: string): string {
+  return `${VMAP_PREFIX}${name}`;
+}
+export function parseProcTabKey(key: string): number | undefined {
+  return key.startsWith(PROC_PREFIX)
+    ? parseInt(key.slice(PROC_PREFIX.length), 10)
+    : undefined;
+}
+export function parseMapTabKey(key: string): string | undefined {
+  return key.startsWith(MAP_PREFIX) ? key.slice(MAP_PREFIX.length) : undefined;
+}
+export function parseVmapTabKey(key: string): string | undefined {
+  return key.startsWith(VMAP_PREFIX)
+    ? key.slice(VMAP_PREFIX.length)
+    : undefined;
+}
+
+// ── Strings state (shared between mapping-level and process-level) ────────
+
+export interface StringsState {
+  stringsData: ProcessStringsResult | null;
+  stringsFilterKey: number;
+  stringsInitialFilters: readonly Filter[];
+  cachedDups: DuplicateGroup[];
+  cachedDupsStrings: VmaString[] | null;
+}
+
+// ── Per-mapping tab state ─────────────────────────────────────────────────
+
+export interface MappingTabState extends StringsState {
+  subTab: string;
+}
+
+export function newMappingTabState(): MappingTabState {
+  return {
+    subTab: TAB_MAPPING,
+    stringsData: null,
+    stringsFilterKey: 0,
+    stringsInitialFilters: [],
+    cachedDups: [],
+    cachedDupsStrings: null,
+  };
+}
+
+// ── Per-process tab state ──────────────────────────────────────────────────
+
+export interface ProcessTabState {
+  subTab: string;
+  openMappings: Map<string, MappingTabState>;
+  openMappingOrder: string[];
+  activeMapping: string | null;
+  processStringsData: ProcessStringsResult | null;
+  processStringsDups: DuplicateGroup[];
+  processStringsDupsStrings: VmaString[] | null;
+  processStringsFilterKey: number;
+  processStringsInitialFilters: readonly Filter[];
+}
+
+export function newProcessTabState(): ProcessTabState {
+  return {
+    subTab: TAB_INSPECT,
+    openMappings: new Map(),
+    openMappingOrder: [],
+    activeMapping: null,
+    processStringsData: null,
+    processStringsDups: [],
+    processStringsDupsStrings: null,
+    processStringsFilterKey: 0,
+    processStringsInitialFilters: [],
+  };
+}
+
+// ── Per-VMA-mapping state (VMA View) ──────────────────────────────────────
+
+export interface VmaMappingTabState {
+  subTab: string;
+  openProcs: Map<number, MappingTabState>;
+  openProcOrder: number[];
+  activeProc: number | null;
+}
+
+export function newVmaMappingTabState(): VmaMappingTabState {
+  return {
+    subTab: 'procs',
+    openProcs: new Map(),
+    openProcOrder: [],
+    activeProc: null,
+  };
+}
+
+// ── Page context (passed to extracted view modules) ─────────────────────────
+
+export interface PageContext {
+  // Data
+  readonly processes: ProcessInfo[] | null;
+  readonly smapsData: Map<number, SmapsAggregated[]>;
+  readonly rollups: Map<number, SmapsRollup>;
+  readonly vmaFilters: VmaFilters;
+  readonly isRoot: boolean;
+
+  // UI state
+  readonly loadingPid: number | null;
+  readonly enrichGeneration: number;
+  readonly smapsScanGeneration: number;
+  readonly scanningAllSmaps: boolean;
+
+  // Tab state (mutable by views)
+  readonly s: SmapsStore;
+
+  // Actions
+  inspectProcess(pid: number): void;
+  openMapping(ps: ProcessTabState, name: string): void;
+  openVmaProcesses(name: string): void;
+  openVmaProcDetail(vs: VmaMappingTabState, pid: number): void;
+  scanSingleVma(
+    pid: number,
+    ms: MappingTabState,
+    addrStart: string,
+    addrEnd: string,
+    perms: string,
+  ): Promise<void>;
+  startStringsScan(
+    pid: number,
+    processName: string,
+    ps: ProcessTabState,
+  ): Promise<void>;
+  captureHeap(pid: number, name: string, app: App): Promise<void>;
+  scanAllSmaps(): Promise<void>;
+  setVmaFilters(f: VmaFilters): void;
+  getProcessStringsState(ps: ProcessTabState): StringsState;
+}
+
+// ── Tab close helper (shared by process_view and vma_view) ──────────────────
+
+/**
+ * Close a tab from an ordered map of open items.
+ * Returns the key to activate if the closed item was currently active.
+ */
+export function closeTab<K>(
+  items: Map<K, unknown>,
+  order: K[],
+  activeKey: K | null,
+  closedKey: K,
+  defaultKey: K | null,
+): K | null {
+  items.delete(closedKey);
+  const idx = order.indexOf(closedKey);
+  if (idx >= 0) order.splice(idx, 1);
+  if (activeKey !== closedKey) return activeKey;
+  if (order.length > 0) {
+    return idx > 0 ? order[idx - 1] : order[0];
+  }
+  return defaultKey;
+}
+
+// ── Persistent store (survives page navigation) ────────────────────────────
+
+export interface SmapsStore {
+  conn: SmapsConnection;
+  processes: ProcessInfo[] | null;
+  rollups: Map<number, SmapsRollup>;
+  smapsData: Map<number, SmapsAggregated[]>;
+  openProcesses: Map<number, ProcessTabState>;
+  openProcessOrder: number[];
+  activeProcessPid: number | null;
+  openVmaMappings: Map<string, VmaMappingTabState>;
+  openVmaMappingOrder: string[];
+  activeVmaMapping: string | null;
+  topView: 0 | 1;
+  processTab: string;
+  vmaTab: string;
+  vmaFilters: VmaFilters;
+}
+
+let store: SmapsStore | undefined;
+
+export function getStore(): SmapsStore {
+  if (store === undefined) {
+    store = {
+      conn: new SmapsConnection(),
+      processes: null,
+      rollups: new Map(),
+      smapsData: new Map(),
+      openProcesses: new Map(),
+      openProcessOrder: [],
+      activeProcessPid: null,
+      openVmaMappings: new Map(),
+      openVmaMappingOrder: [],
+      activeVmaMapping: null,
+      topView: 0,
+      processTab: TAB_PROCESSES,
+      vmaTab: TAB_VMAS,
+      vmaFilters: {type: 'all', r: null, w: null, x: null},
+    };
+  }
+  return store;
+}
diff --git a/ui/src/plugins/com.android.SmapsExplorer/strings_view.ts b/ui/src/plugins/com.android.SmapsExplorer/strings_view.ts
new file mode 100644
index 0000000..15fd582
--- /dev/null
+++ b/ui/src/plugins/com.android.SmapsExplorer/strings_view.ts
@@ -0,0 +1,296 @@
+// 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 {Anchor} from '../../widgets/anchor';
+import {Spinner} from '../../widgets/spinner';
+import {DataGrid} from '../../components/widgets/datagrid/datagrid';
+import {type SchemaRegistry} from '../../components/widgets/datagrid/datagrid_schema';
+import {type Row, type SqlValue} from '../../trace_processor/query_result';
+import {type TabsTab} from '../../widgets/tabs';
+import {type ProcessStringsResult, type VmaString} from './smaps_connection';
+import {
+  hexAddrRenderer,
+  computeDuplicates,
+  ALL_STRINGS_SCHEMA,
+  type DuplicateGroup,
+} from './data';
+import {
+  TAB_STRINGS_ALL,
+  TAB_STRINGS_DUPS,
+  TAB_STRINGS_VMA,
+  type StringsState,
+} from './state';
+
+// ── Duplicate computation cache ─────────────────────────────────────────────
+
+export function getDupsFor(
+  ss: StringsState,
+  strings: VmaString[],
+): DuplicateGroup[] {
+  if (strings !== ss.cachedDupsStrings) {
+    ss.cachedDupsStrings = strings;
+    ss.cachedDups = computeDuplicates(strings);
+  }
+  return ss.cachedDups;
+}
+
+// Sets a filter on the All Strings tab and switches to it.
+function filterAndSwitch(
+  ss: StringsState,
+  switchToStrings: () => void,
+  field: string,
+  value: string,
+): void {
+  ss.stringsInitialFilters = [{field, op: '=', value}];
+  ss.stringsFilterKey++;
+  switchToStrings();
+}
+
+// ── All Strings tab ─────────────────────────────────────────────────────────
+
+export function renderAllStrings(
+  ss: StringsState,
+  strings: VmaString[],
+  data: ProcessStringsResult,
+): m.Children {
+  if (data.scanning && strings.length === 0) {
+    return m('.pf-smaps-explorer__loading', m(Spinner));
+  }
+
+  const rows: Row[] = strings.map((s) => ({
+    vmaAddr: s.vmaAddr,
+    vmaName: data.regions[s.vmaIndex]?.name ?? '',
+    str: s.str,
+  }));
+
+  return m(DataGrid, {
+    key: ss.stringsFilterKey,
+    schema: ALL_STRINGS_SCHEMA,
+    rootSchema: 'string',
+    data: rows,
+    fillHeight: true,
+    initialColumns: [
+      {id: 'vmaAddr', field: 'vmaAddr', sort: 'ASC' as const},
+      {id: 'vmaName', field: 'vmaName'},
+      {id: 'str', field: 'str'},
+    ],
+    initialFilters:
+      ss.stringsInitialFilters.length > 0
+        ? ss.stringsInitialFilters
+        : undefined,
+  });
+}
+
+// ── Duplicates tab ──────────────────────────────────────────────────────────
+
+function buildDuplicatesSchema(
+  ss: StringsState,
+  switchToStrings: () => void,
+): SchemaRegistry {
+  return {
+    duplicate: {
+      totalBytes: {title: 'Bytes', columnType: 'quantitative'},
+      count: {title: 'Count', columnType: 'quantitative'},
+      length: {title: 'Len', columnType: 'quantitative'},
+      vmaCount: {title: 'VMAs', columnType: 'quantitative'},
+      value: {
+        title: 'String',
+        columnType: 'text',
+        cellRenderer: (value: SqlValue) => {
+          const str = String(value);
+          return m(
+            Anchor,
+            {
+              onclick: () => filterAndSwitch(ss, switchToStrings, 'str', str),
+              title: 'Filter strings tab by this value',
+            },
+            str,
+          );
+        },
+      },
+    },
+  };
+}
+
+export function renderDuplicates(
+  ss: StringsState,
+  switchToStrings: () => void,
+  dups: DuplicateGroup[],
+  scanning: boolean,
+): m.Children {
+  if (scanning && dups.length === 0) {
+    return m('.pf-smaps-explorer__loading', m(Spinner));
+  }
+
+  const rows: Row[] = dups.map((d) => ({
+    totalBytes: d.totalBytes,
+    count: d.count,
+    length: d.value.length,
+    vmaCount: d.vmaCount,
+    value: d.value,
+  }));
+
+  return m(DataGrid, {
+    schema: buildDuplicatesSchema(ss, switchToStrings),
+    rootSchema: 'duplicate',
+    data: rows,
+    fillHeight: true,
+    initialColumns: [
+      {id: 'totalBytes', field: 'totalBytes', sort: 'DESC' as const},
+      {id: 'count', field: 'count'},
+      {id: 'length', field: 'length'},
+      {id: 'vmaCount', field: 'vmaCount'},
+      {id: 'value', field: 'value'},
+    ],
+  });
+}
+
+// ── By VMA tab ──────────────────────────────────────────────────────────────
+
+function buildByVmaSchema(
+  ss: StringsState,
+  switchToStrings: () => void,
+): SchemaRegistry {
+  return {
+    vma: {
+      addrStartNum: {
+        title: 'Address',
+        columnType: 'quantitative',
+        cellRenderer: hexAddrRenderer,
+      },
+      perms: {title: 'Perms', columnType: 'text'},
+      name: {
+        title: 'Name',
+        columnType: 'text',
+        cellRenderer: (value: SqlValue) => {
+          const name = String(value) || '[anonymous]';
+          return m(
+            Anchor,
+            {
+              onclick: () =>
+                filterAndSwitch(ss, switchToStrings, 'vmaName', name),
+              title: 'Filter strings tab to this VMA',
+            },
+            name,
+          );
+        },
+      },
+      stringCount: {title: 'Strings', columnType: 'quantitative'},
+      sizeKb: {title: 'Size', columnType: 'quantitative'},
+    },
+  };
+}
+
+export function renderByVma(
+  ss: StringsState,
+  switchToStrings: () => void,
+  strings: VmaString[],
+  data: ProcessStringsResult,
+): m.Children {
+  if (data.scanning && strings.length === 0) {
+    return m('.pf-smaps-explorer__loading', m(Spinner));
+  }
+
+  const vmaCounts = new Map<number, number>();
+  for (const s of strings) {
+    vmaCounts.set(s.vmaIndex, (vmaCounts.get(s.vmaIndex) ?? 0) + 1);
+  }
+
+  const rows: Row[] = data.regions
+    .map((r, i) => ({
+      addrStartNum: parseInt(r.addrStart, 16),
+      perms: r.perms,
+      name: r.name,
+      stringCount: vmaCounts.get(i) ?? 0,
+      sizeKb: r.sizeKb,
+    }))
+    .filter((r) => r.stringCount > 0);
+
+  return m(DataGrid, {
+    schema: buildByVmaSchema(ss, switchToStrings),
+    rootSchema: 'vma',
+    data: rows,
+    fillHeight: true,
+    initialColumns: [
+      {id: 'addrStartNum', field: 'addrStartNum'},
+      {id: 'perms', field: 'perms'},
+      {id: 'name', field: 'name'},
+      {id: 'stringCount', field: 'stringCount', sort: 'DESC' as const},
+      {id: 'sizeKb', field: 'sizeKb'},
+    ],
+  });
+}
+
+// ── Tab builders ────────────────────────────────────────────────────────────
+
+/**
+ * Build the strings-related TabsTab entries (All Strings, Duplicates, By VMA)
+ * for a given StringsState.
+ *
+ * @param titlePrefix - prefix like 'All ' for process-level strings
+ * @param contextLabel - bracketed context, e.g. '[7f00-7f10 libc.so]'
+ */
+export function buildStringsTabs(
+  ss: StringsState,
+  dups: DuplicateGroup[],
+  switchToStringsTab: () => void,
+  titlePrefix: string,
+  contextLabel: string,
+): TabsTab[] {
+  const sd = ss.stringsData!;
+  const strings = sd.strings;
+  const scanning = sd.scanning === true;
+  const progress = scanning
+    ? ` (${sd.scannedVmas ?? 0}/${sd.totalVmas ?? 0})`
+    : '';
+  const ctx = contextLabel !== '' ? ` [${contextLabel}]` : '';
+
+  const tabs: TabsTab[] = [
+    {
+      key: TAB_STRINGS_ALL,
+      title: `${titlePrefix}Strings${progress}${ctx}`,
+      closeButton: true,
+      content: renderAllStrings(ss, strings, sd),
+    },
+    {
+      key: TAB_STRINGS_DUPS,
+      title: `${titlePrefix}Duplicates (${dups.length})${ctx}`,
+      closeButton: true,
+      content: renderDuplicates(ss, switchToStringsTab, dups, scanning),
+    },
+  ];
+  if (sd.regions.length > 1) {
+    tabs.push({
+      key: TAB_STRINGS_VMA,
+      title: `${titlePrefix}Strings by VMA${ctx}`,
+      closeButton: true,
+      content: renderByVma(ss, switchToStringsTab, strings, sd),
+    });
+  }
+  return tabs;
+}
+
+/** Handle closing a strings tab: clear data and reset subTab if needed. */
+export function closeStringsTab(
+  ss: StringsState,
+  key: string,
+  currentSubTab: string,
+  defaultTab: string,
+): string {
+  ss.stringsData = null;
+  ss.stringsInitialFilters = [];
+  ss.stringsFilterKey = 0;
+  return key === currentSubTab ? defaultTab : currentSubTab;
+}
diff --git a/ui/src/plugins/com.android.SmapsExplorer/styles.scss b/ui/src/plugins/com.android.SmapsExplorer/styles.scss
new file mode 100644
index 0000000..c56f2d0
--- /dev/null
+++ b/ui/src/plugins/com.android.SmapsExplorer/styles.scss
@@ -0,0 +1,143 @@
+// 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 "../../assets/theme";
+
+.pf-smaps-explorer {
+  // Full-height flex column — used as the main container for panels with a
+  // toolbar on top and a DataGrid below.
+  &__panel {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    overflow: hidden;
+  }
+
+  // Same as __panel but as a flex child that fills remaining space.
+  &__content {
+    display: flex;
+    flex-direction: column;
+    flex: 1;
+    min-height: 0;
+    overflow: hidden;
+  }
+
+  // Container that fills remaining flex space (e.g. wraps a DataGrid).
+  &__grid-container {
+    flex: 1;
+    min-height: 0;
+    overflow: hidden;
+  }
+
+  // Horizontal toolbar for filters and action buttons.
+  &__toolbar {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    padding: 4px 8px;
+    flex-shrink: 0;
+    flex-wrap: wrap;
+  }
+
+  // Non-wrapping variant for the filter toolbar.
+  &__filter-toolbar {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 4px 8px;
+  }
+
+  // Tightly packed button group (e.g. type filter or r/w/x toggles).
+  &__btn-group {
+    display: flex;
+    gap: 2px;
+  }
+
+  // Small muted label used for counts and descriptions.
+  &__label {
+    color: var(--pf-color-text-muted);
+    font-size: var(--pf-font-size-s);
+  }
+
+  // Pushes siblings to the right in a flex row.
+  &__spacer {
+    flex: 1;
+  }
+
+  // Non-shrinkable wrapper (e.g. holds a toolbar above a flex grid).
+  &__fixed {
+    flex-shrink: 0;
+  }
+
+  // ── Capture page specific ────────────────────────────────────────────────
+
+  &__error-banner {
+    padding: 8px 12px;
+    background: var(--pf-color-danger);
+    color: var(--pf-color-text-on-danger);
+    border-radius: $border-radius-large;
+    margin: 0 8px 8px;
+    flex-shrink: 0;
+  }
+
+  &__badge--warning {
+    padding: 1px 6px;
+    background: var(--pf-color-warning);
+    color: var(--pf-color-text-on-warning);
+    border-radius: $border-radius-large;
+    margin-left: 4px;
+  }
+
+  &__connect {
+    text-align: center;
+    padding: 48px;
+  }
+
+  &__connect-hint {
+    margin-top: 8px;
+    font-size: var(--pf-font-size-s);
+    color: var(--pf-color-text-muted);
+  }
+
+  &__view-selector {
+    padding: 4px 8px;
+    flex-shrink: 0;
+  }
+
+  // ── Loading states ───────────────────────────────────────────────────────
+
+  &__loading {
+    padding: 16px;
+  }
+
+  &__loading--muted {
+    padding: 16px;
+    color: var(--pf-color-text-muted);
+  }
+
+  // ── Info bar (non-interactive label row) ─────────────────────────────────
+
+  &__info-bar {
+    padding: 4px 8px;
+    color: var(--pf-color-text-muted);
+    font-size: var(--pf-font-size-s);
+    flex-shrink: 0;
+  }
+
+  // ── Perm button strikethrough ────────────────────────────────────────────
+
+  &__perm-deselected {
+    text-decoration: line-through;
+  }
+}
diff --git a/ui/src/plugins/com.android.SmapsExplorer/vma_view.ts b/ui/src/plugins/com.android.SmapsExplorer/vma_view.ts
new file mode 100644
index 0000000..5361653
--- /dev/null
+++ b/ui/src/plugins/com.android.SmapsExplorer/vma_view.ts
@@ -0,0 +1,422 @@
+// 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 {Anchor} from '../../widgets/anchor';
+import {Button} from '../../widgets/button';
+import {EmptyState} from '../../widgets/empty_state';
+import {DataGrid} from '../../components/widgets/datagrid/datagrid';
+import {type SchemaRegistry} from '../../components/widgets/datagrid/datagrid_schema';
+import {type Row, type SqlValue} from '../../trace_processor/query_result';
+import {Tabs, type TabsTab} from '../../widgets/tabs';
+import {
+  sizeRenderer,
+  aggregateVmasCrossProcess,
+  VMAS_CROSS_SCHEMA,
+} from './data';
+import {
+  TAB_VMAS,
+  TAB_MAPPING,
+  TAB_STRINGS_ALL,
+  TAB_STRINGS_DUPS,
+  TAB_STRINGS_VMA,
+  vmapTabKey,
+  parseVmapTabKey,
+  procTabKey,
+  parseProcTabKey,
+  closeTab,
+  type MappingTabState,
+  type VmaMappingTabState,
+  type PageContext,
+} from './state';
+import {getDupsFor, buildStringsTabs, closeStringsTab} from './strings_view';
+import {renderVmaFilterToolbar, renderMappingTab} from './mapping_view';
+
+// ── VMA View (outer) ────────────────────────────────────────────────────────
+
+export function renderVmaView(ctx: PageContext): m.Children {
+  if (ctx.smapsData.size === 0) {
+    return m(
+      EmptyState,
+      {
+        icon: 'memory',
+        title: 'No VMA data yet',
+        fillHeight: true,
+      },
+      ctx.isRoot &&
+        !ctx.scanningAllSmaps &&
+        m(Button, {
+          label: 'Scan All VMAs',
+          icon: 'memory',
+          onclick: () => ctx.scanAllSmaps(),
+        }),
+      !ctx.isRoot && 'Inspect individual processes first.',
+    );
+  }
+
+  const outerTabs: TabsTab[] = [];
+  const vmaCount = aggregateVmasCrossProcess(
+    ctx.smapsData,
+    ctx.vmaFilters,
+  ).length;
+
+  outerTabs.push({
+    key: TAB_VMAS,
+    title: `All VMAs (${vmaCount})`,
+    content: renderVmasTab(ctx),
+  });
+
+  for (const name of ctx.s.openVmaMappingOrder) {
+    const vs = ctx.s.openVmaMappings.get(name);
+    if (vs === undefined) continue;
+    outerTabs.push({
+      key: vmapTabKey(name),
+      title: name,
+      closeButton: true,
+      content: renderVmaMappingSubTabs(ctx, name, vs),
+    });
+  }
+
+  const activeKey =
+    ctx.s.activeVmaMapping !== null
+      ? vmapTabKey(ctx.s.activeVmaMapping)
+      : ctx.s.vmaTab;
+
+  return m(Tabs, {
+    tabs: outerTabs,
+    activeTabKey: activeKey,
+    onTabChange: (key) => {
+      if (key === TAB_VMAS) {
+        ctx.s.activeVmaMapping = null;
+        ctx.s.vmaTab = key;
+      } else {
+        const name = parseVmapTabKey(key);
+        if (name !== undefined) {
+          ctx.s.activeVmaMapping = name;
+          ctx.s.vmaTab = key;
+        }
+      }
+    },
+    onTabClose: (key) => {
+      const name = parseVmapTabKey(key);
+      if (name !== undefined) {
+        const next = closeTab(
+          ctx.s.openVmaMappings,
+          ctx.s.openVmaMappingOrder,
+          ctx.s.activeVmaMapping,
+          name,
+          null,
+        );
+        ctx.s.activeVmaMapping = next;
+        ctx.s.vmaTab = next !== null ? vmapTabKey(next) : TAB_VMAS;
+      }
+    },
+  });
+}
+
+// ── VMA mapping sub-tabs: Processes | {process}: VMAs ───────────────────────
+
+function renderVmaMappingSubTabs(
+  ctx: PageContext,
+  mappingName: string,
+  vs: VmaMappingTabState,
+): m.Children {
+  const subTabs: TabsTab[] = [];
+
+  subTabs.push({
+    key: 'procs',
+    title: 'Processes',
+    content: renderVmaProcsTab(ctx, mappingName, vs),
+  });
+
+  for (const pid of vs.openProcOrder) {
+    const ms = vs.openProcs.get(pid);
+    if (ms === undefined) continue;
+    const proc = ctx.processes?.find((p) => p.pid === pid);
+    const pname = proc?.name ?? `PID ${pid}`;
+    subTabs.push({
+      key: procTabKey(pid),
+      title: pname,
+      closeButton: true,
+      content: renderVmaProcSubTabs(ctx, pid, mappingName, ms),
+    });
+  }
+
+  const activeKey =
+    vs.activeProc !== null ? procTabKey(vs.activeProc) : vs.subTab;
+
+  return m(Tabs, {
+    tabs: subTabs,
+    activeTabKey: activeKey,
+    onTabChange: (key) => {
+      if (key === 'procs') {
+        vs.activeProc = null;
+        vs.subTab = 'procs';
+      } else {
+        const pid = parseProcTabKey(key);
+        if (pid !== undefined) {
+          vs.activeProc = pid;
+          vs.subTab = key;
+        }
+      }
+    },
+    onTabClose: (key) => {
+      const pid = parseProcTabKey(key);
+      if (pid !== undefined) {
+        const next = closeTab(
+          vs.openProcs,
+          vs.openProcOrder,
+          vs.activeProc,
+          pid,
+          null,
+        );
+        vs.activeProc = next;
+        vs.subTab = next !== null ? procTabKey(next) : 'procs';
+      }
+    },
+  });
+}
+
+// ── VMAs + strings sub-tabs for a process within VMA View ───────────────────
+
+function renderVmaProcSubTabs(
+  ctx: PageContext,
+  pid: number,
+  mappingName: string,
+  ms: MappingTabState,
+): m.Children {
+  const subTabs: TabsTab[] = [];
+
+  subTabs.push({
+    key: TAB_MAPPING,
+    title: 'VMAs',
+    content: renderMappingTab(ctx, pid, mappingName, ms),
+  });
+
+  if (ms.stringsData !== null) {
+    const dups = getDupsFor(ms, ms.stringsData.strings);
+    subTabs.push(
+      ...buildStringsTabs(
+        ms,
+        dups,
+        () => {
+          ms.subTab = TAB_STRINGS_ALL;
+        },
+        '',
+        ms.stringsData.processName,
+      ),
+    );
+  }
+
+  return m(Tabs, {
+    tabs: subTabs,
+    activeTabKey: ms.subTab,
+    onTabChange: (key) => {
+      ms.subTab = key;
+    },
+    onTabClose: (key) => {
+      if (
+        key === TAB_STRINGS_ALL ||
+        key === TAB_STRINGS_DUPS ||
+        key === TAB_STRINGS_VMA
+      ) {
+        ms.subTab = closeStringsTab(ms, key, ms.subTab, TAB_MAPPING);
+      }
+    },
+  });
+}
+
+// ── Cross-process VMAs DataGrid ─────────────────────────────────────────────
+
+function buildVmasCrossSchema(ctx: PageContext): SchemaRegistry {
+  return {
+    vma: {
+      ...VMAS_CROSS_SCHEMA.vma,
+      name: {
+        title: 'Mapping',
+        columnType: 'text' as const,
+        cellRenderer: (value: SqlValue) => {
+          const name = String(value);
+          return m(
+            Anchor,
+            {
+              onclick: () => ctx.openVmaProcesses(name),
+              title: 'Show processes using this mapping',
+            },
+            name,
+          );
+        },
+      },
+    },
+  };
+}
+
+function renderVmasTab(ctx: PageContext): m.Children {
+  const vmas = aggregateVmasCrossProcess(ctx.smapsData, ctx.vmaFilters);
+
+  const rows: Row[] = vmas.map((v) => ({
+    name: v.name,
+    perms: v.perms,
+    processCount: v.processCount,
+    totalPssKb: v.totalPssKb,
+    totalRssKb: v.totalRssKb,
+    totalPrivDirtyKb: v.totalPrivDirtyKb,
+    totalPrivCleanKb: v.totalPrivCleanKb,
+    totalSwapKb: v.totalSwapKb,
+    totalSizeKb: v.totalSizeKb,
+  }));
+
+  return m('.pf-smaps-explorer__panel', [
+    m('.pf-smaps-explorer__fixed', renderVmaFilterToolbar(ctx)),
+    m(
+      '.pf-smaps-explorer__grid-container',
+      m(DataGrid, {
+        key: ctx.smapsScanGeneration,
+        schema: buildVmasCrossSchema(ctx),
+        rootSchema: 'vma',
+        data: rows,
+        fillHeight: true,
+        initialColumns: [
+          {id: 'name', field: 'name'},
+          {id: 'perms', field: 'perms'},
+          {id: 'processCount', field: 'processCount'},
+          {
+            id: 'totalPssKb',
+            field: 'totalPssKb',
+            sort: 'DESC' as const,
+          },
+          {id: 'totalRssKb', field: 'totalRssKb'},
+          {id: 'totalPrivDirtyKb', field: 'totalPrivDirtyKb'},
+          {id: 'totalPrivCleanKb', field: 'totalPrivCleanKb'},
+          {id: 'totalSwapKb', field: 'totalSwapKb'},
+          {id: 'totalSizeKb', field: 'totalSizeKb'},
+        ],
+      }),
+    ),
+  ]);
+}
+
+// ── Per-mapping processes DataGrid ──────────────────────────────────────────
+
+function renderVmaProcsTab(
+  ctx: PageContext,
+  mapping: string,
+  vs: VmaMappingTabState,
+): m.Children {
+  if (ctx.processes === null) return null;
+
+  const pids = new Set<number>();
+  for (const [pid, agg] of ctx.smapsData) {
+    for (const g of agg) {
+      if ((g.name || '[anonymous]') === mapping) {
+        pids.add(pid);
+        break;
+      }
+    }
+  }
+
+  const procs = ctx.processes.filter((p) => pids.has(p.pid));
+  const rows: Row[] = procs.map((p) => {
+    const agg = ctx.smapsData.get(p.pid);
+    let pssKb = 0;
+    let rssKb = 0;
+    let sizeKb = 0;
+    let count = 0;
+    if (agg !== undefined) {
+      for (const g of agg) {
+        for (const e of g.entries) {
+          if ((e.name || '[anonymous]') === mapping) {
+            pssKb += e.pssKb;
+            rssKb += e.rssKb;
+            sizeKb += e.sizeKb;
+            count++;
+          }
+        }
+      }
+    }
+    return {
+      pid: p.pid,
+      name: p.name,
+      oomLabel: p.oomLabel,
+      count,
+      pssKb,
+      rssKb,
+      sizeKb,
+    };
+  });
+
+  const schema: SchemaRegistry = {
+    process: {
+      pid: {title: 'PID', columnType: 'quantitative'},
+      name: {
+        title: 'Process',
+        columnType: 'text',
+        cellRenderer: (value: SqlValue, row: Row) => {
+          const pid = Number(row.pid);
+          return m(
+            Anchor,
+            {
+              onclick: () => ctx.openVmaProcDetail(vs, pid),
+              title: 'Show VMAs for this process',
+            },
+            String(value),
+          );
+        },
+      },
+      oomLabel: {title: 'State', columnType: 'text'},
+      count: {title: 'VMAs', columnType: 'quantitative'},
+      pssKb: {
+        title: 'PSS',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      rssKb: {
+        title: 'RSS',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+      sizeKb: {
+        title: 'VSS',
+        columnType: 'quantitative',
+        cellRenderer: sizeRenderer,
+      },
+    },
+  };
+
+  return m('.pf-smaps-explorer__panel', [
+    m(
+      '.pf-smaps-explorer__info-bar',
+      `${procs.length} processes use this mapping`,
+    ),
+    m(
+      '.pf-smaps-explorer__grid-container',
+      m(DataGrid, {
+        key: ctx.smapsScanGeneration,
+        schema,
+        rootSchema: 'process',
+        data: rows,
+        fillHeight: true,
+        initialColumns: [
+          {id: 'pid', field: 'pid'},
+          {id: 'name', field: 'name'},
+          {id: 'oomLabel', field: 'oomLabel'},
+          {id: 'count', field: 'count'},
+          {id: 'pssKb', field: 'pssKb', sort: 'DESC' as const},
+          {id: 'rssKb', field: 'rssKb'},
+          {id: 'sizeKb', field: 'sizeKb'},
+        ],
+      }),
+    ),
+  ]);
+}