blob: 6922c882289b233782b489c3cec791a95ee0ca8e [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 type {Engine} from '../../trace_processor/engine';
import {
BLOB_NULL,
LONG,
LONG_NULL,
NUM,
NUM_NULL,
STR,
STR_NULL,
type QueryResult,
} from '../../trace_processor/query_result';
import type {
OverviewData,
HeapInfo,
InstanceRow,
InstanceDetail,
PathEntry,
PrimOrRef,
BitmapListRow,
StringListRow,
DuplicateBitmapGroup,
DuplicateStringGroup,
DuplicateArrayGroup,
ClassRow,
} from './types';
import {fmtHex} from './format';
import {shortClassName, SQL_PREAMBLE} from './components';
export interface HeapDump {
readonly upid: number;
readonly ts: bigint;
readonly processName: string | null;
readonly pid: number;
}
export async function loadDumpsList(engine: Engine): Promise<HeapDump[]> {
const res = await engine.query(`
SELECT
o.upid AS upid,
o.graph_sample_ts AS ts,
coalesce(p.cmdline, p.name) AS pname,
p.pid AS pid
FROM heap_graph_object o
JOIN process p USING (upid)
GROUP BY o.upid, o.graph_sample_ts
ORDER BY o.graph_sample_ts ASC
`);
const result: HeapDump[] = [];
for (
const it = res.iter({
upid: NUM,
ts: LONG,
pname: STR_NULL,
pid: NUM_NULL,
});
it.valid();
it.next()
) {
result.push({
upid: it.upid,
ts: it.ts,
processName: it.pname,
pid: it.pid ?? 0,
});
}
return result;
}
export function dumpFilterSql(dump: HeapDump, alias: string = 'o'): string {
return (
`${alias}.upid = ${dump.upid} ` +
`AND ${alias}.graph_sample_ts = ${dump.ts}`
);
}
async function requireDominatorTree(engine: Engine): Promise<void> {
await engine.query(SQL_PREAMBLE);
}
function className(name: string | null, deobfuscated: string | null): string {
return deobfuscated ?? name ?? '???';
}
function makeDisplay(cls: string, id: number): string {
return `${shortClassName(cls)} ${fmtHex(id)}`;
}
function sqlEsc(s: string): string {
return s.replace(/'/g, "''");
}
function heapFilter(heap: string | null): string {
return heap ? `AND o.heap_type = '${sqlEsc(heap)}'` : '';
}
const KIND_TO_REACHABILITY: Record<string, string> = {
KIND_WEAK_REFERENCE: 'weak',
KIND_SOFT_REFERENCE: 'soft',
KIND_PHANTOM_REFERENCE: 'phantom',
KIND_FINALIZER_REFERENCE: 'finalizer',
};
function rowFromIter(it: {
id: number;
cls: string;
deob: string | null;
self_size: number;
native_size: number;
heap_type: string | null;
root_type: string | null;
dominated_size: number | null;
dominated_native: number | null;
dominated_obj_count: number | null;
value_string: string | null;
class_kind?: string;
}): InstanceRow {
const cls = className(it.cls, it.deob);
const retainedJava = it.dominated_size ?? it.self_size;
const retainedNative = it.dominated_native ?? it.native_size;
const heap = it.heap_type ?? 'default';
const reachabilityName =
(it.class_kind && KIND_TO_REACHABILITY[it.class_kind]) ?? 'strong';
return {
id: it.id,
display: makeDisplay(cls, it.id),
className: cls,
isRoot: it.root_type !== null,
rootTypeNames: it.root_type !== null ? [it.root_type] : null,
reachabilityName,
heap,
shallowJava: it.self_size,
shallowNative: it.native_size,
retainedTotal: retainedJava + retainedNative,
retainedCount: it.dominated_obj_count ?? 1,
reachableSize: null,
reachableNative: null,
reachableCount: null,
retainedByHeap: [{heap, java: retainedJava, native_: retainedNative}],
str: it.value_string,
referent: null,
};
}
const INSTANCE_COLS = `
o.id, c.name AS cls, c.deobfuscated_name AS deob,
o.self_size, o.native_size, o.heap_type, o.root_type,
d.dominated_size_bytes AS dominated_size,
d.dominated_native_size_bytes AS dominated_native,
d.dominated_obj_count,
od.value_string, c.kind AS class_kind`;
const INSTANCE_ITER_SPEC = {
id: NUM,
cls: STR,
deob: STR_NULL,
self_size: NUM,
native_size: NUM,
heap_type: STR_NULL,
root_type: STR_NULL,
dominated_size: NUM_NULL,
dominated_native: NUM_NULL,
dominated_obj_count: NUM_NULL,
value_string: STR_NULL,
class_kind: STR,
};
function collectRows(res: QueryResult): InstanceRow[] {
const rows: InstanceRow[] = [];
for (const it = res.iter(INSTANCE_ITER_SPEC); it.valid(); it.next()) {
rows.push(rowFromIter(it));
}
return rows;
}
/**
* Batch-fetch content hashes for bitmap pixel buffers via DumpData.
* Uses the pre-computed array_data_hash column from the trace processor,
* avoiding expensive byte-level hashing in TypeScript.
*/
async function batchBitmapBufferHashes(
engine: Engine,
activeDump: HeapDump,
bitmaps: Array<{objectId: number; nativePtr: bigint}>,
): Promise<Map<number, string>> {
const result = new Map<number, string>();
const dumpData = await loadBitmapDumpData(engine, activeDump);
if (!dumpData) return result;
// Build bufferObjId → [bitmapObjectId, ...] mapping.
const bufToBitmaps = new Map<number, number[]>();
for (const b of bitmaps) {
const bufId = dumpData.bufferMap.get(b.nativePtr);
if (bufId === undefined) continue;
const existing = bufToBitmaps.get(bufId);
if (existing) {
existing.push(b.objectId);
} else {
bufToBitmaps.set(bufId, [b.objectId]);
}
}
if (bufToBitmaps.size === 0) return result;
const ids = [...bufToBitmaps.keys()].join(',');
const bufRes = await engine.query(`
SELECT o.id AS buf_id, od.array_data_hash AS hash
FROM heap_graph_object o
JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.id IN (${ids})
AND od.array_data_hash IS NOT NULL
`);
for (
const it = bufRes.iter({buf_id: NUM, hash: LONG_NULL});
it.valid();
it.next()
) {
if (it.hash === null) continue;
const hashStr = it.hash.toString();
const bmpIds = bufToBitmaps.get(it.buf_id);
if (bmpIds) {
for (const id of bmpIds) {
result.set(id, hashStr);
}
}
}
return result;
}
export async function getOverview(
engine: Engine,
activeDump: HeapDump,
): Promise<OverviewData> {
const dumpFilter = dumpFilterSql(activeDump, 'o');
const countRes = await engine.query(`
SELECT
sum(iif(o.reachable, 1, 0)) AS reachable,
sum(iif(NOT o.reachable, 1, 0)) AS unreachable,
count(DISTINCT o.type_id) AS classes
FROM heap_graph_object o
WHERE ${dumpFilter}
`);
const countIt = countRes.iter({
reachable: NUM,
unreachable: NUM,
classes: NUM,
});
const reachableInstanceCount = countIt.reachable;
const unreachableInstanceCount = countIt.unreachable;
const classCount = countIt.classes;
const heapRes = await engine.query(`
SELECT
ifnull(o.heap_type, 'default') AS heap,
SUM(o.self_size) AS java,
SUM(o.native_size) AS native_
FROM heap_graph_object o
WHERE o.reachable != 0 AND ${dumpFilter}
GROUP BY heap
ORDER BY heap
`);
const heaps: HeapInfo[] = [];
for (
const it = heapRes.iter({heap: STR, java: NUM, native_: NUM});
it.valid();
it.next()
) {
heaps.push({name: it.heap, java: it.java, native_: it.native_});
}
// Duplicate bitmaps grouped by pixel content hash. Each bitmap's compressed
// DumpData buffer is hashed to detect true content duplicates rather than
// just matching on dimensions. Skipped for proto heap graphs (no HPROF data).
const hasPrimitivesRes = await engine.query(
`SELECT 1 FROM heap_graph_primitive LIMIT 1`,
);
const hasPrimitives = hasPrimitivesRes.iter({}).valid();
const dupRes = hasPrimitives
? await engine.query(`
SELECT
o.id,
MAX(CASE WHEN f.field_name GLOB '*mWidth' THEN f.int_value END) AS w,
MAX(CASE WHEN f.field_name GLOB '*mHeight' THEN f.int_value END) AS h,
MAX(CASE WHEN f.field_name GLOB '*mNativePtr' THEN f.long_value END)
AS native_ptr,
o.self_size + o.native_size AS total_bytes
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
LEFT JOIN heap_graph_primitive f ON f.field_set_id = od.field_set_id
WHERE o.reachable != 0
AND ${dumpFilter}
AND (c.name = 'android.graphics.Bitmap'
OR c.deobfuscated_name = 'android.graphics.Bitmap')
GROUP BY o.id
HAVING w IS NOT NULL AND h IS NOT NULL
`)
: null;
// Collect bitmap info and compute content hashes.
const bitmapInfos: Array<{
id: number;
w: number;
h: number;
totalBytes: number;
nativePtr: bigint | null;
}> = [];
if (dupRes !== null) {
for (
const it = dupRes.iter({
id: NUM,
w: NUM,
h: NUM,
native_ptr: LONG_NULL,
total_bytes: NUM,
});
it.valid();
it.next()
) {
bitmapInfos.push({
id: it.id,
w: it.w,
h: it.h,
totalBytes: it.total_bytes,
nativePtr: it.native_ptr,
});
}
}
const hashInputs = bitmapInfos
.filter((b) => b.nativePtr !== null)
.map((b) => ({objectId: b.id, nativePtr: b.nativePtr!}));
const hashes =
hashInputs.length > 0
? await batchBitmapBufferHashes(engine, activeDump, hashInputs)
: new Map<number, string>();
const hashGroups = new Map<
string,
{w: number; h: number; cnt: number; total: number; min: number}
>();
for (const b of bitmapInfos) {
const hash = hashes.get(b.id);
if (!hash) continue;
const existing = hashGroups.get(hash);
if (existing) {
existing.cnt++;
existing.total += b.totalBytes;
existing.min = Math.min(existing.min, b.totalBytes);
} else {
hashGroups.set(hash, {
w: b.w,
h: b.h,
cnt: 1,
total: b.totalBytes,
min: b.totalBytes,
});
}
}
const duplicateBitmaps: DuplicateBitmapGroup[] = [];
for (const [key, g] of hashGroups) {
if (g.cnt < 2) continue;
duplicateBitmaps.push({
groupKey: key,
width: g.w,
height: g.h,
count: g.cnt,
totalBytes: g.total,
wastedBytes: g.total - g.min,
});
}
duplicateBitmaps.sort((a, b) => b.wastedBytes - a.wastedBytes);
// Duplicate strings grouped by value. Only available for HPROF dumps
// which populate heap_graph_object_data.value_string.
const duplicateStrings: DuplicateStringGroup[] = [];
if (hasPrimitives) {
const strRes = await engine.query(`
SELECT
od.value_string AS value,
COUNT(*) AS cnt,
SUM(o.self_size) AS total_bytes,
MIN(o.self_size) AS min_bytes
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.reachable != 0
AND ${dumpFilter}
AND od.value_string IS NOT NULL
AND (c.name = 'java.lang.String'
OR c.deobfuscated_name = 'java.lang.String')
GROUP BY od.value_string
HAVING cnt > 1
ORDER BY total_bytes - min_bytes DESC
`);
for (
const it = strRes.iter({
value: STR,
cnt: NUM,
total_bytes: NUM,
min_bytes: NUM,
});
it.valid();
it.next()
) {
duplicateStrings.push({
value: it.value,
count: it.cnt,
totalBytes: it.total_bytes,
wastedBytes: it.total_bytes - it.min_bytes,
});
}
}
const duplicateArrays: DuplicateArrayGroup[] = [];
const dupArrRes = await engine.query(`
SELECT
ifnull(c.deobfuscated_name, c.name) AS cls,
CAST(od.array_data_hash AS TEXT) AS hash,
COUNT(*) AS cnt,
SUM(o.self_size + o.native_size) AS total_bytes,
MIN(o.self_size + o.native_size) AS min_bytes
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.reachable != 0
AND ${dumpFilter}
AND od.array_data_hash IS NOT NULL
GROUP BY o.type_id, od.array_data_hash
HAVING cnt > 1
ORDER BY total_bytes - min_bytes DESC
`);
for (
const it = dupArrRes.iter({
cls: STR,
hash: STR,
cnt: NUM,
total_bytes: NUM,
min_bytes: NUM,
});
it.valid();
it.next()
) {
duplicateArrays.push({
className: it.cls,
arrayHash: it.hash,
count: it.cnt,
totalBytes: it.total_bytes,
wastedBytes: it.total_bytes - it.min_bytes,
});
}
return {
reachableInstanceCount,
unreachableInstanceCount,
classCount,
heaps,
duplicateBitmaps:
duplicateBitmaps.length > 0 ? duplicateBitmaps : undefined,
duplicateStrings:
duplicateStrings.length > 0 ? duplicateStrings : undefined,
duplicateArrays: duplicateArrays.length > 0 ? duplicateArrays : undefined,
hasFieldValues: hasPrimitives,
};
}
export async function getAllocations(
engine: Engine,
activeDump: HeapDump,
heap: string | null,
): Promise<ClassRow[]> {
await requireDominatorTree(engine);
const hf = heapFilter(heap);
const res = await engine.query(`
SELECT
ifnull(c.deobfuscated_name, c.name) AS cls,
COUNT(*) AS cnt,
SUM(o.self_size) AS shallow,
SUM(o.native_size) AS native_shallow,
SUM(ifnull(d.dominated_size_bytes, o.self_size)) AS retained,
SUM(ifnull(d.dominated_native_size_bytes, o.native_size))
AS retained_native,
SUM(ifnull(d.dominated_obj_count, 1)) AS retained_count,
ifnull(o.heap_type, 'default') AS heap
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
WHERE o.reachable != 0
AND ${dumpFilterSql(activeDump, 'o')}
${hf}
GROUP BY cls, heap
ORDER BY retained DESC
`);
const rows: ClassRow[] = [];
for (
const it = res.iter({
cls: STR,
cnt: NUM,
shallow: NUM,
native_shallow: NUM,
retained: NUM,
retained_native: NUM,
retained_count: NUM,
heap: STR,
});
it.valid();
it.next()
) {
rows.push({
className: it.cls,
count: it.cnt,
shallowSize: it.shallow,
nativeSize: it.native_shallow,
retainedSize: it.retained,
retainedNativeSize: it.retained_native,
retainedCount: it.retained_count,
reachableSize: null,
reachableNativeSize: null,
reachableCount: null,
heap: it.heap,
});
}
return rows;
}
export async function getRooted(
engine: Engine,
activeDump: HeapDump,
): Promise<InstanceRow[]> {
await requireDominatorTree(engine);
const res = await engine.query(`
SELECT ${INSTANCE_COLS}
FROM heap_graph_dominator_tree d
JOIN heap_graph_object o ON d.id = o.id
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE d.idom_id IS NULL
AND ${dumpFilterSql(activeDump, 'o')}
ORDER BY d.dominated_size_bytes + d.dominated_native_size_bytes DESC
`);
return collectRows(res);
}
type FieldEntry = {name: string; typeName: string; value: PrimOrRef};
/** Fetch primitive and reference field values for an object. */
async function fetchFieldValues(
engine: Engine,
activeDump: HeapDump,
refSetId: number | null,
fieldSetId: number | null,
): Promise<FieldEntry[]> {
const fields: FieldEntry[] = [];
if (fieldSetId !== null) {
const fRes = await engine.query(`
SELECT field_name, field_type,
bool_value, byte_value, char_value, short_value,
int_value, long_value, float_value, double_value
FROM heap_graph_primitive
WHERE field_set_id = ${fieldSetId}
ORDER BY field_name
`);
for (
const fit = fRes.iter({
field_name: STR,
field_type: STR,
bool_value: NUM_NULL,
byte_value: NUM_NULL,
char_value: NUM_NULL,
short_value: NUM_NULL,
int_value: NUM_NULL,
long_value: LONG_NULL,
float_value: NUM_NULL,
double_value: NUM_NULL,
});
fit.valid();
fit.next()
) {
fields.push({
name: fit.field_name,
typeName: fit.field_type,
value: primFieldValue(fit),
});
}
}
if (refSetId !== null) {
// heap_graph_class is not dump-scoped; restrict and dedup by name to
// avoid cross-dump row multiplication when deobfuscating field_type_name.
const rRes = await engine.query(`
WITH class_in_dump AS (
SELECT c.name, MIN(c.deobfuscated_name) AS deobfuscated_name
FROM heap_graph_class c
JOIN heap_graph_object o ON o.type_id = c.id
WHERE ${dumpFilterSql(activeDump, 'o')}
GROUP BY c.name
)
SELECT
ifnull(r.deobfuscated_field_name, r.field_name) AS fname,
ifnull(ct.deobfuscated_name, r.field_type_name) AS ftype,
r.owned_id,
c2.name AS ref_cls,
c2.deobfuscated_name AS ref_deob,
od2.value_string AS ref_str,
o2.self_size AS ref_self_size,
o2.native_size AS ref_native_size,
d2.dominated_size_bytes AS ref_dominated_size,
d2.dominated_native_size_bytes AS ref_dominated_native
FROM heap_graph_reference r
LEFT JOIN class_in_dump ct ON r.field_type_name = ct.name
LEFT JOIN heap_graph_object o2 ON r.owned_id = o2.id
LEFT JOIN heap_graph_class c2 ON o2.type_id = c2.id
LEFT JOIN heap_graph_object_data od2 ON o2.object_data_id = od2.id
LEFT JOIN heap_graph_dominator_tree d2 ON d2.id = o2.id
WHERE r.reference_set_id = ${refSetId}
ORDER BY fname
`);
for (
const rit = rRes.iter({
fname: STR,
ftype: STR_NULL,
owned_id: NUM_NULL,
ref_cls: STR_NULL,
ref_deob: STR_NULL,
ref_str: STR_NULL,
ref_self_size: NUM_NULL,
ref_native_size: NUM_NULL,
ref_dominated_size: NUM_NULL,
ref_dominated_native: NUM_NULL,
});
rit.valid();
rit.next()
) {
if (rit.owned_id === null || rit.owned_id === 0) {
fields.push({
name: rit.fname,
typeName: rit.ftype ?? '',
value: {kind: 'prim', v: 'null'},
});
} else {
const refCls = className(rit.ref_cls, rit.ref_deob);
fields.push({
name: rit.fname,
typeName: rit.ftype ?? '',
value: {
kind: 'ref',
id: rit.owned_id,
display: makeDisplay(refCls, rit.owned_id),
str: rit.ref_str,
shallowJava: rit.ref_self_size ?? 0,
shallowNative: rit.ref_native_size ?? 0,
retainedJava: rit.ref_dominated_size ?? rit.ref_self_size ?? 0,
retainedNative:
rit.ref_dominated_native ?? rit.ref_native_size ?? 0,
},
});
}
}
}
return fields;
}
/** Fetch dominator-tree path from GC root to the given object. */
export async function fetchDominatorPath(
engine: Engine,
id: number,
): Promise<InstanceDetail['dominatorPath']> {
return (await fetchDominatorPaths(engine, [id])).get(id) ?? null;
}
/** Fetch shortest reference path from a GC root to the given object. */
export async function fetchShortestPathFromRoot(
engine: Engine,
id: number,
): Promise<InstanceDetail['shortestPath']> {
return (await fetchShortestPaths(engine, [id])).get(id) ?? null;
}
/** Batch-fetch shortest reference paths for multiple objects. */
export async function fetchShortestPaths(
engine: Engine,
ids: number[],
): Promise<Map<number, PathEntry[]>> {
const result = new Map<number, PathEntry[]>();
if (ids.length === 0) return result;
await requireDominatorTree(engine);
// Walk UP the BFS tree (id→parent_id) from each target.
const seeds = ids
.map((id) => `SELECT ${id} AS root_node_id, 1 AS root_target_weight`)
.join(' UNION ALL ');
const dfsRes = await engine.query(`
INCLUDE PERFETTO MODULE graphs.search;
SELECT root_node_id, node_id
FROM graph_reachable_weight_bounded_dfs!(
(
SELECT id AS source_node_id, parent_id AS dest_node_id,
0 AS edge_weight
FROM _heap_graph_object_min_depth_tree
WHERE parent_id IS NOT NULL
),
(${seeds}),
1
)
`);
const allNodeIds = new Set<number>();
for (const it = dfsRes.iter({node_id: NUM}); it.valid(); it.next()) {
allNodeIds.add(it.node_id);
}
if (allNodeIds.size === 0) return result;
// Fetch parent_id links to reconstruct ordered chains.
const parentRes = await engine.query(`
SELECT id, parent_id
FROM _heap_graph_object_min_depth_tree
WHERE id IN (${Array.from(allNodeIds).join(',')})
`);
const parentMap = new Map<number, number | null>();
for (
const it = parentRes.iter({id: NUM, parent_id: NUM_NULL});
it.valid();
it.next()
) {
parentMap.set(it.id, it.parent_id ?? null);
}
// Reconstruct ordered chains: walk parent_id from each target up, reverse.
const pathChains = new Map<number, number[]>();
for (const id of ids) {
const chain: number[] = [];
let cur: number | null = id;
while (cur !== null && parentMap.has(cur)) {
chain.push(cur);
cur = parentMap.get(cur) ?? null;
}
if (chain.length === 0) continue;
chain.reverse();
pathChains.set(id, chain);
}
if (pathChains.size === 0) return result;
// Batch-fetch node metadata.
const nodeRes = await engine.query(`
SELECT
o.id, c.name AS cls, c.deobfuscated_name AS deob,
o.self_size, o.native_size, o.heap_type, o.root_type,
dt.dominated_size_bytes AS dominated_size,
dt.dominated_native_size_bytes AS dominated_native,
dt.dominated_obj_count,
od.value_string, c.kind AS class_kind
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
LEFT JOIN heap_graph_dominator_tree dt ON dt.id = o.id
WHERE o.id IN (${Array.from(allNodeIds).join(',')})
`);
const nodeMap = new Map<number, InstanceRow>();
for (
const it = nodeRes.iter({
id: NUM,
cls: STR,
deob: STR_NULL,
self_size: NUM,
native_size: NUM,
heap_type: STR_NULL,
root_type: STR_NULL,
dominated_size: NUM_NULL,
dominated_native: NUM_NULL,
dominated_obj_count: NUM_NULL,
value_string: STR_NULL,
class_kind: STR,
});
it.valid();
it.next()
) {
nodeMap.set(it.id, rowFromIter(it));
}
// Batch-resolve field names for all parent→child edges.
const edgePairs: string[] = [];
for (const chain of pathChains.values()) {
for (let i = 0; i < chain.length - 1; i++) {
edgePairs.push(`SELECT ${chain[i]} AS pid, ${chain[i + 1]} AS cid`);
}
}
const fieldMap = new Map<string, string>();
if (edgePairs.length > 0) {
const fRes = await engine.query(`
SELECT e.pid, e.cid,
ifnull(r.deobfuscated_field_name, r.field_name) AS fname
FROM (${edgePairs.join(' UNION ALL ')}) e
JOIN heap_graph_object o ON o.id = e.pid
JOIN heap_graph_reference r
ON r.reference_set_id = o.reference_set_id AND r.owned_id = e.cid
`);
for (
const it = fRes.iter({pid: NUM, cid: NUM, fname: STR});
it.valid();
it.next()
) {
fieldMap.set(`${it.pid}:${it.cid}`, '.' + it.fname);
}
}
for (const [targetId, chain] of pathChains) {
const path: PathEntry[] = [];
for (let i = 0; i < chain.length; i++) {
const row = nodeMap.get(chain[i]);
if (!row) continue;
const field =
i < chain.length - 1
? fieldMap.get(`${chain[i]}:${chain[i + 1]}`) ?? ''
: '';
path.push({row, field, isDominator: false});
}
if (path.length > 0) result.set(targetId, path);
}
return result;
}
/** Batch-fetch dominator-tree paths for multiple objects. */
export async function fetchDominatorPaths(
engine: Engine,
ids: number[],
): Promise<Map<number, PathEntry[]>> {
const result = new Map<number, PathEntry[]>();
if (ids.length === 0) return result;
await requireDominatorTree(engine);
// Reversed edges (id→idom_id) so DFS walks UP towards the root.
const seeds = ids
.map((id) => `SELECT ${id} AS root_node_id, 1 AS root_target_weight`)
.join(' UNION ALL ');
const dfsRes = await engine.query(`
INCLUDE PERFETTO MODULE graphs.search;
SELECT root_node_id, node_id
FROM graph_reachable_weight_bounded_dfs!(
(
SELECT id AS source_node_id, idom_id AS dest_node_id,
0 AS edge_weight
FROM heap_graph_dominator_tree
WHERE idom_id IS NOT NULL
),
(${seeds}),
1
)
`);
const allNodeIds = new Set<number>();
for (const it = dfsRes.iter({node_id: NUM}); it.valid(); it.next()) {
allNodeIds.add(it.node_id);
}
if (allNodeIds.size === 0) return result;
const idomRes = await engine.query(`
SELECT id, idom_id
FROM heap_graph_dominator_tree
WHERE id IN (${Array.from(allNodeIds).join(',')})
`);
const idomMap = new Map<number, number | null>();
for (
const it = idomRes.iter({id: NUM, idom_id: NUM_NULL});
it.valid();
it.next()
) {
idomMap.set(it.id, it.idom_id ?? null);
}
// Reconstruct ordered chains: walk idom_id from each ID up, then reverse.
const pathChains = new Map<number, number[]>();
for (const id of ids) {
const chain: number[] = [];
let cur: number | null = id;
while (cur !== null && idomMap.has(cur)) {
chain.push(cur);
cur = idomMap.get(cur) ?? null;
}
if (chain.length === 0) continue;
chain.reverse();
pathChains.set(id, chain);
}
if (pathChains.size === 0) return result;
const nodeRes = await engine.query(`
SELECT
o.id, c.name AS cls, c.deobfuscated_name AS deob,
o.self_size, o.native_size, o.heap_type, o.root_type,
dt.dominated_size_bytes AS dominated_size,
dt.dominated_native_size_bytes AS dominated_native,
dt.dominated_obj_count,
od.value_string, c.kind AS class_kind
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
LEFT JOIN heap_graph_dominator_tree dt ON dt.id = o.id
WHERE o.id IN (${Array.from(allNodeIds).join(',')})
`);
const nodeMap = new Map<number, InstanceRow>();
for (
const it = nodeRes.iter({
id: NUM,
cls: STR,
deob: STR_NULL,
self_size: NUM,
native_size: NUM,
heap_type: STR_NULL,
root_type: STR_NULL,
dominated_size: NUM_NULL,
dominated_native: NUM_NULL,
dominated_obj_count: NUM_NULL,
value_string: STR_NULL,
class_kind: STR,
});
it.valid();
it.next()
) {
nodeMap.set(it.id, rowFromIter(it));
}
const edgePairs: string[] = [];
for (const chain of pathChains.values()) {
for (let i = 0; i < chain.length - 1; i++) {
edgePairs.push(`SELECT ${chain[i]} AS pid, ${chain[i + 1]} AS cid`);
}
}
const fieldMap = new Map<string, string>();
if (edgePairs.length > 0) {
const fRes = await engine.query(`
SELECT e.pid, e.cid,
ifnull(r.deobfuscated_field_name, r.field_name) AS fname
FROM (${edgePairs.join(' UNION ALL ')}) e
JOIN heap_graph_object o ON o.id = e.pid
JOIN heap_graph_reference r
ON r.reference_set_id = o.reference_set_id AND r.owned_id = e.cid
`);
for (
const it = fRes.iter({pid: NUM, cid: NUM, fname: STR});
it.valid();
it.next()
) {
fieldMap.set(`${it.pid}:${it.cid}`, '.' + it.fname);
}
}
for (const [targetId, chain] of pathChains) {
const path: PathEntry[] = [];
for (let i = 0; i < chain.length; i++) {
const row = nodeMap.get(chain[i]);
if (!row) continue;
const field =
i < chain.length - 1
? fieldMap.get(`${chain[i]}:${chain[i + 1]}`) ?? ''
: '';
path.push({row, field, isDominator: true});
}
if (path.length > 0) result.set(targetId, path);
}
return result;
}
export async function getInstance(
engine: Engine,
activeDump: HeapDump,
id: number,
): Promise<InstanceDetail | null> {
await requireDominatorTree(engine);
const objRes = await engine.query(`
SELECT
o.id,
o.type_id,
c.name AS cls,
c.deobfuscated_name AS deob,
c.kind AS class_kind,
o.self_size,
o.native_size,
o.heap_type,
o.root_type,
od.value_string,
o.reference_set_id,
od.field_set_id,
od.array_element_type,
od.array_data_id,
d.dominated_size_bytes AS dominated_size,
d.dominated_native_size_bytes AS dominated_native,
d.dominated_obj_count
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.id = ${id}
`);
const oit = objRes.iter({
id: NUM,
type_id: NUM,
cls: STR,
deob: STR_NULL,
class_kind: STR,
self_size: NUM,
native_size: NUM,
heap_type: STR_NULL,
root_type: STR_NULL,
value_string: STR_NULL,
reference_set_id: NUM_NULL,
field_set_id: NUM_NULL,
array_element_type: STR_NULL,
array_data_id: NUM_NULL,
dominated_size: NUM_NULL,
dominated_native: NUM_NULL,
dominated_obj_count: NUM_NULL,
});
if (!oit.valid()) return null;
const fullClassName = className(oit.cls, oit.deob);
const classKind = oit.class_kind;
const typeId = oit.type_id;
const refSetId = oit.reference_set_id;
const fieldSetId = oit.field_set_id;
const arrayDataId = oit.array_data_id;
const arrayElementType = oit.array_element_type;
// Class objects in the C++ parser are named "java.lang.Class<ClassName>".
const isClassObj = fullClassName.startsWith('java.lang.Class<');
const isArrayInstance = fullClassName.endsWith('[]');
const reachabilityName = KIND_TO_REACHABILITY[classKind] ?? 'strong';
const row = rowFromIter({...oit, class_kind: classKind});
row.reachabilityName = reachabilityName;
// Detect referent for Reference subclasses.
if (reachabilityName !== 'strong' && refSetId !== null) {
const refResult = await engine.query(`
SELECT
r.owned_id,
c2.name AS ref_cls, c2.deobfuscated_name AS ref_deob,
od2.value_string AS ref_str
FROM heap_graph_reference r
LEFT JOIN heap_graph_object o2 ON r.owned_id = o2.id
LEFT JOIN heap_graph_class c2 ON o2.type_id = c2.id
LEFT JOIN heap_graph_object_data od2 ON o2.object_data_id = od2.id
WHERE r.reference_set_id = ${refSetId}
AND (r.field_name GLOB '*referent'
OR r.deobfuscated_field_name GLOB '*referent')
`);
const rit = refResult.iter({
owned_id: NUM_NULL,
ref_cls: STR_NULL,
ref_deob: STR_NULL,
ref_str: STR_NULL,
});
if (rit.valid() && rit.owned_id !== null && rit.owned_id !== 0) {
const refCls = className(rit.ref_cls, rit.ref_deob);
row.referent = {
id: rit.owned_id,
display: makeDisplay(refCls, rit.owned_id),
className: refCls,
isRoot: false,
rootTypeNames: null,
reachabilityName: 'strong',
heap: row.heap,
shallowJava: 0,
shallowNative: 0,
retainedTotal: 0,
retainedCount: 1,
reachableCount: null,
reachableSize: null,
reachableNative: null,
retainedByHeap: [],
str: rit.ref_str,
referent: null,
};
}
}
// Look up the java.lang.Class<X> object for this class.
let classObjRow: InstanceRow | null = null;
{
const classObjName = sqlEsc(`java.lang.Class<${oit.cls}>`);
const cRes = await engine.query(`
SELECT ${INSTANCE_COLS}
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE ${dumpFilterSql(activeDump, 'o')}
AND (c.name = '${classObjName}'
OR c.deobfuscated_name = '${classObjName}')
`);
const rows = collectRows(cRes);
if (rows.length > 0) classObjRow = rows[0];
}
const instanceFields =
!isArrayInstance && !isClassObj
? await fetchFieldValues(engine, activeDump, refSetId, fieldSetId)
: [];
let arrayLength = 0;
let elemTypeName: string | null = null;
const arrayElems: InstanceDetail['arrayElems'] = [];
if (isArrayInstance) {
elemTypeName = fullClassName.slice(0, -2); // "int[]" → "int"
if (arrayDataId !== null && arrayElementType !== null) {
// Primitive array: decode via JSON SQL function.
const jsonRes = await engine.query(`
SELECT __intrinsic_heap_graph_array_json(${arrayDataId}) AS data
`);
const jit = jsonRes.iter({data: STR_NULL});
if (jit.valid() && jit.data !== null) {
const values = JSON.parse(jit.data) as Array<number | string | boolean>;
for (let i = 0; i < values.length; i++) {
arrayElems.push({
idx: i,
value: {
kind: 'prim',
v: formatPrimValue(arrayElementType, values[i]),
},
});
}
arrayLength = values.length;
}
} else if (refSetId !== null) {
// Object array elements (String[], Object[], etc.)
// For HPROF, field_name is "[0]", "[1]", etc.
// For perfetto heap graph, field_name may be empty or a plain name.
// We use the row order as fallback index.
const oaRes = await engine.query(`
SELECT
r.field_name AS fname,
r.owned_id,
c2.name AS ref_cls,
c2.deobfuscated_name AS ref_deob,
od2.value_string AS ref_str,
o2.self_size AS ref_shallow,
o2.native_size AS ref_native,
ifnull(d2.dominated_size_bytes, o2.self_size) AS ref_retained,
ifnull(d2.dominated_native_size_bytes, o2.native_size)
AS ref_retained_native
FROM heap_graph_reference r
LEFT JOIN heap_graph_object o2 ON r.owned_id = o2.id
LEFT JOIN heap_graph_object_data od2 ON o2.object_data_id = od2.id
LEFT JOIN heap_graph_class c2 ON o2.type_id = c2.id
LEFT JOIN heap_graph_dominator_tree d2 ON d2.id = o2.id
WHERE r.reference_set_id = ${refSetId}
ORDER BY r.id
`);
let seqIdx = 0;
for (
const oait = oaRes.iter({
fname: STR,
owned_id: NUM_NULL,
ref_cls: STR_NULL,
ref_deob: STR_NULL,
ref_str: STR_NULL,
ref_shallow: NUM_NULL,
ref_native: NUM_NULL,
ref_retained: NUM_NULL,
ref_retained_native: NUM_NULL,
});
oait.valid();
oait.next()
) {
const raw = oait.fname;
let idx: number;
if (raw.startsWith('[') && raw.endsWith(']')) {
idx = parseInt(raw.slice(1, raw.length - 1), 10);
} else {
const parsed = parseInt(raw, 10);
idx = Number.isNaN(parsed) ? seqIdx : parsed;
}
seqIdx++;
let value: PrimOrRef;
if (oait.owned_id === null || oait.owned_id === 0) {
value = {kind: 'prim', v: 'null'};
} else {
const refCls = className(oait.ref_cls, oait.ref_deob);
value = {
kind: 'ref',
id: oait.owned_id,
display: makeDisplay(refCls, oait.owned_id),
str: oait.ref_str,
shallowJava: oait.ref_shallow ?? 0,
shallowNative: oait.ref_native ?? 0,
retainedJava: oait.ref_retained ?? 0,
retainedNative: oait.ref_retained_native ?? 0,
};
}
arrayElems.push({idx, value});
arrayLength = Math.max(arrayLength, idx + 1);
}
}
}
const revRes = await engine.query(`
SELECT ${INSTANCE_COLS}
FROM heap_graph_reference r
JOIN heap_graph_object o ON r.owner_id = o.id
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE r.owned_id = ${id}
ORDER BY (ifnull(d.dominated_size_bytes, 0)
+ ifnull(d.dominated_native_size_bytes, 0)) DESC
`);
const reverseRefs = collectRows(revRes);
const domRes = await engine.query(`
SELECT ${INSTANCE_COLS}
FROM heap_graph_dominator_tree d
JOIN heap_graph_object o ON d.id = o.id
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE d.idom_id = ${id}
ORDER BY (d.dominated_size_bytes + d.dominated_native_size_bytes) DESC
`);
const dominated = collectRows(domRes);
const dominatorPath = await fetchDominatorPath(engine, id);
const shortestPath = await fetchShortestPathFromRoot(engine, id);
const staticFields = isClassObj
? await fetchFieldValues(engine, activeDump, refSetId, fieldSetId)
: [];
let bitmap: InstanceDetail['bitmap'] = null;
if (fullClassName === 'android.graphics.Bitmap') {
bitmap = await extractBitmapPixels(engine, activeDump, fieldSetId);
}
if (bitmap === null) {
const deobName = className(oit.cls, oit.deob);
if (deobName === 'android.graphics.Bitmap') {
bitmap = await extractBitmapPixels(engine, activeDump, fieldSetId);
}
}
const classHierarchy = await getClassHierarchy(engine, typeId);
return {
row,
isClassObj,
isArrayInstance,
isClassInstance: !isClassObj && !isArrayInstance,
classObjRow,
instanceSize: row.shallowJava,
classHierarchy,
staticFields,
instanceFields,
elemTypeName,
arrayLength,
arrayElems,
bitmap,
reverseRefs,
dominated,
dominatorPath,
shortestPath,
};
}
/**
* Superclass chain of `startClassId`, ordered starting-class first. The DFS
* over `id → superclass_id` is a linear chain under single inheritance, so
* we walk it by following each row's `parent_node_id` (the DFS predecessor,
* i.e. the subclass that discovered this ancestor).
*/
export async function getClassHierarchy(
engine: Engine,
startClassId: number,
): Promise<string[]> {
const res = await engine.query(`
INCLUDE PERFETTO MODULE graphs.search;
SELECT
dfs.node_id AS class_id,
dfs.parent_node_id AS child_class_id,
coalesce(c.deobfuscated_name, c.name) AS name
FROM graph_reachable_dfs!(
(SELECT id AS source_node_id, superclass_id AS dest_node_id
FROM heap_graph_class WHERE superclass_id IS NOT NULL),
(SELECT ${startClassId} AS node_id)
) AS dfs
JOIN heap_graph_class c ON c.id = dfs.node_id
`);
type Row = {classId: number; childClassId: number | null; className: string};
const rows: Row[] = [];
for (
const it = res.iter({
class_id: NUM,
child_class_id: NUM_NULL,
name: STR,
});
it.valid();
it.next()
) {
rows.push({
classId: it.class_id,
childClassId: it.child_class_id,
className: it.name,
});
}
const byChildId = new Map<number, Row>();
let start: Row | undefined;
for (const r of rows) {
if (r.classId === startClassId) start = r;
else if (r.childClassId !== null) byChildId.set(r.childClassId, r);
}
const chain: string[] = [];
for (let cur = start; cur !== undefined; cur = byChildId.get(cur.classId)) {
chain.push(cur.className);
}
return chain;
}
/** Transitive subclass names of `rootName` (including the root itself). */
export async function getSubclassNames(
engine: Engine,
activeDump: HeapDump,
rootName: string,
): Promise<string[]> {
const res = await engine.query(`
INCLUDE PERFETTO MODULE graphs.search;
WITH dump_classes AS (
SELECT DISTINCT c.id, c.name, c.deobfuscated_name, c.superclass_id
FROM heap_graph_class c
JOIN heap_graph_object o ON o.type_id = c.id
WHERE ${dumpFilterSql(activeDump, 'o')}
)
SELECT coalesce(c.deobfuscated_name, c.name) AS name
FROM graph_reachable_dfs!(
(SELECT superclass_id AS source_node_id, id AS dest_node_id
FROM dump_classes WHERE superclass_id IS NOT NULL),
(SELECT id AS node_id FROM dump_classes
WHERE coalesce(deobfuscated_name, name) = '${sqlEsc(rootName)}'
LIMIT 1)
) AS dfs
JOIN dump_classes c ON c.id = dfs.node_id
`);
const names: string[] = [];
for (const it = res.iter({name: STR}); it.valid(); it.next()) {
names.push(it.name);
}
return names;
}
export async function getRawArrayBlob(
engine: Engine,
objectId: number,
): Promise<Uint8Array | null> {
const res = await engine.query(`
SELECT __intrinsic_heap_graph_array(od.array_data_id) AS data
FROM heap_graph_object o
JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.id = ${objectId} AND od.array_data_id IS NOT NULL
`);
const it = res.iter({data: BLOB_NULL});
if (it.valid() && it.data !== null) return it.data;
return null;
}
export async function getRawBitmapBlob(
engine: Engine,
activeDump: HeapDump,
objectId: number,
): Promise<{data: Uint8Array; format: string} | null> {
const fieldRes = await engine.query(`
SELECT f.long_value
FROM heap_graph_object o
JOIN heap_graph_object_data od ON o.object_data_id = od.id
JOIN heap_graph_primitive f ON f.field_set_id = od.field_set_id
WHERE o.id = ${objectId}
AND f.field_name GLOB '*mNativePtr'
`);
const fit = fieldRes.iter({long_value: LONG_NULL});
if (!fit.valid() || fit.long_value === null) return null;
const nativePtr = fit.long_value;
const dumpData = await loadBitmapDumpData(engine, activeDump);
if (!dumpData) return null;
const bufferObjId = dumpData.bufferMap.get(nativePtr);
if (bufferObjId === undefined) return null;
const bufRes = await engine.query(`
SELECT __intrinsic_heap_graph_array(od.array_data_id) AS data
FROM heap_graph_object o
JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.id = ${bufferObjId}
`);
const bit = bufRes.iter({data: BLOB_NULL});
if (!bit.valid() || bit.data === null) return null;
const format = DUMP_DATA_FORMAT_NAMES[dumpData.format] ?? 'png';
return {data: bit.data, format};
}
/** Format a single JSON-decoded primitive value for display. */
function formatPrimValue(type: string, v: number | string | boolean): string {
if (type === 'boolean') return Number(v) !== 0 ? 'true' : 'false';
if (type === 'char') {
const c = v as number;
return c >= 32 && c < 127 ? `'${String.fromCharCode(c)}'` : String(c);
}
return String(v);
}
//
// Modern Android (API 26+) stores bitmap pixel data via a static field
// `android.graphics.Bitmap.dumpData` pointing to a `Bitmap$DumpData` instance.
// DumpData contains: format (int: 0=JPEG, 1=PNG, 2-4=WEBP), natives (long[])
// mapping native pointers, and buffers (Object[] of byte[]) with compressed
// image data. Each Bitmap instance has mNativePtr (long) used to index into
// the natives/buffers arrays.
interface BitmapDumpData {
format: number; // 0=JPEG, 1=PNG, 2-4=WEBP
// Map from native pointer (as bigint) to buffer object ID.
bufferMap: Map<bigint, number>;
}
const bitmapDumpDataByDump = new WeakMap<
HeapDump,
Promise<BitmapDumpData | null>
>();
function loadBitmapDumpData(
engine: Engine,
activeDump: HeapDump,
): Promise<BitmapDumpData | null> {
const cached = bitmapDumpDataByDump.get(activeDump);
if (cached !== undefined) return cached;
const promise = computeBitmapDumpData(engine, activeDump);
bitmapDumpDataByDump.set(activeDump, promise);
return promise;
}
async function computeBitmapDumpData(
engine: Engine,
activeDump: HeapDump,
): Promise<BitmapDumpData | null> {
const classObjRes = await engine.query(`
SELECT o.reference_set_id
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
WHERE ${dumpFilterSql(activeDump, 'o')}
AND (c.name LIKE '%Class<android.graphics.Bitmap>'
OR c.deobfuscated_name LIKE '%Class<android.graphics.Bitmap>')
`);
const classIt = classObjRes.iter({reference_set_id: NUM_NULL});
if (!classIt.valid() || classIt.reference_set_id === null) return null;
// Step 2: Follow dumpData reference from the class object.
const ddRes = await engine.query(`
SELECT r.owned_id AS dump_data_id
FROM heap_graph_reference r
WHERE r.reference_set_id = ${classIt.reference_set_id}
AND r.field_name GLOB '*dumpData'
`);
const ddIt = ddRes.iter({dump_data_id: NUM_NULL});
if (!ddIt.valid() || ddIt.dump_data_id === null) return null;
const dumpDataId = ddIt.dump_data_id;
// Step 3: Read format from DumpData's fields.
const fmtRes = await engine.query(`
SELECT int_value FROM heap_graph_primitive
WHERE field_set_id = (
SELECT od.field_set_id FROM heap_graph_object o
JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.id = ${dumpDataId}
)
AND field_name GLOB '*format'
`);
const fmtIt = fmtRes.iter({int_value: NUM_NULL});
const format = fmtIt.valid() ? fmtIt.int_value ?? 1 : 1;
// Step 4: Get DumpData's references — natives (long[]) and buffers (Object[]).
const refsRes = await engine.query(`
SELECT r.field_name, r.owned_id
FROM heap_graph_reference r
WHERE r.reference_set_id = (SELECT reference_set_id FROM heap_graph_object WHERE id = ${dumpDataId})
AND (r.field_name GLOB '*natives' OR r.field_name GLOB '*buffers')
`);
let nativesObjId: number | null = null;
let buffersObjId: number | null = null;
for (
const it = refsRes.iter({field_name: STR, owned_id: NUM});
it.valid();
it.next()
) {
if (it.field_name.endsWith('natives')) nativesObjId = it.owned_id;
if (it.field_name.endsWith('buffers')) buffersObjId = it.owned_id;
}
if (nativesObjId === null || buffersObjId === null) return null;
// Step 5: Decode natives long[] via JSON.
const nativesRes = await engine.query(`
SELECT __intrinsic_heap_graph_array_json(od.array_data_id) AS data
FROM heap_graph_object o
JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.id = ${nativesObjId}
`);
const nativesIt = nativesRes.iter({data: STR_NULL});
if (!nativesIt.valid() || nativesIt.data === null) return null;
// Native pointers need BigInt for 64-bit precision in Map lookups.
const nativesPtrs = (JSON.parse(nativesIt.data) as string[]).map(BigInt);
// Step 6: Get buffers Object[] references — array index → byte[] object ID.
const bufsRes = await engine.query(`
SELECT r.field_name, r.owned_id
FROM heap_graph_reference r
WHERE r.reference_set_id = (SELECT reference_set_id FROM heap_graph_object WHERE id = ${buffersObjId})
ORDER BY CAST(SUBSTR(r.field_name, 2) AS INTEGER)
`);
const bufferObjIds: number[] = [];
for (
const it = bufsRes.iter({field_name: STR, owned_id: NUM});
it.valid();
it.next()
) {
// field_name is "[0]", "[1]", etc.
const idx = parseInt(it.field_name.slice(1, -1), 10);
bufferObjIds[idx] = it.owned_id;
}
// Step 7: Build nativePtr → buffer object ID map.
const bufferMap = new Map<bigint, number>();
const count = Math.min(nativesPtrs.length, bufferObjIds.length);
for (let i = 0; i < count; i++) {
if (bufferObjIds[i] !== undefined && bufferObjIds[i] !== 0) {
bufferMap.set(nativesPtrs[i], bufferObjIds[i]);
}
}
return {format, bufferMap};
}
const DUMP_DATA_FORMAT_NAMES: Record<number, string> = {
0: 'jpeg',
1: 'png',
2: 'webp',
3: 'webp',
4: 'webp',
};
async function extractBitmapPixels(
engine: Engine,
activeDump: HeapDump,
fieldSetId: number | null,
): Promise<InstanceDetail['bitmap']> {
if (fieldSetId === null) return null;
const dimRes = await engine.query(`
SELECT field_name, int_value, long_value
FROM heap_graph_primitive
WHERE field_set_id = ${fieldSetId}
AND (field_name GLOB '*mWidth' OR field_name GLOB '*mHeight'
OR field_name GLOB '*mNativePtr')
`);
let width = 0;
let height = 0;
let nativePtr = 0n;
for (
const it = dimRes.iter({
field_name: STR,
int_value: NUM_NULL,
long_value: LONG_NULL,
});
it.valid();
it.next()
) {
if (it.field_name.endsWith('mWidth')) width = it.int_value ?? 0;
if (it.field_name.endsWith('mHeight')) height = it.int_value ?? 0;
if (it.field_name.endsWith('mNativePtr')) {
nativePtr = it.long_value ?? 0n;
}
}
if (width <= 0 || height <= 0 || nativePtr === 0n) return null;
const dumpData = await loadBitmapDumpData(engine, activeDump);
if (dumpData === null) return null;
const bufferObjId = dumpData.bufferMap.get(nativePtr);
if (bufferObjId === undefined) return null;
const bufRes = await engine.query(`
SELECT __intrinsic_heap_graph_array(od.array_data_id) AS data
FROM heap_graph_object o
JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.id = ${bufferObjId}
`);
const bufIt = bufRes.iter({data: BLOB_NULL});
if (!bufIt.valid() || bufIt.data === null) return null;
const format = DUMP_DATA_FORMAT_NAMES[dumpData.format] ?? 'png';
return {width, height, format, data: bufIt.data};
}
export async function getBitmapPixels(
engine: Engine,
activeDump: HeapDump,
objectId: number,
): Promise<InstanceDetail['bitmap']> {
const res = await engine.query(`
SELECT od.field_set_id
FROM heap_graph_object o
JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.id = ${objectId}
`);
const row = res.maybeFirstRow({field_set_id: NUM_NULL});
return extractBitmapPixels(engine, activeDump, row?.field_set_id ?? null);
}
export async function search(
engine: Engine,
activeDump: HeapDump,
query: string,
): Promise<InstanceRow[]> {
await requireDominatorTree(engine);
if (query.startsWith('0x') || query.startsWith('0X')) {
const numId = parseInt(query, 16);
if (!isNaN(numId)) {
const res = await engine.query(`
SELECT ${INSTANCE_COLS}
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.id = ${numId}
`);
return collectRows(res);
}
}
const escaped = query.replace(/[%_\\]/g, '\\$&').replace(/'/g, "''");
const res = await engine.query(`
SELECT ${INSTANCE_COLS}
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.reachable != 0
AND ${dumpFilterSql(activeDump, 'o')}
AND (c.name LIKE '%${escaped}%' ESCAPE '\\'
OR c.deobfuscated_name LIKE '%${escaped}%' ESCAPE '\\')
ORDER BY (ifnull(d.dominated_size_bytes, 0)
+ ifnull(d.dominated_native_size_bytes, 0)) DESC
`);
return collectRows(res);
}
export async function getObjects(
engine: Engine,
activeDump: HeapDump,
cls: string,
heap: string | null,
): Promise<InstanceRow[]> {
await requireDominatorTree(engine);
const escaped = sqlEsc(cls);
const hf = heapFilter(heap);
const res = await engine.query(`
SELECT ${INSTANCE_COLS}
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
WHERE o.reachable != 0
AND ${dumpFilterSql(activeDump, 'o')}
AND (c.name = '${escaped}' OR c.deobfuscated_name = '${escaped}')
${hf}
ORDER BY o.self_size + o.native_size DESC
`);
return collectRows(res);
}
export async function getObjectsByFlamegraphSelection(
engine: Engine,
pathHashes: string,
isDominator: boolean,
): Promise<InstanceRow[]> {
await requireDominatorTree(engine);
// Query objects matching the given path hashes from the flamegraph.
// Path hashes are comma-separated integers identifying class tree nodes.
const hashTable = isDominator
? '_heap_graph_dominator_path_hashes'
: '_heap_graph_path_hashes';
const values = pathHashes
.split(',')
.map((v) => `(${v.trim()})`)
.join(', ');
const res = await engine.query(`
WITH _hde_sel(path_hash) AS (VALUES ${values})
SELECT ${INSTANCE_COLS}
FROM _hde_sel f
JOIN ${hashTable} h ON h.path_hash = f.path_hash
JOIN heap_graph_object o ON o.id = h.id
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
ORDER BY o.self_size + o.native_size DESC
`);
return collectRows(res);
}
export async function getStringList(
engine: Engine,
activeDump: HeapDump,
): Promise<StringListRow[]> {
await requireDominatorTree(engine);
const res = await engine.query(`
SELECT
o.id,
od.value_string AS value,
o.self_size,
o.native_size,
o.heap_type,
c.name AS cls,
c.deobfuscated_name AS deob,
d.dominated_size_bytes AS dominated_size,
d.dominated_native_size_bytes AS dominated_native
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
WHERE o.reachable != 0
AND ${dumpFilterSql(activeDump, 'o')}
AND od.value_string IS NOT NULL
AND (c.name = 'java.lang.String'
OR c.deobfuscated_name = 'java.lang.String')
ORDER BY (ifnull(d.dominated_size_bytes, 0)
+ ifnull(d.dominated_native_size_bytes, 0)) DESC
`);
const rows: StringListRow[] = [];
for (
const it = res.iter({
id: NUM,
value: STR,
self_size: NUM,
native_size: NUM,
heap_type: STR_NULL,
cls: STR,
deob: STR_NULL,
dominated_size: NUM_NULL,
dominated_native: NUM_NULL,
});
it.valid();
it.next()
) {
const fullCls = className(it.cls, it.deob);
rows.push({
id: it.id,
value: it.value,
length: it.value.length,
retainedSize: it.dominated_size ?? it.self_size,
reachableSize: null,
reachableNativeSize: null,
reachableCount: null,
shallowSize: it.self_size,
nativeSize: it.native_size,
heap: it.heap_type ?? 'default',
className: fullCls,
display: makeDisplay(fullCls, it.id),
});
}
return rows;
}
export async function getBitmapList(
engine: Engine,
activeDump: HeapDump,
): Promise<BitmapListRow[]> {
await requireDominatorTree(engine);
const dumpData = await loadBitmapDumpData(engine, activeDump);
await engine.query(
`INCLUDE PERFETTO MODULE android.memory.heap_graph.bitmap;`,
);
const res = await engine.query(`
SELECT
o.id,
c.name AS cls,
c.deobfuscated_name AS deob,
o.self_size,
o.native_size,
o.heap_type,
o.root_type,
d.dominated_size_bytes AS dominated_size,
d.dominated_native_size_bytes AS dominated_native,
d.dominated_obj_count,
od.value_string,
c.kind AS class_kind,
cast_int!(b.width) AS width,
cast_int!(b.height) AS height,
cast_int!(b.density) AS density,
MAX(CASE WHEN f.field_name GLOB '*mNativePtr' THEN f.long_value END) AS native_ptr,
b.bitmap_id,
b.bitmap_storage_type,
b.source_id,
cast_int!(b.source_pid) AS source_pid,
b.source_storage_type,
b.source_process_name
FROM heap_graph_object o
JOIN heap_graph_class c ON o.type_id = c.id
LEFT JOIN heap_graph_object_data od ON o.object_data_id = od.id
LEFT JOIN heap_graph_dominator_tree d ON d.id = o.id
LEFT JOIN heap_graph_primitive f ON f.field_set_id = od.field_set_id
LEFT JOIN heap_graph_bitmaps b ON b.object_id = o.id
WHERE o.reachable != 0
AND ${dumpFilterSql(activeDump, 'o')}
AND (c.name = 'android.graphics.Bitmap'
OR c.deobfuscated_name = 'android.graphics.Bitmap')
GROUP BY o.id
ORDER BY (ifnull(d.dominated_size_bytes, 0)
+ ifnull(d.dominated_native_size_bytes, 0)) DESC
`);
// Collect rows and native pointers for hash lookup.
const rawRows: Array<{
row: InstanceRow;
w: number;
h: number;
hasPixelData: boolean;
density: number;
nativePtr: bigint | null;
bitmapId: bigint | null;
storageType: string | null;
sourceId: bigint | null;
sourcePid: number | null;
sourceStorageType: string | null;
sourceProcessName: string | null;
}> = [];
const hashInputs: Array<{objectId: number; nativePtr: bigint}> = [];
for (
const it = res.iter({
id: NUM,
cls: STR,
deob: STR_NULL,
self_size: NUM,
native_size: NUM,
heap_type: STR_NULL,
root_type: STR_NULL,
dominated_size: NUM_NULL,
dominated_native: NUM_NULL,
dominated_obj_count: NUM_NULL,
value_string: STR_NULL,
class_kind: STR,
width: NUM_NULL,
height: NUM_NULL,
density: NUM_NULL,
native_ptr: LONG_NULL,
bitmap_id: LONG_NULL,
bitmap_storage_type: STR_NULL,
source_id: LONG_NULL,
source_pid: NUM_NULL,
source_storage_type: STR_NULL,
source_process_name: STR_NULL,
});
it.valid();
it.next()
) {
const w = it.width ?? 0;
const h = it.height ?? 0;
let hasPixelData = false;
if (dumpData !== null && it.native_ptr !== null && w > 0 && h > 0) {
hasPixelData = dumpData.bufferMap.has(it.native_ptr);
if (hasPixelData) {
hashInputs.push({objectId: it.id, nativePtr: it.native_ptr});
}
}
rawRows.push({
row: rowFromIter(it),
w,
h,
hasPixelData,
density: it.density ?? 0,
nativePtr: it.native_ptr,
bitmapId: it.bitmap_id,
storageType: it.bitmap_storage_type,
sourceId: it.source_id,
sourcePid: it.source_pid,
sourceStorageType: it.source_storage_type,
sourceProcessName: it.source_process_name,
});
}
// Look up pre-computed content hashes for bitmaps with pixel data.
const hashes =
hashInputs.length > 0
? await batchBitmapBufferHashes(engine, activeDump, hashInputs)
: new Map<number, string>();
const rows: BitmapListRow[] = rawRows.map((r) => ({
row: r.row,
width: r.w,
height: r.h,
pixelCount: r.w * r.h,
hasPixelData: r.hasPixelData,
density: r.density,
bufferHash: hashes.get(r.row.id) ?? null,
storageType: r.storageType,
bitmapId: r.bitmapId,
sourceId: r.sourceId,
sourcePid: r.sourcePid,
sourceStorageType: r.sourceStorageType,
sourceProcessName: r.sourceProcessName,
}));
return rows;
}
function primFieldValue(it: {
field_type: string;
bool_value: number | null;
byte_value: number | null;
char_value: number | null;
short_value: number | null;
int_value: number | null;
long_value: bigint | null;
float_value: number | null;
double_value: number | null;
}): PrimOrRef {
switch (it.field_type) {
case 'boolean':
return {
kind: 'prim',
v: it.bool_value !== null && it.bool_value !== 0 ? 'true' : 'false',
};
case 'byte':
return {kind: 'prim', v: String(it.byte_value ?? 0)};
case 'char': {
const code = it.char_value ?? 0;
const ch =
code >= 32 && code < 127
? `'${String.fromCharCode(code)}'`
: String(code);
return {kind: 'prim', v: ch};
}
case 'short':
return {kind: 'prim', v: String(it.short_value ?? 0)};
case 'int':
return {kind: 'prim', v: String(it.int_value ?? 0)};
case 'long':
return {kind: 'prim', v: String(it.long_value ?? 0)};
case 'float':
return {kind: 'prim', v: String(it.float_value ?? 0)};
case 'double':
return {kind: 'prim', v: String(it.double_value ?? 0)};
default:
return {kind: 'prim', v: '???'};
}
}
//
// The _heap_graph_object_tree_aggregation table computes cumulative reachable
// sizes via a BFS tree. The table materialisation is expensive on first access,
// so we load it asynchronously and fill in reachable columns after the initial
// render.
/** Fetch reachable (cumulative) sizes for a set of object IDs. */
async function getReachableSizes(
engine: Engine,
ids: number[],
): Promise<Map<number, {size: number; native: number; count: number}>> {
await engine.query(
`INCLUDE PERFETTO MODULE android.memory.heap_graph.object_tree`,
);
if (ids.length === 0) return new Map();
const res = await engine.query(`
SELECT id,
cumulative_size AS size,
cumulative_native_size AS native_size,
cumulative_count AS count
FROM _heap_graph_object_tree_aggregation
WHERE id IN (${ids.join(',')})
`);
const map = new Map<number, {size: number; native: number; count: number}>();
for (
const it = res.iter({id: NUM, size: NUM, native_size: NUM, count: NUM});
it.valid();
it.next()
) {
map.set(it.id, {size: it.size, native: it.native_size, count: it.count});
}
return map;
}
/**
* Enrich InstanceRow[] with reachable sizes. Call after initial data load;
* the caller should trigger a re-render when the returned promise resolves.
*/
export async function enrichWithReachable(
engine: Engine,
rows: InstanceRow[],
): Promise<void> {
const unenriched = rows.filter((r) => r.reachableSize === null);
if (unenriched.length === 0) return;
const ids = unenriched.map((r) => r.id);
const map = await getReachableSizes(engine, ids);
for (const row of unenriched) {
const s = map.get(row.id);
row.reachableSize = s?.size ?? 0;
row.reachableNative = s?.native ?? 0;
row.reachableCount = s?.count ?? 0;
}
}
/**
* Enrich PrimOrRef fields with reachable sizes (for ref-kind fields only).
*/
export async function enrichFieldsWithReachable(
engine: Engine,
fields: {name: string; typeName: string; value: PrimOrRef}[],
): Promise<void> {
const ids: number[] = [];
for (const f of fields) {
if (f.value.kind === 'ref' && f.value.reachableJava === undefined) {
ids.push(f.value.id);
}
}
if (ids.length === 0) return;
const map = await getReachableSizes(engine, ids);
for (const f of fields) {
if (f.value.kind === 'ref' && f.value.reachableJava === undefined) {
const s = map.get(f.value.id);
f.value.reachableJava = s?.size ?? 0;
f.value.reachableNative = s?.native ?? 0;
f.value.reachableCount = s?.count ?? 0;
}
}
}
/**
* Enrich array elements with reachable sizes (for ref-kind values only).
*/
export async function enrichArrayElemsWithReachable(
engine: Engine,
elems: {idx: number; value: PrimOrRef}[],
): Promise<void> {
const ids: number[] = [];
for (const e of elems) {
if (e.value.kind === 'ref' && e.value.reachableJava === undefined) {
ids.push(e.value.id);
}
}
if (ids.length === 0) return;
const map = await getReachableSizes(engine, ids);
for (const e of elems) {
if (e.value.kind === 'ref' && e.value.reachableJava === undefined) {
const s = map.get(e.value.id);
e.value.reachableJava = s?.size ?? 0;
e.value.reachableNative = s?.native ?? 0;
e.value.reachableCount = s?.count ?? 0;
}
}
}