blob: 33f5aed1c27ee38c028fec24716a02700fd812dc [file]
// 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();
}
}
}