| // 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 {SqlValue} from '../../trace_processor/query_result'; |
| import {NUM} from '../../trace_processor/query_result'; |
| import type {CellRenderResult} from '../../components/widgets/datagrid/datagrid_schema'; |
| import type {Filter} from '../../components/widgets/datagrid/model'; |
| import {filterToSql} from '../../components/widgets/datagrid/sql_utils'; |
| import type {Engine} from '../../trace_processor/engine'; |
| import type {InstanceRow, PathEntry, PrimOrRef} from './types'; |
| import {fmtSize} from './format'; |
| import type {NavState} from './nav_state'; |
| import {Tooltip} from '../../widgets/tooltip'; |
| import {Icon} from '../../widgets/icon'; |
| |
| export type NavFn = ( |
| view: NavState['view'], |
| params?: Record<string, unknown>, |
| ) => void; |
| |
| export type ObjLinkRef = { |
| id: number; |
| display: string; |
| str?: string | null; |
| }; |
| |
| interface InstanceLinkAttrs { |
| readonly row: InstanceRow | ObjLinkRef | null; |
| readonly navigate: NavFn; |
| } |
| export function InstanceLink(): m.Component<InstanceLinkAttrs> { |
| return { |
| view(vnode) { |
| const {row, navigate} = vnode.attrs; |
| if (!row || row.id === 0) { |
| return m('span', {class: 'pf-hde-badge-referent'}, 'ROOT'); |
| } |
| const full = 'className' in row ? (row as InstanceRow) : null; |
| return m( |
| 'span', |
| full && |
| full.reachabilityName !== 'unreachable' && |
| full.reachabilityName !== 'strong' |
| ? m( |
| 'span', |
| {class: 'pf-hde-badge-reachability'}, |
| full.reachabilityName, |
| ) |
| : null, |
| full?.isRoot ? m('span', {class: 'pf-hde-badge-root'}, 'root') : null, |
| m( |
| 'button', |
| { |
| class: 'pf-hde-link', |
| onclick: () => navigate('object', {id: row.id, label: row.display}), |
| }, |
| row.display, |
| ), |
| row.str != null |
| ? m( |
| 'span', |
| { |
| class: 'pf-hde-badge-string', |
| title: row.str.length > 80 ? row.str : undefined, |
| }, |
| '"' + |
| (row.str.length > 80 |
| ? row.str.slice(0, 80) + '\u2026' |
| : row.str) + |
| '"', |
| ) |
| : null, |
| full?.referent |
| ? m( |
| 'span', |
| {class: 'pf-hde-badge-referent'}, |
| ' for ', |
| m(InstanceLink, { |
| row: full.referent, |
| navigate, |
| }), |
| ) |
| : null, |
| ); |
| }, |
| }; |
| } |
| |
| interface SectionAttrs { |
| readonly title: string; |
| readonly defaultOpen?: boolean; |
| // Optional inline actions rendered to the right of the title. Action |
| // clicks are isolated from the section toggle. |
| readonly actions?: m.Children; |
| } |
| export function Section(): m.Component<SectionAttrs> { |
| let open = true; |
| return { |
| oninit(vnode) { |
| open = vnode.attrs.defaultOpen !== false; |
| }, |
| view(vnode) { |
| return m( |
| 'div', |
| {class: 'pf-hde-section'}, |
| m( |
| 'div', |
| {class: 'pf-hde-section__header'}, |
| m( |
| 'button', |
| { |
| 'class': 'pf-hde-section__toggle', |
| 'onclick': () => { |
| open = !open; |
| }, |
| 'aria-expanded': open, |
| }, |
| m('span', {class: 'pf-hde-section__title'}, vnode.attrs.title), |
| m( |
| 'svg', |
| { |
| class: `pf-hde-section__chevron${open ? ' pf-hde-section__chevron--open' : ''}`, |
| viewBox: '0 0 20 20', |
| fill: 'currentColor', |
| }, |
| m('path', { |
| 'fill-rule': 'evenodd', |
| 'd': 'M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z', |
| 'clip-rule': 'evenodd', |
| }), |
| ), |
| ), |
| vnode.attrs.actions !== undefined |
| ? m( |
| 'div', |
| { |
| class: 'pf-hde-section__actions', |
| onclick: (e: Event) => e.stopPropagation(), |
| }, |
| vnode.attrs.actions, |
| ) |
| : null, |
| ), |
| open ? m('div', {class: 'pf-hde-section__body'}, vnode.children) : null, |
| ); |
| }, |
| }; |
| } |
| |
| /** Renders a size value as a right-aligned formatted byte string. */ |
| export function sizeRenderer(value: SqlValue): CellRenderResult { |
| return { |
| content: m('span', {class: 'pf-hde-mono'}, fmtSize(Number(value ?? 0))), |
| align: 'right', |
| }; |
| } |
| |
| /** Renders a numeric count value as a right-aligned locale string. */ |
| export function countRenderer(value: SqlValue): CellRenderResult { |
| return { |
| content: m( |
| 'span', |
| {class: 'pf-hde-mono'}, |
| Number(value ?? 0).toLocaleString(), |
| ), |
| align: 'right', |
| }; |
| } |
| |
| /** |
| * Returns the short (unqualified) class name, preserving generics and array |
| * brackets. Each fully-qualified segment between `<>`, `,` delimiters is |
| * shortened independently so `java.util.Map<java.lang.String, int[]>` becomes |
| * `Map<String, int[]>`. |
| */ |
| export function shortClassName(full: string): string { |
| const bracket = full.indexOf('['); |
| const base = bracket >= 0 ? full.slice(0, bracket) : full; |
| const suffix = bracket >= 0 ? full.slice(bracket) : ''; |
| // Shorten each qualified-name token; delimiters (<>,) are preserved. |
| const short = base.replace(/[\w$.]+/g, (tok) => { |
| const dot = tok.lastIndexOf('.'); |
| return dot >= 0 ? tok.slice(dot + 1) : tok; |
| }); |
| return short + suffix; |
| } |
| |
| // Hover-help text for size/count columns, shared across HDE views. |
| export const COL_INFO = { |
| shallow: |
| 'Java memory used by this object alone, excluding referenced objects.', |
| shallowNative: |
| 'Native memory attributed to this object alone (e.g. a Bitmap pixel ' + |
| 'buffer). Zero for most plain Java objects.', |
| retained: |
| 'Java size of every object exclusively held by this one (dominator ' + |
| 'subtree, self inclusive). If something is reachable from multiple ' + |
| 'paths, it is not retained here — it is retained higher up at the ' + |
| 'common ancestor.', |
| retainedNative: |
| 'Native size of every object exclusively held by this one (dominator ' + |
| 'subtree, self inclusive). Often zero in multi-rooted graphs where ' + |
| 'native-bearing objects (e.g. Bitmaps) are reachable via multiple ' + |
| 'paths — try Reachable Native if you expected a non-zero value.', |
| retainedCount: |
| 'Number of objects exclusively held by this one (dominator subtree, ' + |
| 'self inclusive).', |
| reachable: |
| 'Java size of every object reachable from this one along the heap ' + |
| "graph's BFS shortest-path tree. Includes objects also reachable via " + |
| 'other paths. Matches the flamegraph "Object Size" cumulative.', |
| reachableNative: |
| 'Native size of every object reachable from this one (BFS tree). ' + |
| 'Includes objects also reachable via other paths.', |
| reachableCount: 'Count of objects reachable from this one (BFS tree).', |
| bitmapStorage: |
| 'Pixel-storage backing decoded from Bitmap.mId. ' + |
| "'heap' = malloc'd in this process — each duplicate is real RAM cost. " + |
| "'ashmem' = shared kernel memory — duplicates with the same source_id " + |
| 'are kernel-shared (PSS-attributed), no real RAM cost. ' + |
| "'hardware' = AHardwareBuffer — duplicates may share GPU memory if " + |
| 'they wrap the same buffer handle.', |
| bitmapId: |
| 'Encoded Bitmap.mId — process-monotonic instance counter (' + |
| 'pid·10⁷ + storage_type·10⁶ + counter). Stable identifier within one ' + |
| "process; it's never a dedup key (every Bitmap allocation gets a " + |
| 'fresh value).', |
| bitmapSource: |
| 'Parcel sender derived from Bitmap.mSourceId. Blank when this Bitmap ' + |
| "was allocated locally. When present, it's the sender's process name " + |
| 'and pid at writeToParcel time. Multiple Bitmaps with the same ' + |
| 'source_id and same content_hash are kernel-shared, NOT duplicated.', |
| } as const; |
| |
| // Label + info icon for DataGrid column titles. |
| export function colHeader(label: string, info: m.Children): m.Children { |
| return m( |
| 'span', |
| {class: 'pf-hde-col-header'}, |
| label, |
| m( |
| Tooltip, |
| {trigger: m(Icon, {className: 'pf-hde-col-header__info', icon: 'info'})}, |
| info, |
| ), |
| ); |
| } |
| |
| /** SQL preamble that includes dominator tree and object tree modules. */ |
| export const SQL_PREAMBLE = |
| 'INCLUDE PERFETTO MODULE android.memory.heap_graph.dominator_tree;\n' + |
| 'INCLUDE PERFETTO MODULE android.memory.heap_graph.object_tree'; |
| |
| /** |
| * Tracks total and filtered row counts for a SQL-backed DataGrid view. |
| * Call `init()` in oninit, pass `onFiltersChanged` to DataGrid, and read |
| * `heading()` for the formatted title. |
| */ |
| export class RowCounter { |
| total: number | null = null; |
| filtered: number | null = null; |
| |
| private engine: Engine | null = null; |
| private baseQuery = ''; |
| private preamble = ''; |
| private currentFilters: readonly Filter[] = []; |
| |
| init(engine: Engine, query: string, preamble = '') { |
| this.engine = engine; |
| this.baseQuery = query; |
| this.preamble = preamble; |
| this.runCount(); |
| } |
| |
| /** Format a heading like "Objects (1,234)" or "Objects (42 / 1,234)". */ |
| heading(label: string): string { |
| if (this.total === null) return label; |
| if ( |
| this.filtered !== null && |
| this.currentFilters.length > 0 && |
| this.filtered !== this.total |
| ) { |
| return `${label} (${this.filtered.toLocaleString()} / ${this.total.toLocaleString()})`; |
| } |
| return `${label} (${this.total.toLocaleString()})`; |
| } |
| |
| /** Pass this as the DataGrid `onFiltersChanged` callback. */ |
| readonly onFiltersChanged = (filters: readonly Filter[]) => { |
| this.currentFilters = filters; |
| this.runFilteredCount(); |
| }; |
| |
| private runCount() { |
| if (!this.engine) return; |
| const prefix = this.preamble ? `${this.preamble};\n` : ''; |
| this.engine |
| .query(`${prefix}SELECT COUNT(*) AS cnt FROM (${this.baseQuery})`) |
| .then((r) => { |
| this.total = r.firstRow({cnt: NUM}).cnt; |
| m.redraw(); |
| }) |
| .catch(console.error); |
| } |
| |
| private runFilteredCount() { |
| if (!this.engine || this.currentFilters.length === 0) { |
| this.filtered = null; |
| m.redraw(); |
| return; |
| } |
| const where = this.currentFilters |
| .map((f) => filterToSql(f, f.field)) |
| .join(' AND '); |
| const prefix = this.preamble ? `${this.preamble};\n` : ''; |
| this.engine |
| .query( |
| `${prefix}SELECT COUNT(*) AS cnt FROM (${this.baseQuery}) WHERE ${where}`, |
| ) |
| .then((r) => { |
| this.filtered = r.firstRow({cnt: NUM}).cnt; |
| m.redraw(); |
| }) |
| .catch(console.error); |
| } |
| } |
| |
| interface PrimOrRefCellAttrs { |
| readonly v: PrimOrRef; |
| readonly navigate: NavFn; |
| } |
| export function PrimOrRefCell(): m.Component<PrimOrRefCellAttrs> { |
| return { |
| view(vnode) { |
| const {v, navigate} = vnode.attrs; |
| if (v.kind === 'ref') { |
| return m(InstanceLink, { |
| row: {id: v.id, display: v.display, str: v.str}, |
| navigate, |
| }); |
| } |
| return m('span', {class: 'pf-hde-mono'}, v.v); |
| }, |
| }; |
| } |
| |
| interface BitmapImageAttrs { |
| readonly width: number; |
| readonly height: number; |
| readonly format: string; |
| readonly data: Uint8Array; |
| } |
| |
| export function BitmapImage(): m.Component<BitmapImageAttrs> { |
| let blobUrl: string | null = null; |
| |
| return { |
| oncreate(vnode) { |
| const {width, height, format, data} = vnode.attrs; |
| if (format === 'rgba') { |
| const canvas = vnode.dom as HTMLCanvasElement; |
| canvas.width = width; |
| canvas.height = height; |
| const ctx = canvas.getContext('2d'); |
| if (!ctx) return; |
| const clamped = new Uint8ClampedArray(data.length); |
| clamped.set(data); |
| ctx.putImageData(new ImageData(clamped, width, height), 0, 0); |
| return; |
| } |
| const mimeMap: Record<string, string> = { |
| png: 'image/png', |
| jpeg: 'image/jpeg', |
| webp: 'image/webp', |
| }; |
| const copy = new Uint8Array(data.length); |
| copy.set(data); |
| const blob = new Blob([copy], { |
| type: mimeMap[format] ?? 'image/png', |
| }); |
| blobUrl = URL.createObjectURL(blob); |
| m.redraw(); |
| }, |
| onremove() { |
| if (blobUrl) { |
| URL.revokeObjectURL(blobUrl); |
| blobUrl = null; |
| } |
| }, |
| view(vnode) { |
| const {format} = vnode.attrs; |
| if (format === 'rgba') { |
| return m('canvas', {class: 'pf-hde-bitmap-image'}); |
| } |
| // Always render the img element so oncreate fires and creates the blob URL. |
| // Before the blob URL is ready, src is empty (blank image). |
| return m('img', {src: blobUrl ?? '', class: 'pf-hde-bitmap-image'}); |
| }, |
| }; |
| } |
| |
| /** Renders a single dominator-tree path as an indented arrow chain. */ |
| export function renderPath(path: PathEntry[], navigate: NavFn): m.Children { |
| return m( |
| 'div', |
| {class: 'pf-hde-view-stack--tight'}, |
| path.map((pe, i) => |
| m( |
| 'div', |
| { |
| key: i, |
| class: `pf-hde-path-entry${pe.isDominator ? ' pf-hde-semibold' : ''}`, |
| style: {'--pf-hde-depth': String(i)}, |
| }, |
| [ |
| m('span', {class: 'pf-hde-path-arrow'}, i === 0 ? '' : '\u2192'), |
| m(InstanceLink, {row: pe.row, navigate}), |
| pe.field ? m('span', {class: 'pf-hde-path-field'}, pe.field) : null, |
| ], |
| ), |
| ), |
| ); |
| } |