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'}, + ], + }), + ), + ]); +}