blob: c55abb8103af724fe59d4aeebba42e3c0ed3a4ba [file]
// Copyright 2026 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';
/******** Constants ********/
const ZOOM_LEVELS = [
{title: 'Fit', scale: null},
{title: '10%', scale: 1 / 10},
{title: '25%', scale: 1 / 4},
{title: '33.3%', scale: 1 / 3},
{title: '50%', scale: 1 / 2},
{title: '66.7%', scale: 2 / 3},
{title: '75%', scale: 3 / 4},
{title: '100%', scale: 1},
{title: '150%', scale: 1.5},
{title: '200%', scale: 2},
{title: '300%', scale: 3},
{title: '400%', scale: 4},
];
const ZOOM_FIT_INDEX = 0;
/******** LayoutMode ********/
/**
* Defines the direction and interaction properties for a layout configuration
* (e.g., `LEFT` is for Screenshot on the left, Tree on the right).
*/
class LayoutMode {
/**
* @param {string} name e.g., 'left', 'top'. Used for data-layout attribute.
* Fundamentally this represents the position of Screenshot panel.
* @param {!PointXY} dir The primary axis direction vector.
*/
constructor(name, dir) {
this.name = name;
this.dir = dir;
Object.freeze(this);
}
/**
* Returns the layout mode most aligned with the given direction vector.
* @param {number} vx
* @param {number} vy
* @param {LayoutMode=} fallbackMode Mode to return if the vector is zero.
* @return {!LayoutMode}
*/
static fromVector(vx, vy, fallbackMode = LayoutMode.LEFT) {
if (vx === 0 && vy === 0) return fallbackMode;
if (Math.abs(vx) >= Math.abs(vy)) {
return (vx < 0) ? LayoutMode.LEFT : LayoutMode.RIGHT;
}
return (vy < 0) ? LayoutMode.TOP : LayoutMode.BOTTOM;
}
get orientation() {
return (this.dir.y === 0) ? ORIENTATION.HORIZ : ORIENTATION.VERT;
}
get cursor() {
return (this.dir.y === 0) ? 'col-resize' : 'row-resize';
}
// clang-format off
static LEFT = new LayoutMode('left', new PointXY(-1, 0));
static RIGHT = new LayoutMode('right', new PointXY( 1, 0));
static TOP = new LayoutMode('top', new PointXY( 0, -1));
static BOTTOM = new LayoutMode('bottom', new PointXY( 0, 1));
// clang-format on
}
/******** VisOptions ********/
/**
* Defines globally shared, mutable state for rendering options, including
* scale.
*/
class VisOptions {
constructor() {
this.densityFactor = 1.0;
this.layoutMode = LayoutMode.LEFT;
// The usage of scaling leads to two sets of dimensions / coordinates:
// * "World": Prefixed by "w-", these represent actual pixels on the device,
// and is unaffected by zoom value `scale`.
// * "Scaled": Prefixed by "s-", these represent UI pixels in the
// Screenshot, and is proportional to `scale` (from Zoom feature).
/** @type {number} Index into `ZOOM_LEVELS`. */
this.zoomIndex = ZOOM_FIT_INDEX;
/** @type {number} Current ratio from World to Scaled. */
this.scale = 1.0;
/** @type {!Dims2D} World device dimensions. */
this.wDims = new Dims2D(0, 0);
/** @type {!Dims2D} Scaled device dimensions. */
this.sDims = new Dims2D(0, 0);
}
setWorldSize(ww, wh) {
this.wDims.assign(ww, wh);
}
setDensityFactor(factor) {
this.densityFactor = factor > 0 ? factor : 1.0;
}
setZoomIndex(index) {
this.zoomIndex = index;
}
isZoomFit() {
return this.zoomIndex === ZOOM_FIT_INDEX;
}
getAutoResizeDims() {
const targetScale = this.isZoomFit() ? 1.0 : this.scale;
return this.wDims.clone().mulBy(targetScale);
}
/**
* Recalculates the visual scale based on viewport constraints.
* @param {!Dims2D} vpDims The browser viewport dimensions to show Screenshot.
*/
updateGeometry(vpDims) {
if (this.wDims.w <= 0 || this.wDims.h <= 0) {
this.scale = 1.0;
this.sDims.assign(0, 0);
return;
}
if (this.isZoomFit()) {
if (this.layoutMode.orientation === ORIENTATION.HORIZ) {
// Constrained by width. Due to browser rounding, `vpDims.w` may drift
// away from integer, e.g., 799.984375. For better fidelity, we use
// `round()` (not `floor()`) and restore this to, e.g., 800px. Note that
// Screenshot result may be fractionally larger than the viewport!
// Fortunately, this won't create a 1px-shift scrollbar to appear, since
// apparently the decision to show scrollbar also uses pre-round sizes.
const sw = Math.round(vpDims.w);
this.scale = sw / this.wDims.w;
this.sDims.assign(sw, this.wDims.h * this.scale);
} else {
// Constrained by height. Similar consideration as the above.
const sh = Math.round(vpDims.h);
this.scale = sh / this.wDims.h;
this.sDims.assign(this.wDims.w * this.scale, sh);
}
} else {
this.scale = ZOOM_LEVELS[this.zoomIndex].scale;
this.sDims.assign(this.wDims.w * this.scale, this.wDims.h * this.scale);
}
}
pxToDp(px) {
return px / this.densityFactor;
}
}
/******** ViewNode ********/
/**
* A single Android View parsed from the UI Dump, as a node in the View Tree.
*/
class ViewNode {
/**
* @param {number} index
* @param {?ViewNode} parent
* @param {number} depth
* @param {string} className
* @param {string} resourceId
* @param {!Rectangle} rect
*/
constructor(index, parent, depth, className, resourceId, rect) {
this.index = index;
this.parent = parent;
this.depth = depth;
this.children = [];
this.className = className;
this.resourceId = resourceId;
this.rect = rect;
}
/**
* Factory method to instantiate a {@link ViewNode} from a <window> tag.
* @param {!Element} xmlWindow
* @param {number} index
* @return {!{view: !ViewNode, initiallyExpanded: boolean}}
*/
static fromXmlWindow(xmlWindow, index) {
const type = xmlWindow.getAttribute('type') || 'Window';
const title = xmlWindow.getAttribute('title');
const rect = Rectangle.fromAndroidBounds(xmlWindow.getAttribute('bounds'));
const view = new ViewNode(index, null, 0, type, title || type, rect);
const initiallyExpanded =
(type !== 'TYPE_SYSTEM' && type !== 'TYPE_SPLIT_SCREEN_DIVIDER');
return {view, initiallyExpanded};
}
/**
* Factory method to instantiate a {@link ViewNode} from a <node> tag.
* @param {!Element} xmlNode
* @param {number} index
* @param {?ViewNode} parent
* @param {number} depth
* @return {!ViewNode}
*/
static fromXmlNode(xmlNode, index, parent, depth) {
const className = xmlNode.getAttribute('class') || '';
const resourceId = xmlNode.getAttribute('resource-id') || '';
const rect = Rectangle.fromAndroidBounds(xmlNode.getAttribute('bounds'));
return new ViewNode(index, parent, depth, className, resourceId, rect);
}
}
/******** ViewEngaged ********/
class ViewEngaged {
constructor() {
/** @type {?number} The active hover index, null for none. */
this.hover = null;
}
clear() {
this.hover = null;
}
}
/******** MainModel ********/
/**
* Global source of truth representing the device state. Owns data fetching
* from the ADB server, XML parsing, and the View hierarchy model.
*/
class MainModel {
constructor() {
this.visOpts = new VisOptions();
this.isLoaded = false;
this.imgScreenshot = null;
/** @type {!Array<!{view: !ViewNode, initiallyExpanded: boolean}>} */
this.windowInfos = [];
/** @type {!Array<!ViewNode>} */
this.views = [];
this.engaged = new ViewEngaged();
}
/**
* Visits parsed Android UI dump XML hierarchy, instantiates {@link ViewNode}
* from its Element, and writes them into {@link MainModel#views}.
* @param {!Element} xmlHierarchy Parsed Android UI dump XML `<hierarchy>`.
* @param {?ViewNode} ancestorView Optional ancestor {@link ViewNode} to
* attach tree to.
*/
_populateViewTree(xmlHierarchy, ancestorView) {
// Recursion-free pre-order DFS traversal of the Window's View hierarchy.
const stack = [{parentView: ancestorView, parentXml: xmlHierarchy, i: 0}];
while (stack.length > 0) {
const frame = stack.at(-1);
if (frame.i < frame.parentXml.children.length) {
const childXml = frame.parentXml.children[frame.i++];
const depth = frame.parentView ? frame.parentView.depth + 1 : 0;
const view = ViewNode.fromXmlNode(childXml, this.views.length,
frame.parentView, depth);
if (frame.parentView) frame.parentView.children.push(view);
this.views.push(view);
stack.push({parentView: view, parentXml: childXml, i: 0});
} else {
stack.pop();
}
}
}
/**
* Visits parsed Android UI dump XML hierarchy, instantiates {@link ViewNode}
* from its Element, and writes them into {@link MainModel#views}.
* @param {!Document} xmlDoc Parsed Android UI dump XML.
*/
_populateViewForest(xmlDoc) {
this.views.length = 0;
this.windowInfos.length = 0;
const xmlWindows = xmlDoc.querySelectorAll('window');
if (xmlWindows.length > 0) {
for (const xmlWindow of xmlWindows) {
const windowInfo = ViewNode.fromXmlWindow(xmlWindow, this.views.length);
this.windowInfos.push(windowInfo);
const windowView = windowInfo.view;
this.views.push(windowView);
const xmlHierarchy = xmlWindow.querySelector(':scope > hierarchy');
if (xmlHierarchy) this._populateViewTree(xmlHierarchy, windowView);
}
} else { // Backwards compatibility.
const xmlHierarchy = xmlDoc.querySelector('hierarchy');
if (xmlHierarchy) this._populateViewTree(xmlHierarchy, null);
}
}
/** Fetches the latest device data from the ADB server. */
async _fetchData() {
const [screenshotResponse, uiDumpResponse, densityResponse] =
await Promise.all([
fetch('/api/screenshot.png'), fetch('/api/ui-dump.json'),
fetch('/api/density.json')
]);
if (!screenshotResponse.ok) throw new Error('Screenshot fetch failed');
if (!uiDumpResponse.ok) throw new Error('UI dump fetch failed');
const screenshotBlob = await screenshotResponse.blob();
const uiDumpData = await uiDumpResponse.json();
if (!uiDumpData.success) throw new Error(uiDumpData.error);
// Make density data optional, and fall back to 1.0.
let densityData = null;
try {
if (!densityResponse.ok) throw new Error('Density fetch failed');
densityData = await densityResponse.json();
if (!densityData.success) throw new Error(densityData.error);
} catch (e) {
console.error(e);
console.error('Fall back to density factor of 1.0');
densityData = {density_factor: 1.0};
}
return {screenshotBlob, uiDumpData, densityData};
}
async load() {
const {screenshotBlob, uiDumpData, densityData} = await this._fetchData();
this.imgScreenshot =
await convertImageBlobToImage(screenshotBlob).catch((e) => {
throw new Error('Failed to convert screenshot image');
});
const {width, height} = this.imgScreenshot;
this.visOpts.setWorldSize(width, height);
this.visOpts.setDensityFactor(densityData.density_factor);
const xmlDoc = new DOMParser().parseFromString(uiDumpData.xml, 'text/xml');
this._populateViewForest(xmlDoc);
this.isLoaded = true;
}
async unload() {
this.isLoaded = false;
this.engaged.clear();
this.views.length = 0;
this.windowInfos.length = 0;
this.imgScreenshot = null;
// Reset geometry constraints.
this.visOpts.sDims.assign(0, 0);
this.visOpts.scale = 1.0;
}
}