// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
'use strict';
/**
* @fileoverview
* Methods for manipulating the state and the DOM of the page
*/
/**
* @typedef {string|number|boolean} StateValue
*/
/**
* @typedef {Object} HasValue
* @property {StateValue} value - Readable and writable value.
*/
/**
* Encapsulation of an UI state, supporting observers on setting values.
*/
class UiState {
/** @param {!StateValue} defaultValue */
constructor(defaultValue) {
/** @protected {!StateValue} */
this.defaultValue = defaultValue;
/** @protected {!StateValue} */
this.value = this.defaultValue;
/** @private {!Array<!function(): *>} */
this.observers = [];
}
/**
* @param {!function(): *} observer
* @public
*/
addObserver(observer) {
this.observers.push(observer);
}
/** @protected */
notifyObservers() {
for (const observer of this.observers) {
observer();
}
}
/**
* @return {!StateValue}
* @public
*/
get() {
return this.value;
}
/**
* @param {!StateValue} v
* @public
*/
set(v) {
this.value = v;
this.notifyObservers();
}
}
/**
* UiState supporting reads from / writes to a provided query param.
*/
class QueryParamUiState extends UiState {
/**
* @param {string} name
* @param {!StateValue} defaultValue
* @param {?function(string): StateValue} parser
* @param {boolean} isHash
*/
constructor(name, defaultValue, parser, isHash) {
super(defaultValue);
/** @public @const {string} */
this.name = name;
/** @private @const {?function(string): StateValue} null = identity. */
this.parser = parser;
/** @public @const {boolean} */
this.isHash = isHash;
/** @public {string} */
this.hidden = false;
}
/**
* @param {!URLSearchParams} params
* @public
*/
readFromSearchParams(params) {
if (params.has(this.name)) {
const s = params.get(this.name);
this.value = this.parser ? this.parser(s) : s;
} else {
this.value = this.defaultValue;
}
this.notifyObservers();
}
/**
* @param {!URLSearchParams} params
* @public
*/
writeToSearchParams(params) {
if (this.hidden || this.value === this.defaultValue) {
params.delete(this.name);
} else {
const s = (this.value === true) ? 'on' : this.value.toString();
params.set(this.name, s);
}
}
}
/**
* QueryParamUiState that syncs with UI elements.
*/
class ElementUiState extends QueryParamUiState {
/**
* @param {string} name
* @param {!HasValue} elt
* @param {boolean} isHash
*/
constructor(name, elt, isHash) {
let parser = null;
let readElt = () => elt.value;
let writeElt = (v) => {
elt.value = v;
};
// Define Element specific adapters for data access to concentrate the mess,
// and reduce boilerplate from defining stateful adapter classes.
if (elt instanceof HTMLInputElement) {
const input = /** @type {HTMLInputElement} */ (elt);
if (input.type === 'number') {
parser = (s) => {
const ret = parseInt(s, 10);
return isNaN(ret) ? this.defaultValue : ret;
};
} else if (input.type === 'checkbox') {
parser = (s) => s === 'on' || s === 'true' || s === '1';
readElt = () => elt.checked;
writeElt = (v) => {
elt.checked = v;
};
}
} else if (elt instanceof HTMLSelectElement) {
const sel = /** @type {!HTMLSelectElement} */ (elt);
const values =
new Set(Array.from(sel.querySelectorAll('option'), e => e.value));
parser = (s) => values.has(s) ? s : this.defaultValue;
} else if (elt instanceof RadioNodeList) {
const inputs = Array.from(
/** @type {!RadioNodeList} */ (elt),
e => /** @type {HTMLInputElement} */ (e));
assert(inputs.length > 0);
if (inputs[0].type === 'radio') {
const values = new Set(Array.from(inputs, e => e.value));
parser = (s) => values.has(s) ? s : this.defaultValue;
} else if (inputs[0].type === 'checkbox') {
readElt = () => {
return Array.from(inputs, e => e.checked ? e.value : '').join('');
};
writeElt = (s) => {
const values = new Set(Array.from(s));
for (const e of inputs) {
e.checked = values.has(e.value);
}
};
} else {
throw new Error(`Unknown RadioNodeList type: ${inputs[0].type}.`);
}
} else {
throw new Error('Unknown element type.');
}
super(name, readElt(), parser, isHash);
/** @private @const {function(): StateValue} */
this.readElt = readElt;
/** @private @const {function(): StateValue} */
this.writeElt = writeElt;
}
/**
* @param {!StateValue} v
* @public @override
*/
set(v) {
super.set(v);
this.writeElt(/** @type {StateValue} */ (this.value));
}
/**
* @param {!URLSearchParams} params
* @public @override
*/
readFromSearchParams(params) {
super.readFromSearchParams(params);
this.writeElt(/** @type {StateValue} */ (this.value));
}
/** @public */
syncFromElt() {
// Calling super.set() to avoid redundant writeElt() call in this.set().
super.set(/** @type {StateValue} */ (this.readElt()));
}
}
/** Build utilities for working with the state. */
class MainState {
constructor() {
/** @private @const {!Array<!QueryParamUiState>} */
this.uiStates = [];
/**
* Instantiation helper that also pushes object to |uiStates|.
* @param {string} name
* @param {?HasValue} elt
* @param {boolean=} isHash
*/
const newUiState = (name, elt, isHash = false) => {
if (!elt) {
// Assume string value with defaultValue == ''.
this.uiStates.push(new QueryParamUiState(name, '', null, isHash));
} else {
this.uiStates.push(new ElementUiState(name, elt, isHash));
}
return this.uiStates[this.uiStates.length - 1];
};
/**
* @public @const {!QueryParamUiState} Active "load" URL that gets updated
* on "Upload data".
*/
this.stLoadUrl = newUiState(STATE_KEY.LOAD_URL, null);
/**
* @public @const {!QueryParamUiState} "Before" URL that gets cleared on
* "Upload data".
*/
this.stBeforeUrl = newUiState(STATE_KEY.BEFORE_URL, null);
/** @public @const {!ElementUiState} */
this.stMethodCount = newUiState(STATE_KEY.METHOD_COUNT, g_el.cbMethodCount);
/** @public @const {!ElementUiState} */
this.stByteUnit = newUiState(STATE_KEY.BYTE_UNIT, g_el.selByteUnit);
/** @public @const {!ElementUiState} */
this.stGroupBy = newUiState(STATE_KEY.GROUP_BY, g_el.rnlGroupBy);
/** @public @const {!ElementUiState} */
this.stMinSize = newUiState(STATE_KEY.MIN_SIZE, g_el.nbMinSize);
/** @public @const {!ElementUiState} */
this.stInclude = newUiState(STATE_KEY.INCLUDE, g_el.tbIncludeRegex);
/** @public @const {!ElementUiState} */
this.stExclude = newUiState(STATE_KEY.EXCLUDE, g_el.tbExcludeRegex);
/** @public @const {!ElementUiState} */
this.stType = newUiState(STATE_KEY.TYPE, g_el.rnlType);
/** @public @const {!ElementUiState} */
this.stFlagFilter = newUiState(STATE_KEY.FLAG_FILTER, g_el.rnlFlagFilter);
/** @public @const {!QueryParamUiState} */
this.stFocus = newUiState(STATE_KEY.FOCUS, null, true);
/** @private {boolean} */
this.diffMode = false;
}
/**
* Formats the filter state as a string.
* @return {string}
* @private
*/
toString() {
const queryParams = new URLSearchParams();
const hashParams = new URLSearchParams();
for (const st of this.uiStates) {
st.writeToSearchParams(st.isHash ? hashParams : queryParams);
}
const queryString = queryParams.toString();
const hashString = hashParams.toString();
return (queryString.length > 0 ? `?${queryString}` : '') +
(hashString.length > 0 ? `#${hashString}` : '');
}
/** @private */
updateUrlParams() {
// Passing empty `state` leads to no change, so use `location.pathname`.
history.replaceState(null, null, this.toString() || location.pathname);
}
/**
* @return {?function(string): boolean}
* @public
*/
getFilter() {
const getRegExpOrNull = (s) => {
if (s) {
try {
return new RegExp(s);
} catch (err) {
}
}
return null;
};
const includeRE = getRegExpOrNull(this.stInclude.get());
const excludeRE = getRegExpOrNull(this.stExclude.get());
if (includeRE) {
return excludeRE ? (s) => includeRE.test(s) && !excludeRE.test(s) :
(s) => includeRE.test(s);
}
return excludeRE ? (s) => !excludeRE.test(s) : null;
}
/**
* @return {boolean}
* @public
*/
getDiffMode() {
return this.diffMode;
}
/**
* @param {boolean} diffMode
* @public
*/
setDiffMode(diffMode) {
this.diffMode = diffMode;
}
/**
* @return {!BuildOptions}
* @public
*/
exportToBuildOptions() {
const ret = /** @type {BuildOptions} */ ({});
ret.loadUrl = /** @type {string} */ (this.stLoadUrl.get());
ret.beforeUrl = /** @type {string} */ (this.stBeforeUrl.get());
ret.methodCountMode = /** @type {boolean} */ (this.stMethodCount.get());
// Skipping |this.stByteUnit|.
ret.minSymbolSize = /** @type {number} */ (this.stMinSize.value);
ret.groupBy = /** @type {string} */ (this.stGroupBy.get());
ret.includeRegex = /** @type {string} */ (this.stInclude.get());
ret.excludeRegex = /** @type {string} */ (this.stExclude.get());
if (ret.methodCountMode) {
ret.includeSections = _DEX_METHOD_SYMBOL_TYPE;
} else {
ret.includeSections = /** @type {string} */ (this.stType.get());
}
const flagToFilterStr = /** @type {string} */ (this.stFlagFilter.get());
ret.flagToFilter = _NAMES_TO_FLAGS[flagToFilterStr] ?? 0;
ret.nonOverhead = flagToFilterStr === 'nonoverhead';
ret.disassemblyMode = flagToFilterStr === 'disassembly';
return ret;
}
/** @public */
init() {
const queryParams = new URLSearchParams(location.search.slice(1));
const hashParams = new URLSearchParams(location.hash.slice(1));
for (const st of this.uiStates) {
st.readFromSearchParams(st.isHash ? hashParams : queryParams);
}
// At this point it's possible to update the URL to fix mistakes and
// canonicalize (e.g., param ordering). However, we choose to NOT do this
// since it's disconcerting for the user, as they might want to continue
// editing or tweaking the URL.
const loadUrl = /** @type {string} */ (this.stLoadUrl.get());
const beforeUrl = /** @type {string} */ (this.stBeforeUrl.get());
this.setDiffMode(
Boolean(loadUrl && (loadUrl.endsWith('.sizediff') || beforeUrl)));
// If load_url changes beyond initial load, clear before_url and hide both
// in query params.
this.stLoadUrl.addObserver(() => {
this.stBeforeUrl.set('');
if (!this.stLoadUrl.hidden) {
this.stLoadUrl.hidden = true;
this.stBeforeUrl.hidden = true;
this.updateUrlParams();
}
});
// Update states on form change.
g_el.frmOptions.addEventListener('change', (e) => {
for (const st of this.uiStates) {
if (st instanceof ElementUiState)
st.syncFromElt();
}
this.updateUrlParams();
});
this.stFocus.addObserver(() => this.updateUrlParams());
}
}
function _startListeners() {
const _SHOW_OPTIONS_STORAGE_KEY = 'show-options';
/** @type {RadioNodeList} */
const typeCheckboxes = /** @type {RadioNodeList} */ (
g_el.frmOptions.elements.namedItem(STATE_KEY.TYPE));
/**
* The settings dialog on the side can be toggled on and off by elements with
* a 'toggle-options' class.
*/
function _toggleOptions() {
const openedOptions = document.body.classList.toggle('show-options');
localStorage.setItem(_SHOW_OPTIONS_STORAGE_KEY, openedOptions.toString());
}
for (const node of g_el.nlShowOptions) {
node.addEventListener('click', _toggleOptions);
}
// Default to open if getItem returns null
if (localStorage.getItem(_SHOW_OPTIONS_STORAGE_KEY) !== 'false') {
document.body.classList.add('show-options');
}
/** Disables some fields when method_count is set. */
function setMethodCountModeUI() {
if (state.stMethodCount.get()) {
g_el.selByteUnit.setAttribute('disabled', '');
g_el.fsTypesFilter.setAttribute('disabled', '');
g_el.spanSizeHeader.textContent = 'Methods';
} else {
g_el.selByteUnit.removeAttribute('disabled');
g_el.fsTypesFilter.removeAttribute('disabled');
g_el.spanSizeHeader.textContent = 'Size';
}
}
setMethodCountModeUI();
state.stMethodCount.addObserver(setMethodCountModeUI);
/**
* Displays error text on blur for regex inputs, if the input is invalid.
* @param {Event} event
*/
function checkForRegExError(event) {
const input = /** @type {HTMLInputElement} */ (event.currentTarget);
const errorBox = g_el.getAriaDescribedBy(input);
try {
new RegExp(input.value);
errorBox.textContent = '';
input.setAttribute('aria-invalid', 'false');
} catch (err) {
errorBox.textContent = err.message;
input.setAttribute('aria-invalid', 'true');
}
}
for (const input of [g_el.tbIncludeRegex, g_el.tbExcludeRegex]) {
input.addEventListener('blur', checkForRegExError);
input.dispatchEvent(new Event('blur'));
}
g_el.btnTypeAll.addEventListener('click', () => {
for (const checkbox of typeCheckboxes) {
/** @type {HTMLInputElement} */ (checkbox).checked = true;
}
g_el.frmOptions.dispatchEvent(new Event('change'));
});
g_el.btnTypeNone.addEventListener('click', () => {
for (const checkbox of typeCheckboxes) {
/** @type {HTMLInputElement} */ (checkbox).checked = false;
}
g_el.frmOptions.dispatchEvent(new Event('change'));
});
// Outside of input, make pressing "?" open FAQ page.
window.addEventListener('keydown', event => {
if (event.key === '?' &&
/** @type {HTMLElement} */ (event.target).tagName !== 'INPUT') {
// Open help when "?" is pressed.
g_el.linkFaq.click();
}
});
}
function _makeIconTemplateGetter() {
const getSymbolIcon = (q) => assertNotNull(g_el.divIcons.querySelector(q));
/**
* @type {{[type:string]: SVGSVGElement}} Icon elements
* that correspond to each symbol type.
*/
const symbolIcons = {
D: getSymbolIcon('.foldericon'),
G: getSymbolIcon('.groupicon'),
J: getSymbolIcon('.javaclassicon'),
F: getSymbolIcon('.fileicon'),
b: getSymbolIcon('.bssicon'),
d: getSymbolIcon('.dataicon'),
r: getSymbolIcon('.readonlyicon'),
t: getSymbolIcon('.codeicon'),
R: getSymbolIcon('.relroicon'),
x: getSymbolIcon('.dexothericon'),
m: getSymbolIcon('.dexmethodicon'),
p: getSymbolIcon('.localpakicon'),
P: getSymbolIcon('.nonlocalpakicon'),
a: getSymbolIcon('.arscicon'),
o: getSymbolIcon('.othericon'), // used as default icon
'*': null,
};
const getDiffStatusIcon = (q) => {
return assertNotNull(g_el.divDiffStatusIcons.querySelector(q));
};
const statusIcons = {
added: getDiffStatusIcon('.addedicon'),
removed: getDiffStatusIcon('.removedicon'),
changed: getDiffStatusIcon('.changedicon'),
unchanged: getDiffStatusIcon('.unchangedicon'),
};
const getMiscIcon = (q) => {
return assertNotNull(g_el.divMiscIcons.querySelector(q))
};
const metricsIcons = {
group: getSymbolIcon('.groupicon'), // Reuse.
elf: getMiscIcon('.elficon'),
dex: getMiscIcon('.dexicon'),
arsc: getSymbolIcon('.arscicon'), // Reuse.
metrics: getMiscIcon('.metricsicon'),
other: getSymbolIcon('.othericon'), // Reuse.
};
const metadataIcons = {
root: getMiscIcon('.metadataicon'),
group: getSymbolIcon('.groupicon'), // Reuse.
};
/** @type {Map<string, {color:string, description:string}>} */
const iconInfoCache = new Map();
/**
* Returns the SVG icon template element corresponding to the given type.
* @param {string} type Symbol type character.
* @param {boolean} readonly If true, the original template is returned.
* If false, a copy is returned that can be modified.
* @return {SVGSVGElement}
*/
function getIconTemplate(type, readonly = false) {
const iconTemplate = symbolIcons[type] || symbolIcons[_OTHER_SYMBOL_TYPE];
return /** @type {SVGSVGElement} */ (
readonly ? iconTemplate : iconTemplate.cloneNode(true));
}
/**
* @param {string} type Symbol type character.
* @param {?string} fill If non-null, fill color of icon.
*/
function getIconTemplateWithFill(type, fill) {
const icon = getIconTemplate(type);
if (fill)
icon.setAttribute('fill', fill);
return icon;
}
/**
* Returns style info about SVG icon template element corresponding to the
* given type.
* @param {string} type Symbol type character.
*/
function getIconStyle(type) {
let info = iconInfoCache.get(type);
if (!info) {
const icon = getIconTemplate(type, true);
info = {
color: icon.getAttribute('fill'),
description: icon.querySelector('title').textContent,
};
iconInfoCache.set(type, info);
}
return info;
}
/**
* Returns the SVG status icon template element corresponding to the diff
* status of the node. Only valid for leaf nodes.
* @param {TreeNode} node Leaf node whose diff status is used to select
* template.
* @return {SVGSVGElement}
*/
function getDiffStatusTemplate(node) {
const isLeaf = node.children && node.children.length === 0;
const entries = Object.entries(node.childStats);
let key = 'unchanged';
if (isLeaf && entries.length !== 0) {
const statsEntry = entries[0][1];
if (statsEntry.added) {
key = 'added';
} else if (statsEntry.removed) {
key = 'removed';
} else if (statsEntry.changed) {
key = 'changed';
}
} else if (node.diffStatus === _DIFF_STATUSES.ADDED) {
key = 'added';
} else if (node.diffStatus === _DIFF_STATUSES.REMOVED) {
key = 'removed';
}
return statusIcons[key].cloneNode(true);
}
/**
* @param {string} key
* @return {SVGSVGElement}
*/
function getMetricsIconTemplate(key) {
return metricsIcons[key].cloneNode(true);
}
/**
* @param {string} key
* @return {SVGSVGElement}
*/
function getMetadataIconTemplate(key) {
return metadataIcons[key].cloneNode(true);
}
return {
getIconTemplate,
getIconTemplateWithFill,
getIconStyle,
getDiffStatusTemplate,
getMetricsIconTemplate,
getMetadataIconTemplate,
};
}
function _makeSizeTextGetter() {
/**
* @param {number} bytes
* @return {!DocumentFragment}
*/
function makeBytesElement(bytes) {
const unit = /** @type {string} */ (state.stByteUnit.get());
const suffix = _BYTE_UNITS[unit];
// Format |bytes| as a number with 2 digits after the decimal point
const text = formatNumber(bytes / suffix, 2, 2);
const textNode = document.createTextNode(`${text} `);
// Display the suffix with a smaller font
const suffixElement = dom.textElement('small', unit);
return dom.createFragment([textNode, suffixElement]);
}
/**
* Create the contents for the size element of a tree node.
* The unit to use is selected from the current state.
*
* If in method count mode, size instead represents the amount of methods in
* the node. Otherwise, the original number of bytes will be displayed.
*
* @param {TreeNode} node Node whose size is the number of bytes to use for
* the size text
* @return {GetSizeResult} Object with hover text title and size element
* body.
*/
function getSizeContents(node) {
if (state.stMethodCount.get()) {
const {count: methodCount = 0} =
node.childStats[_DEX_METHOD_SYMBOL_TYPE] || {};
const methodStr = formatNumber(methodCount);
return {
description: `${methodStr} method${methodCount === 1 ? '' : 's'}`,
element: document.createTextNode(methodStr),
value: methodCount,
};
} else {
const isLeaf = node.children && node.children.length === 0;
const bytes = node.size;
const descriptionToks = [];
// Show "before → after" only for leaf nodes, since group nodes'
// |beforeSize| would miss contributions from unchanged symbols.
if (isLeaf && ('beforeSize' in node)) {
const before = formatNumber(node.beforeSize);
const after = formatNumber(node.beforeSize + bytes);
descriptionToks.push(`(${before} → ${after})`); // '→' is '\u2192'.
}
descriptionToks.push(`${formatNumber(bytes)} bytes`);
if (node.numAliases && node.numAliases > 1) {
descriptionToks.push(`for 1 of ${node.numAliases} aliases`);
}
return {
description: descriptionToks.join(' '),
element: makeBytesElement(bytes),
value: bytes,
};
}
}
/**
* Set classes on an element based on the size it represents.
* @param {HTMLElement} sizeElement
* @param {number} value
* @param {boolean} isCount Whether |value| is count (true) or byte (false).
*/
function setSizeClasses(sizeElement, value, isCount) {
const cutOff = isCount ? 10 : 50000;
const shouldHaveStyle = state.getDiffMode() && Math.abs(value) > cutOff;
if (shouldHaveStyle) {
if (value < 0) {
sizeElement.classList.add('shrunk');
sizeElement.classList.remove('grew');
} else {
sizeElement.classList.remove('shrunk');
sizeElement.classList.add('grew');
}
} else {
sizeElement.classList.remove('shrunk', 'grew');
}
}
return {makeBytesElement, getSizeContents, setSizeClasses};
}
/** Global UI State. */
const state = new MainState();
state.init();
/** Utilities for working with the state */
const {
getIconTemplate,
getIconTemplateWithFill,
getIconStyle,
getDiffStatusTemplate,
getMetricsIconTemplate,
getMetadataIconTemplate,
} = _makeIconTemplateGetter();
const {makeBytesElement, getSizeContents, setSizeClasses} =
_makeSizeTextGetter();
_startListeners();