From 175bf56584c8fe06d06e8212324300e7f4be98be Mon Sep 17 00:00:00 2001 From: Absinthe Date: Thu, 16 Jul 2026 15:50:41 +0800 Subject: [PATCH 1/8] Remove frame from native library detail button --- packages/apk-webui/src/report.css | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/apk-webui/src/report.css b/packages/apk-webui/src/report.css index 86cf230..5060c07 100644 --- a/packages/apk-webui/src/report.css +++ b/packages/apk-webui/src/report.css @@ -526,9 +526,8 @@ width: 40px; height: 40px; margin: -4px -4px -4px 0; - border-color: var(--line); - border-radius: 10px; - background: color-mix(in srgb, var(--surface-soft) 78%, transparent); + border: 0; + background: transparent; color: color-mix(in srgb, var(--control-text) 78%, var(--muted)); touch-action: manipulation; } @@ -1157,9 +1156,8 @@ color: var(--topbar-text); } - .native-library-detail-button:hover { - border-color: color-mix(in srgb, var(--accent-border) 70%, var(--line)); - background: var(--accent-soft); + .icon-button.native-library-detail-button:hover:not(:disabled) { + background: transparent; color: var(--accent-strong); } From ad50a5a9b4204963dde0aa19b9d2bb196cb0dc46 Mon Sep 17 00:00:00 2001 From: Absinthe Date: Thu, 16 Jul 2026 15:51:58 +0800 Subject: [PATCH 2/8] Animate ELF detail modal backdrop --- .../apk-webui/src/app/elf-detail-modal.js | 86 +++++++++++++++++-- packages/apk-webui/src/elf-detail-modal.css | 57 ++++++++---- .../apk-webui/test/report-renderer.test.mjs | 3 + 3 files changed, 122 insertions(+), 24 deletions(-) diff --git a/packages/apk-webui/src/app/elf-detail-modal.js b/packages/apk-webui/src/app/elf-detail-modal.js index 9125e35..0fde65e 100644 --- a/packages/apk-webui/src/app/elf-detail-modal.js +++ b/packages/apk-webui/src/app/elf-detail-modal.js @@ -6,8 +6,10 @@ import { escapeAttr, escapeHtml } from "./html.js"; const SYMBOL_RENDER_LIMIT = 500; const SECTION_RENDER_LIMIT = 512; const DYNAMIC_RENDER_LIMIT = 512; +const ELF_DETAIL_CLOSE_FALLBACK_MS = 150; let requestToken = 0; +let closeTimer = 0; /** @type {HTMLElement | null} */ let restoreFocusTarget = null; @@ -39,10 +41,7 @@ export function openElfDetailModal({ ? renderElfDetailLoading(library, t) : renderElfDetailUnavailable(library, t); elements.body.setAttribute("aria-busy", sourceAvailable ? "true" : "false"); - - if (!elements.dialog.open) { - elements.dialog.showModal(); - } + showElfDetailDialog(elements.dialog); if (!sourceAvailable) { onLoaded("unavailable"); @@ -70,10 +69,51 @@ export function openElfDetailModal({ } export function closeElfDetailModal() { + requestToken += 1; const dialog = /** @type {HTMLDialogElement | null} */ (document.querySelector("#elf-detail-dialog")); - if (dialog?.open) { - dialog.close(); + if (!dialog?.open || dialog.classList.contains("is-closing")) { + return; + } + + dialog.classList.remove("is-open"); + dialog.classList.add("is-closing"); + const closeDurationMs = getElfDetailModalCloseDurationMs(dialog); + closeTimer = window.setTimeout(() => { + closeTimer = 0; + if (dialog.open && dialog.classList.contains("is-closing")) { + dialog.close(); + } + }, closeDurationMs); +} + +export function getElfDetailModalCloseDurationMs(dialog) { + if ( + !dialog || + typeof window === "undefined" || + typeof window.getComputedStyle !== "function" + ) { + return ELF_DETAIL_CLOSE_FALLBACK_MS; + } + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) { + return 0; + } + return parseCssTimeMs( + window.getComputedStyle(dialog).getPropertyValue("--modal-close-dur"), + ELF_DETAIL_CLOSE_FALLBACK_MS, + ); +} + +export function parseCssTimeMs(value, fallbackMs = 0) { + const normalized = String(value || "").trim(); + if (normalized.endsWith("ms")) { + const milliseconds = Number.parseFloat(normalized); + return Number.isFinite(milliseconds) ? milliseconds : fallbackMs; } + if (normalized.endsWith("s")) { + const seconds = Number.parseFloat(normalized); + return Number.isFinite(seconds) ? seconds * 1000 : fallbackMs; + } + return fallbackMs; } export function shouldCloseElfDetailModalOnBackdropClick(event, dialog, pointerStartedOnBackdrop) { @@ -189,18 +229,27 @@ function ensureElfDetailModalElements(t) { throw new Error("Failed to create ELF detail dialog"); } let pointerStartedOnBackdrop = false; - dialog.querySelector("#elf-detail-close")?.addEventListener("click", () => dialog.close()); + dialog.querySelector("#elf-detail-close")?.addEventListener("click", closeElfDetailModal); dialog.addEventListener("pointerdown", (event) => { pointerStartedOnBackdrop = event.target === dialog; }); dialog.addEventListener("click", (event) => { if (shouldCloseElfDetailModalOnBackdropClick(event, dialog, pointerStartedOnBackdrop)) { - dialog.close(); + closeElfDetailModal(); } pointerStartedOnBackdrop = false; }); + dialog.addEventListener("pointercancel", () => { + pointerStartedOnBackdrop = false; + }); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + closeElfDetailModal(); + }); dialog.addEventListener("close", () => { requestToken += 1; + clearCloseTimer(); + dialog.classList.remove("is-open", "is-closing"); const target = restoreFocusTarget; restoreFocusTarget = null; target?.focus?.(); @@ -216,6 +265,26 @@ function ensureElfDetailModalElements(t) { }; } +function showElfDetailDialog(dialog) { + clearCloseTimer(); + const wasOpen = dialog.open; + dialog.classList.remove("is-closing"); + if (!wasOpen) { + dialog.classList.remove("is-open"); + dialog.showModal(); + dialog.getBoundingClientRect(); + } + dialog.classList.add("is-open"); +} + +function clearCloseTimer() { + if (!closeTimer) { + return; + } + window.clearTimeout(closeTimer); + closeTimer = 0; +} + function renderElfDetailModalShell(t) { const closeLabel = t("lcappsClose"); return [ @@ -382,4 +451,3 @@ function renderDataTable(label, columns, rows, total, limit, t, truncated = fals : "", ].join(""); } - diff --git a/packages/apk-webui/src/elf-detail-modal.css b/packages/apk-webui/src/elf-detail-modal.css index e208645..9d46375 100644 --- a/packages/apk-webui/src/elf-detail-modal.css +++ b/packages/apk-webui/src/elf-detail-modal.css @@ -12,11 +12,29 @@ } .elf-detail-dialog::backdrop { - background: rgb(12 8 18 / 0.18); + background-color: rgb(12 8 18 / 0); + -webkit-backdrop-filter: blur(0) saturate(1); + backdrop-filter: blur(0) saturate(1); + transition: + background-color var(--modal-open-dur) var(--modal-ease), + -webkit-backdrop-filter var(--modal-open-dur) var(--modal-ease), + backdrop-filter var(--modal-open-dur) var(--modal-ease); +} + +.elf-detail-dialog.is-open::backdrop { + background-color: rgb(12 8 18 / 0.18); -webkit-backdrop-filter: blur(16px) saturate(1.08); backdrop-filter: blur(16px) saturate(1.08); } +.elf-detail-dialog.is-closing { + pointer-events: none; +} + +.elf-detail-dialog.is-closing::backdrop { + transition-duration: var(--modal-close-dur); +} + .elf-detail-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); @@ -28,7 +46,24 @@ border-radius: 20px; background: var(--surface); box-shadow: 0 30px 80px rgb(10 8 18 / 0.32); - animation: elf-detail-enter var(--modal-open-dur) var(--modal-ease); + opacity: 0; + transform: translateY(8px) scale(var(--modal-scale)); + transform-origin: center; + transition: + opacity var(--modal-open-dur) var(--modal-ease), + transform var(--modal-open-dur) var(--modal-ease); + will-change: opacity, transform; +} + +.elf-detail-dialog.is-open .elf-detail-panel { + opacity: 1; + transform: translateY(0) scale(1); +} + +.elf-detail-dialog.is-closing .elf-detail-panel { + opacity: 0; + transform: translateY(8px) scale(var(--modal-scale)); + transition-duration: var(--modal-close-dur); } .elf-detail-header { @@ -422,7 +457,11 @@ } @media (prefers-reduced-motion: reduce) { - .elf-detail-panel, + .elf-detail-dialog::backdrop, + .elf-detail-panel { + transition: none; + } + .elf-detail-spinner { animation: none; } @@ -432,18 +471,6 @@ } } -@keyframes elf-detail-enter { - from { - opacity: 1; - transform: translateY(8px) scale(var(--modal-scale)); - } - - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - @keyframes elf-detail-spin { to { transform: rotate(360deg); diff --git a/packages/apk-webui/test/report-renderer.test.mjs b/packages/apk-webui/test/report-renderer.test.mjs index 5386cf9..12e5447 100644 --- a/packages/apk-webui/test/report-renderer.test.mjs +++ b/packages/apk-webui/test/report-renderer.test.mjs @@ -215,6 +215,9 @@ test("ELF detail content renders all inspection groups and escapes parsed data", const dialog = {}; assert.equal(elfDetailModal.shouldCloseElfDetailModalOnBackdropClick({ target: dialog }, dialog, true), true); assert.equal(elfDetailModal.shouldCloseElfDetailModalOnBackdropClick({ target: {} }, dialog, true), false); + assert.equal(elfDetailModal.parseCssTimeMs("150ms", 1), 150); + assert.equal(elfDetailModal.parseCssTimeMs("0.25s", 1), 250); + assert.equal(elfDetailModal.parseCssTimeMs("invalid", 150), 150); }); test("report renderer adds build feature icons and hides app metadata", () => { From d6be8f5a7ebf408559da9a612bdba2d29a1c9286 Mon Sep 17 00:00:00 2001 From: Absinthe Date: Thu, 16 Jul 2026 15:53:32 +0800 Subject: [PATCH 3/8] Avoid flashing ELF detail loading state --- .../apk-webui/src/app/elf-detail-modal.js | 40 +++++++++++++++---- packages/apk-webui/src/elf-detail-modal.css | 12 +++++- .../apk-webui/test-browser/webui.spec.mjs | 34 +++++++++++++++- 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/packages/apk-webui/src/app/elf-detail-modal.js b/packages/apk-webui/src/app/elf-detail-modal.js index 0fde65e..896f74a 100644 --- a/packages/apk-webui/src/app/elf-detail-modal.js +++ b/packages/apk-webui/src/app/elf-detail-modal.js @@ -6,9 +6,11 @@ import { escapeAttr, escapeHtml } from "./html.js"; const SYMBOL_RENDER_LIMIT = 500; const SECTION_RENDER_LIMIT = 512; const DYNAMIC_RENDER_LIMIT = 512; +const ELF_DETAIL_LOADING_DELAY_MS = 150; const ELF_DETAIL_CLOSE_FALLBACK_MS = 150; let requestToken = 0; +let loadingTimer = 0; let closeTimer = 0; /** @type {HTMLElement | null} */ let restoreFocusTarget = null; @@ -37,39 +39,54 @@ export function openElfDetailModal({ const token = ++requestToken; restoreFocusTarget = trigger || null; updateElfDetailModalHeader(elements, library, t); - elements.body.innerHTML = sourceAvailable - ? renderElfDetailLoading(library, t) - : renderElfDetailUnavailable(library, t); - elements.body.setAttribute("aria-busy", sourceAvailable ? "true" : "false"); - showElfDetailDialog(elements.dialog); + elements.dialog.classList.toggle("has-source", sourceAvailable); + clearLoadingTimer(); if (!sourceAvailable) { + elements.body.innerHTML = renderElfDetailUnavailable(library, t); + elements.body.setAttribute("aria-busy", "false"); + showElfDetailDialog(elements.dialog); onLoaded("unavailable"); return; } + elements.body.setAttribute("aria-busy", "true"); + loadingTimer = window.setTimeout(() => { + loadingTimer = 0; + if (token !== requestToken) { + return; + } + elements.body.innerHTML = renderElfDetailLoading(library, t); + showElfDetailDialog(elements.dialog); + }, ELF_DETAIL_LOADING_DELAY_MS); + void Promise.resolve() .then(loadDetails) .then((details) => { - if (token !== requestToken || !elements.dialog.open) { + if (token !== requestToken) { return; } + clearLoadingTimer(); elements.body.setAttribute("aria-busy", "false"); elements.body.innerHTML = renderElfDetailContent({ details, library, t }); + showElfDetailDialog(elements.dialog); onLoaded("success"); }) .catch(() => { - if (token !== requestToken || !elements.dialog.open) { + if (token !== requestToken) { return; } + clearLoadingTimer(); elements.body.setAttribute("aria-busy", "false"); elements.body.innerHTML = renderElfDetailError(library, t); + showElfDetailDialog(elements.dialog); onLoaded("error"); }); } export function closeElfDetailModal() { requestToken += 1; + clearLoadingTimer(); const dialog = /** @type {HTMLDialogElement | null} */ (document.querySelector("#elf-detail-dialog")); if (!dialog?.open || dialog.classList.contains("is-closing")) { return; @@ -248,6 +265,7 @@ function ensureElfDetailModalElements(t) { }); dialog.addEventListener("close", () => { requestToken += 1; + clearLoadingTimer(); clearCloseTimer(); dialog.classList.remove("is-open", "is-closing"); const target = restoreFocusTarget; @@ -277,6 +295,14 @@ function showElfDetailDialog(dialog) { dialog.classList.add("is-open"); } +function clearLoadingTimer() { + if (!loadingTimer) { + return; + } + window.clearTimeout(loadingTimer); + loadingTimer = 0; +} + function clearCloseTimer() { if (!closeTimer) { return; diff --git a/packages/apk-webui/src/elf-detail-modal.css b/packages/apk-webui/src/elf-detail-modal.css index 9d46375..93ffb72 100644 --- a/packages/apk-webui/src/elf-detail-modal.css +++ b/packages/apk-webui/src/elf-detail-modal.css @@ -66,6 +66,10 @@ transition-duration: var(--modal-close-dur); } +.elf-detail-dialog.has-source .elf-detail-panel { + height: min(880px, calc(100dvh - clamp(32px, 8vw, 72px))); +} + .elf-detail-header { display: flex; align-items: flex-start; @@ -117,8 +121,8 @@ } .elf-detail-body { - display: grid; - align-content: start; + display: flex; + flex-direction: column; gap: 14px; min-height: 0; padding: 16px 20px 22px; @@ -126,6 +130,10 @@ overscroll-behavior: contain; } +.elf-detail-body > * { + flex: 0 0 auto; +} + .elf-detail-facts { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); diff --git a/packages/apk-webui/test-browser/webui.spec.mjs b/packages/apk-webui/test-browser/webui.spec.mjs index 540d27d..1284923 100644 --- a/packages/apk-webui/test-browser/webui.spec.mjs +++ b/packages/apk-webui/test-browser/webui.spec.mjs @@ -81,7 +81,7 @@ test("analyzes a small APK, saves history, reopens the report, and compares it", await expect(page.locator("#compare-result")).toContainText(/No differences|无差异/u); }); -test("opens ELF details on demand and degrades safely for history reports", async ({ page }) => { +test("opens ELF details on demand and degrades safely for history reports", async ({ page }, testInfo) => { await page.setViewportSize({ width: 375, height: 812 }); const workerStarted = page.waitForEvent("worker"); await page.locator("#file-input").setInputFiles(validApk); @@ -96,14 +96,39 @@ test("opens ELF details on demand and degrades safely for history reports", asyn await expect(alignmentLabel).toHaveCSS("border-top-style", "solid"); await expect(alignmentLabel).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); await expect(detailButton).toHaveAttribute("aria-label", /libsmoke\.so/u); + await expect(detailButton).toHaveCSS("border-top-style", "none"); + await expect(detailButton).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await page.evaluate(() => { + window.__elfLoadingRenderCount = 0; + window.__elfLoadingObserver = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if ( + node.nodeType === Node.ELEMENT_NODE && + (node.matches?.(".elf-detail-status:not(.is-empty):not(.is-error)") || + node.querySelector?.(".elf-detail-status:not(.is-empty):not(.is-error)")) + ) { + window.__elfLoadingRenderCount += 1; + } + } + } + }); + window.__elfLoadingObserver.observe(document.body, { childList: true, subtree: true }); + }); await detailButton.click(); const dialog = page.locator("#elf-detail-dialog"); const body = page.locator("#elf-detail-body"); await expect(dialog).toBeVisible(); + await expect(dialog).toHaveClass(/\bis-open\b/u); await expect(body).toHaveAttribute("aria-busy", "false"); await expect(body).toContainText("ELF64"); await expect(body).toContainText("PT_LOAD"); + const loadingRenderCount = await page.evaluate(() => { + window.__elfLoadingObserver.disconnect(); + return window.__elfLoadingRenderCount; + }); + expect(loadingRenderCount).toBe(0); const spinnerShape = await body.evaluate((element) => { const spinner = document.createElement("span"); @@ -114,7 +139,11 @@ test("opens ELF details on demand and degrades safely for history reports", asyn spinner.remove(); return result; }); - expect(spinnerShape).toEqual({ cornerShape: "round", width: 22, height: 22 }); + expect(spinnerShape).toEqual({ + cornerShape: testInfo.project.name === "webkit" ? undefined : "round", + width: 22, + height: 22, + }); await expect.poll(() => body.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true); await body.evaluate((element) => { element.scrollTop = 120; @@ -122,6 +151,7 @@ test("opens ELF details on demand and degrades safely for history reports", asyn await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); await page.keyboard.press("Escape"); + await expect(dialog).toHaveClass(/\bis-closing\b/u); await expect(dialog).toBeHidden(); await expect(detailButton).toBeFocused(); From b5c07c6f325bbabde110b48715369c24965e8f45 Mon Sep 17 00:00:00 2001 From: Absinthe Date: Thu, 16 Jul 2026 16:04:58 +0800 Subject: [PATCH 4/8] Add divider to ELF header details --- packages/apk-webui/src/elf-detail-modal.css | 14 ++++++++++++++ packages/apk-webui/test-browser/webui.spec.mjs | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/apk-webui/src/elf-detail-modal.css b/packages/apk-webui/src/elf-detail-modal.css index 93ffb72..ab1b097 100644 --- a/packages/apk-webui/src/elf-detail-modal.css +++ b/packages/apk-webui/src/elf-detail-modal.css @@ -243,6 +243,7 @@ } .elf-detail-kv { + position: relative; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; @@ -251,6 +252,15 @@ background: var(--surface-soft); } +.elf-detail-kv::before { + position: absolute; + inset: 0 auto 0 50%; + width: 1px; + background: var(--line); + content: ""; + pointer-events: none; +} + .elf-detail-kv > div { display: grid; grid-template-columns: minmax(130px, 0.8fr) minmax(0, 1.2fr); @@ -448,6 +458,10 @@ grid-template-columns: 1fr; } + .elf-detail-kv::before { + display: none; + } + .elf-detail-kv > div:nth-last-child(-n + 2) { border-bottom: 1px solid var(--line); } diff --git a/packages/apk-webui/test-browser/webui.spec.mjs b/packages/apk-webui/test-browser/webui.spec.mjs index 1284923..15fa542 100644 --- a/packages/apk-webui/test-browser/webui.spec.mjs +++ b/packages/apk-webui/test-browser/webui.spec.mjs @@ -124,6 +124,23 @@ test("opens ELF details on demand and degrades safely for history reports", asyn await expect(body).toHaveAttribute("aria-busy", "false"); await expect(body).toContainText("ELF64"); await expect(body).toContainText("PT_LOAD"); + const headerKeyValues = page.locator(".elf-detail-group[open] .elf-detail-kv"); + await expect(headerKeyValues).toBeVisible(); + expect(await headerKeyValues.evaluate((element) => getComputedStyle(element, "::before").display)).toBe("none"); + await page.setViewportSize({ width: 1280, height: 900 }); + const desktopDivider = await headerKeyValues.evaluate((element) => { + const style = getComputedStyle(element, "::before"); + return { + containerWidth: element.clientWidth, + display: style.display, + left: Number.parseFloat(style.left), + width: style.width, + }; + }); + expect(desktopDivider.display).toBe("block"); + expect(desktopDivider.width).toBe("1px"); + expect(desktopDivider.left).toBeCloseTo(desktopDivider.containerWidth / 2, 0); + await page.setViewportSize({ width: 375, height: 812 }); const loadingRenderCount = await page.evaluate(() => { window.__elfLoadingObserver.disconnect(); return window.__elfLoadingRenderCount; From a77d11f852ab2f860edf8daec1f9a6e8b39f5314 Mon Sep 17 00:00:00 2001 From: Absinthe Date: Thu, 16 Jul 2026 16:15:15 +0800 Subject: [PATCH 5/8] Emphasize component simple names --- packages/apk-webui/src/app/report-renderer.js | 19 ++++++++++++++++++- packages/apk-webui/src/report.css | 8 ++++++++ .../apk-webui/test/report-renderer.test.mjs | 19 +++++++++++++++++++ .../apk-webui/test/style-boundaries.test.mjs | 1 + 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/apk-webui/src/app/report-renderer.js b/packages/apk-webui/src/app/report-renderer.js index f3353cd..9b8c16e 100644 --- a/packages/apk-webui/src/app/report-renderer.js +++ b/packages/apk-webui/src/app/report-renderer.js @@ -438,7 +438,7 @@ function renderComponentRow(component) { `
`, `
`, `
`, - `
${escapeHtml(component.name || t("unknown"))}
`, + `
${renderComponentName(component.name)}
`, `
`, `
`, renderComponentDetails(component), @@ -446,6 +446,23 @@ function renderComponentRow(component) { ].join(""); } +function renderComponentName(name) { + const value = String(name || t("unknown")); + const separatorIndex = value.lastIndexOf("."); + if (separatorIndex < 0 || separatorIndex === value.length - 1) { + return `${escapeHtml(value)}`; + } + + const prefix = value.slice(0, separatorIndex + 1); + const simpleName = value.slice(separatorIndex + 1); + return [ + ``, + `${escapeHtml(prefix)}`, + `${escapeHtml(simpleName)}`, + ``, + ].join(""); +} + function renderComponentDetails(component) { let rows = ""; diff --git a/packages/apk-webui/src/report.css b/packages/apk-webui/src/report.css index 5060c07..247c632 100644 --- a/packages/apk-webui/src/report.css +++ b/packages/apk-webui/src/report.css @@ -752,6 +752,14 @@ line-height: 1.35; } +.component-name-prefix { + color: var(--control-text); +} + +.component-name-simple { + color: var(--heading-text); +} + .component-detail-table .kv-row { grid-template-columns: minmax(92px, 145px) minmax(0, 1fr); padding: 9px 12px; diff --git a/packages/apk-webui/test/report-renderer.test.mjs b/packages/apk-webui/test/report-renderer.test.mjs index 12e5447..3b6a0d1 100644 --- a/packages/apk-webui/test/report-renderer.test.mjs +++ b/packages/apk-webui/test/report-renderer.test.mjs @@ -119,6 +119,25 @@ test("report renderer caps long component groups", () => { assert.doesNotMatch(html, /Activity121/); }); +test("report renderer separates and escapes component package and simple name", () => { + setupRenderer("components"); + const html = reportRenderer.renderTabPanelHtml(createReport({ + components: { + activities: [ + { name: "top.yukonga.mishka.MainActivity" }, + { name: "StandaloneActivity" }, + { name: "com.example." }, + ], + }, + })); + + assert.match(html, /top\.yukonga\.mishka\.<\/span>/); + assert.match(html, /MainActivity<\/span>/); + assert.match(html, /StandaloneActivity<\/span>/); + assert.match(html, /<UnsafeActivity><\/span>/); + assert.doesNotMatch(html, //); +}); + test("report renderer caps long native library groups", () => { setupRenderer("native"); const nativeLibraries = Array.from({ length: reportRenderer.REPORT_LIST_RENDER_LIMIT + 4 }, (_, index) => ({ diff --git a/packages/apk-webui/test/style-boundaries.test.mjs b/packages/apk-webui/test/style-boundaries.test.mjs index 4abbfc2..ae6c53f 100644 --- a/packages/apk-webui/test/style-boundaries.test.mjs +++ b/packages/apk-webui/test/style-boundaries.test.mjs @@ -22,6 +22,7 @@ test("report and compare styles stay behind lazy module boundaries", async () => /^\.native-abi-count\s*\{/mu, /^\.component-group-block\s*\{/mu, /^\.component-group-grid\s*\{/mu, + /^\.component-name-prefix\s*\{/mu, /^\.component-row-header\s*\{/mu, /^\.permission-table-row\s*\{/mu, /^\.sdk-row-header\s*\{/mu, From cb6d90d3a83f72352cc5e7378a98cc6e65a56b17 Mon Sep 17 00:00:00 2001 From: Absinthe Date: Thu, 16 Jul 2026 16:42:39 +0800 Subject: [PATCH 6/8] Clarify native library ZIP alignment --- locales/en.json | 1 + locales/ja.json | 1 + locales/ko.json | 1 + locales/zh-Hans.json | 1 + locales/zh-Hant.json | 1 + .../apk-webui/src/app/elf-detail-modal.js | 7 +- .../apk-webui/test/report-renderer.test.mjs | 32 ++++ packages/bot-worker/src/apk-url-preview.js | 103 +++++++++++- .../bot-worker/test/apk-url-preview.test.mjs | 157 ++++++++++++++++++ packages/shared/src/apk.js | 37 ++++- packages/shared/src/contracts.js | 1 + packages/shared/test/apk-icon.test.mjs | 4 + 12 files changed, 341 insertions(+), 5 deletions(-) diff --git a/locales/en.json b/locales/en.json index 678cc66..129418f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -192,6 +192,7 @@ "elfNeededLibraries": "Needed Libraries", "elfClass": "ELF Class", "elfPageAlignment": "Page Alignment", + "elfZipAlignmentCompressed": "Compressed / not applicable", "components": "Components", "signatures": "Signatures", "metaData": "Meta-Data", diff --git a/locales/ja.json b/locales/ja.json index 1b18751..ee51954 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -192,6 +192,7 @@ "elfNeededLibraries": "必要なライブラリ", "elfClass": "ELF クラス", "elfPageAlignment": "ページアラインメント", + "elfZipAlignmentCompressed": "圧縮済み / 対象外", "components": "コンポーネント", "signatures": "署名", "metaData": "メタデータ", diff --git a/locales/ko.json b/locales/ko.json index 677ccf9..771d0fc 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -192,6 +192,7 @@ "elfNeededLibraries": "필요한 라이브러리", "elfClass": "ELF 클래스", "elfPageAlignment": "페이지 정렬", + "elfZipAlignmentCompressed": "압축됨 / 해당 없음", "components": "구성요소", "signatures": "서명", "metaData": "메타데이터", diff --git a/locales/zh-Hans.json b/locales/zh-Hans.json index a0c2744..8937fd7 100644 --- a/locales/zh-Hans.json +++ b/locales/zh-Hans.json @@ -192,6 +192,7 @@ "elfNeededLibraries": "依赖库", "elfClass": "ELF 位数", "elfPageAlignment": "页面对齐", + "elfZipAlignmentCompressed": "已压缩 / 不适用", "components": "组件", "signatures": "签名", "metaData": "元数据", diff --git a/locales/zh-Hant.json b/locales/zh-Hant.json index 4ce209a..afdfe95 100644 --- a/locales/zh-Hant.json +++ b/locales/zh-Hant.json @@ -192,6 +192,7 @@ "elfNeededLibraries": "相依程式庫", "elfClass": "ELF 位元數", "elfPageAlignment": "頁面對齊", + "elfZipAlignmentCompressed": "已壓縮 / 不適用", "components": "元件", "signatures": "簽章", "metaData": "中繼資料", diff --git a/packages/apk-webui/src/app/elf-detail-modal.js b/packages/apk-webui/src/app/elf-detail-modal.js index 896f74a..6fd7649 100644 --- a/packages/apk-webui/src/app/elf-detail-modal.js +++ b/packages/apk-webui/src/app/elf-detail-modal.js @@ -375,12 +375,17 @@ function renderElfDetailError(library, t) { function renderElfDetailFacts(library, details, t) { const pageSize = Number(library?.elfPageSize) || 0; const zipAlignment = Number(library?.zipAlignment) || 0; + const zipAlignmentText = library?.zipCompression === "deflate" + ? t("elfZipAlignmentCompressed") + : zipAlignment > 0 + ? formatAlignmentBytes(zipAlignment) + : t("unknown"); const facts = [ ["ABI", library?.abi || t("unknown")], [t("size"), formatBytes(library?.size || details?.byteLength || 0)], [t("elfClass"), details?.header?.class || t("unknown")], [t("elfPageAlignment"), pageSize > 0 ? formatAlignmentBytes(pageSize) : t("unknown")], - ["ZIPALIGN", zipAlignment > 0 ? formatAlignmentBytes(zipAlignment) : t("unknown")], + ["ZIPALIGN", zipAlignmentText], ]; return [ diff --git a/packages/apk-webui/test/report-renderer.test.mjs b/packages/apk-webui/test/report-renderer.test.mjs index 3b6a0d1..0431423 100644 --- a/packages/apk-webui/test/report-renderer.test.mjs +++ b/packages/apk-webui/test/report-renderer.test.mjs @@ -230,6 +230,7 @@ test("ELF detail content renders all inspection groups and escapes parsed data", assert.match(html, /<img src=x onerror=alert\(1\)>/u); assert.doesNotMatch(html, /16 KBZIPALIGN<\/dt>
4 KB<\/dd>/u); assert.doesNotMatch(html, /16\.00 KB/u); const dialog = {}; assert.equal(elfDetailModal.shouldCloseElfDetailModalOnBackdropClick({ target: dialog }, dialog, true), true); @@ -239,6 +240,37 @@ test("ELF detail content renders all inspection groups and escapes parsed data", assert.equal(elfDetailModal.parseCssTimeMs("invalid", 150), 150); }); +test("ELF detail content distinguishes compressed and unknown ZIP alignment", () => { + const translate = (key, variables) => i18n.translate("en", key, variables); + const details = { + byteLength: 4096, + header: { class: "ELF64" }, + }; + const compressedHtml = elfDetailModal.renderElfDetailContent({ + library: { + abi: "arm64-v8a", + name: "libcompressed.so", + size: 4096, + zipCompression: "deflate", + }, + details, + t: translate, + }); + const unknownHtml = elfDetailModal.renderElfDetailContent({ + library: { + abi: "arm64-v8a", + name: "libunknown.so", + size: 4096, + zipCompression: "store", + }, + details, + t: translate, + }); + + assert.match(compressedHtml, /
ZIPALIGN<\/dt>
Compressed \/ not applicable<\/dd>/u); + assert.match(unknownHtml, /
ZIPALIGN<\/dt>
Unknown<\/dd>/u); +}); + test("report renderer adds build feature icons and hides app metadata", () => { setupRenderer("summary", { renderSdkIcon: (src, label, singleColorIcon = false) => src diff --git a/packages/bot-worker/src/apk-url-preview.js b/packages/bot-worker/src/apk-url-preview.js index 1b1cfb2..2b6dde0 100644 --- a/packages/bot-worker/src/apk-url-preview.js +++ b/packages/bot-worker/src/apk-url-preview.js @@ -1,4 +1,4 @@ -import { readApkInfoFromZipSource } from "../../shared/src/apk.js"; +import { applyNativeLibraryZipAlignments, readApkInfoFromZipSource } from "../../shared/src/apk.js"; import ipaddr from "ipaddr.js"; const EOCD_SIGNATURE = 0x06054b50; @@ -16,6 +16,10 @@ const DEFAULT_MAX_ENTRY_COMPRESSED_BYTES = 24 * 1024 * 1024; const DEFAULT_MAX_RESOURCE_BYTES = 12 * 1024 * 1024; const METADATA_PROBE_BYTES = 1024; const LOCAL_HEADER_EXTRA_SLOP_BYTES = 512; +const NATIVE_LOCAL_HEADER_PROBE_CONCURRENCY = 4; +const MAX_NATIVE_LOCAL_HEADER_PROBES = 16; +// Keep headroom below the Workers Free 50-subrequest limit for HEAD, R2/KV, and Telegram follow-ups. +const OPTIONAL_NATIVE_HEADER_RANGE_REQUEST_CEILING = 32; const SMALL_ENTRY_COALESCE_MAX_BYTES = 128 * 1024; const SMALL_ENTRY_LOOKAHEAD_BYTES = 192 * 1024; const SMALL_ENTRY_COALESCE_MIN_OFFSET = 1024 * 1024; @@ -144,6 +148,11 @@ export async function readApkInfoFromUrl(rawUrl, options = {}) { scanDex: false, maxResourceBytes: options.maxResourceBytes ?? DEFAULT_MAX_RESOURCE_BYTES, }); + await hydrateStoredNativeLibraryDataOffsets(apkUrl, zipEntries, stats, { + contentLength: metadata.contentLength, + rangeCache, + }); + applyNativeLibraryZipAlignments(apkInfo.nativeLibraries, zipEntries); notifyUrlProgress(options, stats, diagnostics, "apk_metadata", 0.82); return { @@ -228,6 +237,7 @@ async function fetchHeadMetadata(url) { } async function fetchRangeProbeMetadata(url, stats) { + stats.rangeRequestCount += 1; const response = await fetch(url, { headers: { ...buildRemoteHeaders(), @@ -241,7 +251,6 @@ async function fetchRangeProbeMetadata(url, stats) { } const bytes = new Uint8Array(await response.arrayBuffer()); - stats.rangeRequestCount += 1; stats.downloadedBytes += bytes.byteLength; return { @@ -465,6 +474,93 @@ function compactDiagnosticFields(fields) { return compacted; } +async function hydrateStoredNativeLibraryDataOffsets(url, zipEntries, stats, options = {}) { + const requestedMaxProbes = Number(options.maxProbes ?? MAX_NATIVE_LOCAL_HEADER_PROBES); + const configuredMaxProbes = Number.isFinite(requestedMaxProbes) + ? Math.max(0, Math.trunc(requestedMaxProbes)) + : MAX_NATIVE_LOCAL_HEADER_PROBES; + const remainingRangeRequestBudget = Math.max( + 0, + OPTIONAL_NATIVE_HEADER_RANGE_REQUEST_CEILING - (Number(stats.rangeRequestCount) || 0), + ); + const maxProbes = Math.min(configuredMaxProbes, remainingRangeRequestBudget); + const entries = []; + for (const [path, entry] of zipEntries.entries()) { + if ( + entries.length >= maxProbes || + !path.startsWith("lib/") || + !path.endsWith(".so") || + entry.compressionMethod !== ZIP_COMPRESSION_STORE || + entry.dataOffset + ) { + continue; + } + entries.push(entry); + } + + let nextIndex = 0; + let stopped = false; + const workerCount = Math.min(NATIVE_LOCAL_HEADER_PROBE_CONCURRENCY, entries.length); + const workers = Array.from({ length: workerCount }, async () => { + while (!stopped && nextIndex < entries.length) { + const entry = entries[nextIndex]; + nextIndex += 1; + try { + const dataOffset = await readRemoteZipEntryDataOffset(url, entry, stats, options); + if (dataOffset > 0) { + entry.dataOffset = dataOffset; + } + } catch { + // Stop optional probes after a shared remote failure so required work keeps its request budget. + stopped = true; + } + } + }); + await Promise.all(workers); +} + +async function readRemoteZipEntryDataOffset(url, entry, stats, options) { + const headerOffset = Number(entry.localHeaderOffset); + const contentLength = Number(options.contentLength) || 0; + if ( + !Number.isSafeInteger(headerOffset) || + headerOffset < 0 || + (contentLength > 0 && headerOffset + LOCAL_FILE_HEADER_FIXED_SIZE > contentLength) + ) { + return 0; + } + + const header = hasAvailableRange(options.rangeCache, headerOffset, LOCAL_FILE_HEADER_FIXED_SIZE) + ? await downloadCachedRange( + url, + headerOffset, + LOCAL_FILE_HEADER_FIXED_SIZE, + stats, + options.rangeCache, + ) + : await downloadRange(url, headerOffset, LOCAL_FILE_HEADER_FIXED_SIZE, stats); + if ( + header.byteLength < LOCAL_FILE_HEADER_FIXED_SIZE || + readUint32(header, 0) !== LOCAL_FILE_HEADER_SIGNATURE || + readUint16(header, 8) !== ZIP_COMPRESSION_STORE + ) { + return 0; + } + + const fileNameLength = readUint16(header, 26); + const extraLength = readUint16(header, 28); + const dataOffset = headerOffset + LOCAL_FILE_HEADER_FIXED_SIZE + fileNameLength + extraLength; + const dataEnd = dataOffset + (Number(entry.compressedSize) || 0); + if ( + !Number.isSafeInteger(dataOffset) || + !Number.isSafeInteger(dataEnd) || + (contentLength > 0 && dataEnd > contentLength) + ) { + return 0; + } + return dataOffset; +} + async function downloadRemoteZipEntry(url, entry, stats, options) { if ((entry.flags & 0x0001) !== 0) { throw new Error("Encrypted ZIP entries are not supported"); @@ -704,6 +800,7 @@ async function downloadRange(url, offset, length, stats) { } const end = offset + length - 1; + stats.rangeRequestCount += 1; const response = await fetch(url, { headers: { ...buildRemoteHeaders(), @@ -717,7 +814,6 @@ async function downloadRange(url, offset, length, stats) { } const bytes = new Uint8Array(await response.arrayBuffer()); - stats.rangeRequestCount += 1; stats.downloadedBytes += bytes.byteLength; return bytes; } @@ -850,6 +946,7 @@ async function inflateRaw(bytes) { export const __apkUrlPreviewTestInternals = { findEndOfCentralDirectory, + hydrateStoredNativeLibraryDataOffsets, parseCentralDirectory, parseEocd, }; diff --git a/packages/bot-worker/test/apk-url-preview.test.mjs b/packages/bot-worker/test/apk-url-preview.test.mjs index d064e7d..7069954 100644 --- a/packages/bot-worker/test/apk-url-preview.test.mjs +++ b/packages/bot-worker/test/apk-url-preview.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { applyNativeLibraryZipAlignments } from "../../shared/src/apk.js"; import { __apkUrlPreviewTestInternals, parseHttpUrl } from "../src/apk-url-preview.js"; const textEncoder = new TextEncoder(); @@ -75,6 +76,151 @@ test("remote ZIP directory parsing retains offsets needed for HTTP Range reads", }); }); +test("remote ZIP alignment hydration reads only stored native local headers", async () => { + const storedNative = { + name: "lib/arm64-v8a/libstored.so", + compressionMethod: 0, + compressedSize: 50, + localHeaderOffset: 120, + extraLength: 0, + }; + const cappedNative = { + name: "lib/arm64-v8a/libcapped.so", + compressionMethod: 0, + compressedSize: 50, + localHeaderOffset: 320, + }; + const deflatedNative = { + name: "lib/arm64-v8a/libdeflated.so", + compressionMethod: 8, + compressedSize: 50, + localHeaderOffset: 520, + }; + const storedResource = { + name: "resources.arsc", + compressionMethod: 0, + compressedSize: 50, + localHeaderOffset: 720, + }; + const entries = new Map([ + [storedNative.name, storedNative], + [cappedNative.name, cappedNative], + [deflatedNative.name, deflatedNative], + [storedResource.name, storedResource], + ]); + const localExtraLength = 4096 + - storedNative.localHeaderOffset + - 30 + - textEncoder.encode(storedNative.name).byteLength; + const header = createLocalFileHeader(storedNative.name, { + compressionMethod: 0, + extraLength: localExtraLength, + }); + const requests = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_url, options) => { + requests.push(options.headers.range); + return new Response(header, { status: 206 }); + }; + + const stats = { rangeRequestCount: 0, downloadedBytes: 0, rangeCacheHitCount: 0 }; + try { + await __apkUrlPreviewTestInternals.hydrateStoredNativeLibraryDataOffsets( + new URL("https://example.com/sample.apk"), + entries, + stats, + { contentLength: 5000, maxProbes: 1 }, + ); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal( + storedNative.dataOffset, + storedNative.localHeaderOffset + 30 + textEncoder.encode(storedNative.name).byteLength + localExtraLength, + ); + const nativeLibraries = [{ path: storedNative.name, zipCompression: "store" }]; + applyNativeLibraryZipAlignments(nativeLibraries, entries); + assert.equal(nativeLibraries[0].zipAlignment, 4096); + assert.equal(nativeLibraries[0].zip16kbAligned, false); + assert.equal(cappedNative.dataOffset, undefined); + assert.equal(deflatedNative.dataOffset, undefined); + assert.equal(storedResource.dataOffset, undefined); + assert.deepEqual(requests, ["bytes=120-149"]); + assert.equal(stats.rangeRequestCount, 1); + assert.equal(stats.downloadedBytes, 30); +}); + +test("remote ZIP alignment hydration stops after one failed probe batch", async () => { + const entries = new Map(Array.from({ length: 10 }, (_, index) => { + const name = `lib/arm64-v8a/libfailure${index}.so`; + return [name, { + name, + compressionMethod: 0, + compressedSize: 50, + localHeaderOffset: 100 + index * 100, + }]; + })); + const requests = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_url, options) => { + requests.push(options.headers.range); + return new Response(null, { status: 429 }); + }; + + const stats = { rangeRequestCount: 0, downloadedBytes: 0, rangeCacheHitCount: 0 }; + try { + await __apkUrlPreviewTestInternals.hydrateStoredNativeLibraryDataOffsets( + new URL("https://example.com/sample.apk"), + entries, + stats, + { contentLength: 2000 }, + ); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(requests.length, 4); + assert.equal(stats.rangeRequestCount, 4); + assert.equal(stats.downloadedBytes, 0); + assert.equal(Array.from(entries.values()).some((entry) => entry.dataOffset != null), false); +}); + +test("remote ZIP alignment hydration preserves the shared request budget", async () => { + const entries = new Map(Array.from({ length: 3 }, (_, index) => { + const name = `lib/arm64-v8a/libbudget${index}.so`; + return [name, { + name, + compressionMethod: 0, + compressedSize: 50, + localHeaderOffset: 100 + index * 100, + }]; + })); + const header = createLocalFileHeader("lib/arm64-v8a/libbudget0.so", { compressionMethod: 0 }); + const requests = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_url, options) => { + requests.push(options.headers.range); + return new Response(header, { status: 206 }); + }; + + const stats = { rangeRequestCount: 31, downloadedBytes: 0, rangeCacheHitCount: 0 }; + try { + await __apkUrlPreviewTestInternals.hydrateStoredNativeLibraryDataOffsets( + new URL("https://example.com/sample.apk"), + entries, + stats, + { contentLength: 2000 }, + ); + } finally { + globalThis.fetch = originalFetch; + } + + assert.deepEqual(requests, ["bytes=100-129"]); + assert.equal(stats.rangeRequestCount, 32); + assert.equal(Array.from(entries.values()).filter((entry) => entry.dataOffset != null).length, 1); +}); + test("remote ZIP directory parsing rejects damaged and out-of-bounds records", () => { const damaged = createCentralDirectoryEntry("AndroidManifest.xml"); damaged[0] = 0; @@ -124,3 +270,14 @@ function createCentralDirectoryEntry(name, options = {}) { bytes.set(nameBytes, 46); return bytes; } + +function createLocalFileHeader(name, options = {}) { + const nameBytes = textEncoder.encode(name); + const bytes = new Uint8Array(30); + const view = new DataView(bytes.buffer); + view.setUint32(0, 0x04034b50, true); + view.setUint16(8, options.compressionMethod ?? 8, true); + view.setUint16(26, nameBytes.byteLength, true); + view.setUint16(28, options.extraLength ?? 0, true); + return bytes; +} diff --git a/packages/shared/src/apk.js b/packages/shared/src/apk.js index 799983d..eb5ca32 100644 --- a/packages/shared/src/apk.js +++ b/packages/shared/src/apk.js @@ -2623,7 +2623,9 @@ async function collectNativeLibraries(source, options = {}) { } async function readNativeLibraryBinaryInfo(source, entry, parseElf, parserProfile = null) { - const info = {}; + const info = { + zipCompression: getNativeLibraryZipCompression(entry), + }; const zipAlignment = getNativeLibraryZipAlignment(entry); if (zipAlignment > 0) { info.zipAlignment = zipAlignment; @@ -2649,6 +2651,39 @@ async function readNativeLibraryBinaryInfo(source, entry, parseElf, parserProfil return info; } +function getNativeLibraryZipCompression(entry) { + if (entry.compressionMethod === ZIP_COMPRESSION_STORE) { + return "store"; + } + if (entry.compressionMethod === ZIP_COMPRESSION_DEFLATE) { + return "deflate"; + } + return "unknown"; +} + +export function applyNativeLibraryZipAlignments(nativeLibraries, zipEntries) { + if (!Array.isArray(nativeLibraries) || !(zipEntries instanceof Map)) { + return; + } + + for (const library of nativeLibraries) { + const entry = zipEntries.get(library?.path); + if (!entry) { + continue; + } + + library.zipCompression = getNativeLibraryZipCompression(entry); + const zipAlignment = getNativeLibraryZipAlignment(entry); + if (zipAlignment > 0) { + library.zipAlignment = zipAlignment; + library.zip16kbAligned = zipAlignment >= NATIVE_PAGE_SIZE_16_KB; + } else { + delete library.zipAlignment; + delete library.zip16kbAligned; + } + } +} + function getNativeLibraryZipAlignment(entry) { if (entry.compressionMethod !== ZIP_COMPRESSION_STORE || !entry.dataOffset) { return -1; diff --git a/packages/shared/src/contracts.js b/packages/shared/src/contracts.js index b0ff55d..d34bc9d 100644 --- a/packages/shared/src/contracts.js +++ b/packages/shared/src/contracts.js @@ -34,6 +34,7 @@ * @property {number=} size * @property {number=} elfPageSize * @property {boolean=} elf16kbAligned + * @property {"store" | "deflate" | "unknown"=} zipCompression * @property {number=} zipAlignment * @property {boolean=} zip16kbAligned * @property {SdkMarker | null=} sdk diff --git a/packages/shared/test/apk-icon.test.mjs b/packages/shared/test/apk-icon.test.mjs index b8af7d2..535d71a 100644 --- a/packages/shared/test/apk-icon.test.mjs +++ b/packages/shared/test/apk-icon.test.mjs @@ -132,6 +132,7 @@ test("annotates native libraries with ELF page size and ZIP alignment", async () assert.equal(libraries[0].name, "libpage.so"); assert.equal(libraries[0].elfPageSize, 0x4000); assert.equal(libraries[0].elf16kbAligned, true); + assert.equal(libraries[0].zipCompression, "store"); assert.equal(libraries[0].zipAlignment, 4096); assert.equal(libraries[0].zip16kbAligned, false); }); @@ -155,6 +156,9 @@ test("reads deflated native library ELF info without full entry extraction", asy assert.equal(libraries.length, 1); assert.equal(libraries[0].elfPageSize, 0x4000); assert.equal(libraries[0].elf16kbAligned, true); + assert.equal(libraries[0].zipCompression, "deflate"); + assert.equal(libraries[0].zipAlignment, undefined); + assert.equal(libraries[0].zip16kbAligned, undefined); assert.ok(stages.find((stage) => stage.stage === "parse-native-elf").bytes < elfBytes.byteLength); }); From 97d62d863bfec0825c5db9dff4c9662198cc59d4 Mon Sep 17 00:00:00 2001 From: Absinthe Date: Thu, 16 Jul 2026 17:14:33 +0800 Subject: [PATCH 7/8] Add typed SDK summary preview labels --- packages/apk-webui/src/app/report-renderer.js | 92 +++++++++++++++++-- packages/apk-webui/src/report.css | 80 ++++++++++++++++ .../apk-webui/test/report-renderer.test.mjs | 42 +++++++++ packages/shared/src/contracts.js | 9 +- packages/shared/src/report-model.js | 17 +++- packages/shared/src/sdk-markers.js | 36 ++++++-- packages/shared/test/report-model.test.mjs | 1 + packages/shared/test/sdk-markers.test.mjs | 39 ++++++++ 8 files changed, 298 insertions(+), 18 deletions(-) diff --git a/packages/apk-webui/src/app/report-renderer.js b/packages/apk-webui/src/app/report-renderer.js index 9b8c16e..aa371a9 100644 --- a/packages/apk-webui/src/app/report-renderer.js +++ b/packages/apk-webui/src/app/report-renderer.js @@ -315,6 +315,7 @@ function formatSvgNumber(value) { function renderSummaryTab(report) { const model = buildWebReportViewModel(report); const featureHtml = renderFeaturePills(model.summary.features, report.featureIcons); + const componentPreviewKinds = buildComponentPreviewKindLookup(report.apkInfo.components); return sectionStack([ `
`, @@ -322,15 +323,20 @@ function renderSummaryTab(report) { `
`, section(t("summary"), renderKeyValueTable(model.summary.rows.map((row) => [row.label, row.value]))), section(t("buildFeatures"), featureHtml || emptyList(t("noBuildFeatures"))), - section(t("sdk"), renderSdkRows(model.summary.sdkPreview)), + section(t("sdk"), renderSdkRows(model.summary.sdkPreview, "component", componentPreviewKinds)), ]); } function renderSdkTab(report) { const summary = report.apkInfo.sdkSummary || {}; + const componentPreviewKinds = buildComponentPreviewKindLookup(report.apkInfo.components); return sectionStack([ - section(t("sdkNative"), renderSdkRows(summary.native || [])), - section(t("sdkComponents"), renderSdkRows(summary.components || [])), + section(t("sdkNative"), renderSdkRows(summary.native || [], "native")), + section(t("sdkComponents"), renderSdkRows( + summary.components || [], + "component", + componentPreviewKinds, + )), ]); } @@ -631,7 +637,7 @@ function renderRawTab(report) { ].join(""); } -function renderSdkRows(entries) { +function renderSdkRows(entries, fallbackPreviewKind = "component", componentPreviewKinds = new Map()) { if (!entries.length) { return emptyList(t("noSdkMarkers")); } @@ -644,7 +650,11 @@ function renderSdkRows(entries) { const { visibleItems, hiddenCount } = getVisibleItems(entries); const rows = visibleItems.map((entry) => { const width = Math.max(4, Math.round(((entry.count || 0) / max) * 100)); - const preview = renderCodeChipList(entry.previewItems || []); + const preview = renderSdkPreviewItems( + entry.previewItems || [], + entry.previewItemKind || fallbackPreviewKind, + componentPreviewKinds, + ); const detail = joinTextParts([entry.source, formatSdkSummaryDetail(entry)]); return [ `
`, @@ -720,14 +730,82 @@ function parseLegacyNativeSdkSummaryDetail(detail) { return null; } -function renderCodeChipList(items) { +function renderSdkPreviewItems(items, fallbackKind, componentPreviewKinds) { let html = ""; for (const item of items) { - html += codeChip(item); + const normalized = normalizeSdkPreviewItem(item, fallbackKind, componentPreviewKinds); + if (!normalized) { + continue; + } + html += [ + ``, + `${escapeHtml(getSdkPreviewKindLabel(normalized.kind))}`, + `${escapeHtml(normalized.name)}`, + ``, + ].join(""); } return html; } +function normalizeSdkPreviewItem(item, fallbackKind, componentPreviewKinds) { + const normalizedFallback = normalizeSdkPreviewKind(fallbackKind, "component"); + if (typeof item === "string") { + return item ? { + name: item, + kind: componentPreviewKinds.get(item) || normalizedFallback, + } : null; + } + if (!item || typeof item !== "object" || typeof item.name !== "string" || !item.name) { + return null; + } + return { + name: item.name, + kind: normalizeSdkPreviewKind( + item.kind, + componentPreviewKinds.get(item.name) || normalizedFallback, + ), + }; +} + +function buildComponentPreviewKindLookup(components = {}) { + const lookup = new Map(); + const sectionKinds = { + activities: "activity", + services: "service", + receivers: "receiver", + providers: "provider", + }; + for (const [sectionName, kind] of Object.entries(sectionKinds)) { + for (const component of components?.[sectionName] || []) { + if (component.name) { + lookup.set(component.name, kind); + } + if (component.shortName) { + lookup.set(component.shortName, kind); + } + } + } + return lookup; +} + +function normalizeSdkPreviewKind(kind, fallbackKind) { + return SDK_PREVIEW_KINDS.has(kind) ? kind : fallbackKind; +} + +const SDK_PREVIEW_KIND_LABELS = Object.freeze({ + native: "ELF", + activity: "Activity", + service: "Service", + receiver: "Receiver", + provider: "Provider", + component: "Component", +}); +const SDK_PREVIEW_KINDS = new Set(Object.keys(SDK_PREVIEW_KIND_LABELS)); + +function getSdkPreviewKindLabel(kind) { + return SDK_PREVIEW_KIND_LABELS[kind] || SDK_PREVIEW_KIND_LABELS.component; +} + function joinTextParts(parts) { const values = []; for (const part of parts) { diff --git a/packages/apk-webui/src/report.css b/packages/apk-webui/src/report.css index 247c632..ac8cee3 100644 --- a/packages/apk-webui/src/report.css +++ b/packages/apk-webui/src/report.css @@ -1086,6 +1086,78 @@ padding: 12px 14px; } +.sdk-preview-item { + --sdk-preview-tone: #737880; + + display: inline-flex; + align-items: stretch; + min-width: 0; + max-width: 100%; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--sdk-preview-tone) 46%, var(--line)); + border-radius: 10px; + background: color-mix(in srgb, var(--sdk-preview-tone) 10%, var(--surface-soft)); +} + +.sdk-preview-item--native { + --sdk-preview-tone: #6687a2; +} + +.sdk-preview-item--activity { + --sdk-preview-tone: #836e99; +} + +.sdk-preview-item--service { + --sdk-preview-tone: #588676; +} + +.sdk-preview-item--receiver { + --sdk-preview-tone: #987b49; +} + +.sdk-preview-item--provider { + --sdk-preview-tone: #97697d; +} + +.sdk-preview-kind, +.sdk-preview-name { + display: inline-flex; + align-items: center; +} + +.sdk-preview-kind { + flex: 0 0 auto; + padding: 0.42rem 0.6rem; + border-inline-end: 1px solid color-mix(in srgb, var(--sdk-preview-tone) 52%, var(--line)); + background: color-mix(in srgb, var(--sdk-preview-tone) 38%, var(--surface)); + color: color-mix(in srgb, var(--sdk-preview-tone) 78%, var(--control-text)); + font-size: 0.72rem; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.sdk-preview-name { + min-width: 0; + padding: 0.42rem 0.72rem; + color: var(--control-text); + font-family: "Cascadia Code", "JetBrains Mono", Consolas, monospace; + font-size: 0.9rem; + line-height: 1.35; + overflow-wrap: anywhere; +} + +:root[data-color-scheme="dark"] .sdk-preview-item { + border-color: color-mix(in srgb, var(--sdk-preview-tone) 52%, var(--line)); + background: color-mix(in srgb, var(--sdk-preview-tone) 16%, var(--surface-soft)); +} + +:root[data-color-scheme="dark"] .sdk-preview-kind { + border-inline-end-color: color-mix(in srgb, var(--sdk-preview-tone) 58%, var(--line)); + background: color-mix(in srgb, var(--sdk-preview-tone) 34%, var(--surface)); + color: color-mix(in srgb, var(--sdk-preview-tone) 38%, var(--control-text)); +} + .sdk-count { flex: 0 0 auto; padding: 0.2rem 0.58rem; @@ -1231,6 +1303,14 @@ @media (max-width: 480px) { + .sdk-preview-item { + width: 100%; + } + + .sdk-preview-name { + flex: 1 1 auto; + } + .archive-chart-body { grid-template-columns: 1fr; justify-items: center; diff --git a/packages/apk-webui/test/report-renderer.test.mjs b/packages/apk-webui/test/report-renderer.test.mjs index 0431423..87f738f 100644 --- a/packages/apk-webui/test/report-renderer.test.mjs +++ b/packages/apk-webui/test/report-renderer.test.mjs @@ -60,6 +60,7 @@ test("native SDK summary details follow the active locale", () => { const englishHtml = reportRenderer.renderTabPanelHtml(report); assert.match(englishHtml, /1 library name · 4 files · ABI arm64-v8a, x86_64/); assert.doesNotMatch(englishHtml, /个库名|个文件/); + assert.match(englishHtml, /data-sdk-preview-kind="native"[\s\S]*sdk-preview-kind">ELF { assert.doesNotMatch(html, /个库名|个文件/); }); +test("SDK summaries render typed two-part preview items and support legacy strings", () => { + const report = createReport({ + components: { + services: [{ name: "com.example.LegacyService", shortName: "LegacyService" }], + }, + sdkSummary: { + native: [{ + label: "Native SDK", + count: 1, + previewItems: [{ name: "lib.so", kind: "native" }], + }], + components: [{ + label: "Component SDK", + count: 6, + previewItems: [ + { name: "MainActivity", kind: "activity" }, + { name: "SyncService", kind: "service" }, + { name: "BootReceiver", kind: "receiver" }, + { name: "DataProvider", kind: "provider" }, + "LegacyService", + "LegacyComponent", + ], + }], + }, + }); + + setupRenderer("sdk"); + const html = reportRenderer.renderTabPanelHtml(report); + + assert.equal(count(html, `ELFActivityServiceReceiverProviderService[\s\S]*LegacyService/); + assert.match(html, /sdk-preview-item--component" data-sdk-preview-kind="component"[\s\S]*sdk-preview-kind">Component[\s\S]*LegacyComponent/); + assert.match(html, /lib<unsafe>\.so/); + assert.doesNotMatch(html, /lib\.so/); +}); + test("report renderer caps long permission lists", () => { setupRenderer("permissions"); const permissions = Array.from({ length: reportRenderer.REPORT_LIST_RENDER_LIMIT + 3 }, (_, index) => ( diff --git a/packages/shared/src/contracts.js b/packages/shared/src/contracts.js index d34bc9d..195be5c 100644 --- a/packages/shared/src/contracts.js +++ b/packages/shared/src/contracts.js @@ -151,6 +151,13 @@ /** @typedef {Record} LibCheckerRuleDetailMap */ /** @typedef {"exact" | "regex" | "action"} SdkMarkerMatchSource */ +/** @typedef {"native" | "activity" | "service" | "receiver" | "provider" | "component"} SdkSummaryPreviewKind */ + +/** + * @typedef {object} SdkSummaryPreviewItem + * @property {string} name + * @property {SdkSummaryPreviewKind} kind + */ /** * @typedef {object} SdkMarker @@ -178,7 +185,7 @@ * @property {number=} fileCount * @property {string[]=} abis * @property {string} detail - * @property {string[]} previewItems + * @property {(string | SdkSummaryPreviewItem)[]} previewItems */ /** diff --git a/packages/shared/src/report-model.js b/packages/shared/src/report-model.js index f0eb908..e344b7e 100644 --- a/packages/shared/src/report-model.js +++ b/packages/shared/src/report-model.js @@ -166,17 +166,28 @@ export function buildFeatureLabel(name, version) { export function getSdkSummaryPreviewEntries(sdkSummary, sourceLabels = {}, limit = 8) { const combined = []; - appendSdkSummaryEntries(combined, sdkSummary?.native, sourceLabels.native || "Native Libraries"); - appendSdkSummaryEntries(combined, sdkSummary?.components, sourceLabels.components || "Components"); + appendSdkSummaryEntries( + combined, + sdkSummary?.native, + sourceLabels.native || "Native Libraries", + "native", + ); + appendSdkSummaryEntries( + combined, + sdkSummary?.components, + sourceLabels.components || "Components", + "component", + ); combined.sort((left, right) => right.count - left.count || left.label.localeCompare(right.label)); return combined.slice(0, limit); } -export function appendSdkSummaryEntries(target, entries = [], source) { +export function appendSdkSummaryEntries(target, entries = [], source, previewItemKind = "component") { for (const entry of entries || []) { target.push({ ...entry, source, + previewItemKind, }); } } diff --git a/packages/shared/src/sdk-markers.js b/packages/shared/src/sdk-markers.js index 357ac7d..cc13d67 100644 --- a/packages/shared/src/sdk-markers.js +++ b/packages/shared/src/sdk-markers.js @@ -4,6 +4,8 @@ import { isLibCheckerRule } from "./contracts.js"; /** @typedef {import("./contracts.js").LibCheckerRule} LibCheckerRule */ /** @typedef {import("./contracts.js").LibCheckerRuleDetailMap} LibCheckerRuleDetailMap */ /** @typedef {import("./contracts.js").SdkMarkerAnnotations} SdkMarkerAnnotations */ +/** @typedef {import("./contracts.js").SdkSummaryPreviewItem} SdkSummaryPreviewItem */ +/** @typedef {import("./contracts.js").SdkSummaryPreviewKind} SdkSummaryPreviewKind */ const RULE_TYPE_NATIVE = 0; const RULE_TYPE_SERVICE = 1; @@ -19,6 +21,14 @@ const COMPONENT_TYPE_LABELS = { providers: "Provider", }; +/** @type {Record} */ +const COMPONENT_PREVIEW_KINDS = { + activities: "activity", + services: "service", + receivers: "receiver", + providers: "provider", +}; + const QIHOO_NATIVE_LIBS = new Set([ "libjiagu.so", "libjiagu_a64.so", @@ -271,7 +281,9 @@ function summarizeNativeSdkMarkers(libraries, resolveIconUrl) { fileCount: entry.fileCount, abis: [...entry.abis].filter(Boolean).sort(), detail: buildNativeSummaryDetail(entry), - previewItems: [...entry.itemNames].slice(0, 4), + previewItems: [...entry.itemNames] + .slice(0, 4) + .map((name) => createSdkSummaryPreviewItem(name, "native")), })) .sort(compareSummaryEntries); } @@ -281,6 +293,7 @@ function summarizeComponentSdkMarkers(components, resolveIconUrl) { for (const [sectionName, items] of Object.entries(components)) { const componentTypeLabel = COMPONENT_TYPE_LABELS[sectionName] || "Component"; + const previewKind = COMPONENT_PREVIEW_KINDS[sectionName] || "component"; for (const component of items) { if (!component.sdk) { continue; @@ -298,14 +311,17 @@ function summarizeComponentSdkMarkers(components, resolveIconUrl) { detailKey: component.sdk.detailKey || null, ruleDetail: component.sdk.ruleDetail || null, count: 0, - items: [], + previewItems: [], componentKinds: new Map(), }; grouped.set(key, entry); } entry.count += 1; - entry.items.push(component); + const previewName = component.shortName || component.name; + if (previewName) { + entry.previewItems.push(createSdkSummaryPreviewItem(previewName, previewKind)); + } entry.componentKinds.set( componentTypeLabel, (entry.componentKinds.get(componentTypeLabel) || 0) + 1, @@ -324,14 +340,20 @@ function summarizeComponentSdkMarkers(components, resolveIconUrl) { ruleDetail: entry.ruleDetail || null, count: entry.count, detail: buildComponentSummaryDetail(entry), - previewItems: entry.items - .map((item) => item.shortName || item.name) - .filter(Boolean) - .slice(0, 4), + previewItems: entry.previewItems.slice(0, 4), })) .sort(compareSummaryEntries); } +/** + * @param {string} name + * @param {SdkSummaryPreviewKind} kind + * @returns {SdkSummaryPreviewItem} + */ +function createSdkSummaryPreviewItem(name, kind) { + return { name, kind }; +} + function buildNativeSummaryDetail(entry) { const abiText = [...entry.abis].sort().join(", "); if (!abiText) { diff --git a/packages/shared/test/report-model.test.mjs b/packages/shared/test/report-model.test.mjs index 276faa4..dfbd81a 100644 --- a/packages/shared/test/report-model.test.mjs +++ b/packages/shared/test/report-model.test.mjs @@ -69,6 +69,7 @@ test("APK report view model keeps shared report sections aligned", () => { assert.equal(model.summary.rows[fileNameRowIndex + 1]?.key, "downloadUrl"); assert.equal(model.summary.rows[fileNameRowIndex + 1]?.value, "https://example.com/sample.apk"); assert.equal(model.summary.sdkPreview[0].label, "SDK A"); + assert.equal(model.summary.sdkPreview[0].previewItemKind, "native"); assert.equal(model.native.groups[0].abi, "arm64-v8a"); const signatureRows = model.signatures.certificates[0].rows; assert.equal(signatureRows.find((row) => row.key === "serialNumber")?.value, "Unknown"); diff --git a/packages/shared/test/sdk-markers.test.mjs b/packages/shared/test/sdk-markers.test.mjs index ad250cc..2ee5e04 100644 --- a/packages/shared/test/sdk-markers.test.mjs +++ b/packages/shared/test/sdk-markers.test.mjs @@ -38,4 +38,43 @@ test("native SDK summaries preserve structured counts for localized rendering", assert.equal(annotated.nativeLibraries[0].sdk.ruleDetail, null); assert.equal(annotated.sdkSummary.native[0].detailKey, "0::libsample.so"); assert.equal(annotated.sdkSummary.native[0].ruleDetail, null); + assert.deepEqual(annotated.sdkSummary.native[0].previewItems, [ + { name: "libsample.so", kind: "native" }, + ]); +}); + +test("component SDK summaries retain the manifest component type", () => { + const apkInfo = { + nativeLibraries: [], + components: { + activities: [{ name: "com.example.MainActivity", actions: ["com.example.SDK_ACTION"] }], + services: [{ name: "com.example.SyncService" }], + receivers: [{ name: "com.example.BootReceiver" }], + providers: [{ name: "com.example.DataProvider" }], + }, + buildFeatures: {}, + }; + const commonRule = { + label: "Sample SDK", + iconIndex: 1, + iconName: "ic_sample", + singleColorIcon: false, + isRegexRule: false, + regexName: null, + }; + const rules = [ + { ...commonRule, name: "com.example.SDK_ACTION", type: 9 }, + { ...commonRule, name: "com.example.SyncService", type: 1 }, + { ...commonRule, name: "com.example.BootReceiver", type: 3 }, + { ...commonRule, name: "com.example.DataProvider", type: 4 }, + ]; + + const annotated = annotateSdkMarkers(apkInfo, (iconName) => `/icons/${iconName}.svg`, rules); + + assert.deepEqual(annotated.sdkSummary.components[0].previewItems, [ + { name: "com.example.MainActivity", kind: "activity" }, + { name: "com.example.SyncService", kind: "service" }, + { name: "com.example.BootReceiver", kind: "receiver" }, + { name: "com.example.DataProvider", kind: "provider" }, + ]); }); From 826ac93c4a39684ae6a8e22d38f9dd82f5e38632 Mon Sep 17 00:00:00 2001 From: Absinthe Date: Thu, 16 Jul 2026 17:18:38 +0800 Subject: [PATCH 8/8] Clarify component name hierarchy --- packages/apk-webui/src/report.css | 4 +++- packages/apk-webui/test/style-boundaries.test.mjs | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/apk-webui/src/report.css b/packages/apk-webui/src/report.css index ac8cee3..d133ac7 100644 --- a/packages/apk-webui/src/report.css +++ b/packages/apk-webui/src/report.css @@ -753,11 +753,13 @@ } .component-name-prefix { - color: var(--control-text); + color: var(--muted); + font-weight: 600; } .component-name-simple { color: var(--heading-text); + font-weight: 700; } .component-detail-table .kv-row { diff --git a/packages/apk-webui/test/style-boundaries.test.mjs b/packages/apk-webui/test/style-boundaries.test.mjs index ae6c53f..b915f62 100644 --- a/packages/apk-webui/test/style-boundaries.test.mjs +++ b/packages/apk-webui/test/style-boundaries.test.mjs @@ -39,6 +39,8 @@ test("report and compare styles stay behind lazy module boundaries", async () => assert.match(compareController, /^import "\.\.\/compare\.css";/mu); assert.match(reportCss, /^\.group-block\s*\{[^}]*\bpadding:\s*14px;/msu); assert.match(reportCss, /^\.component-group-grid\s*\{[^}]*\bgrid-template-columns:\s*1fr;/msu); + assert.match(reportCss, /^\.component-name-prefix\s*\{[^}]*\bcolor:\s*var\(--muted\);[^}]*\bfont-weight:\s*600;/msu); + assert.match(reportCss, /^\.component-name-simple\s*\{[^}]*\bcolor:\s*var\(--heading-text\);[^}]*\bfont-weight:\s*700;/msu); assert.match(reportCss, /^\.permission-table-row\s*\{[^}]*\bgrid-template-columns:\s*minmax\(0,\s*1fr\);/msu); assert.doesNotMatch(compareCss, /^\.compare-diff-block\s*\{[^}]*\bpadding:/msu); });