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 9125e35..6fd7649 100644
--- a/packages/apk-webui/src/app/elf-detail-modal.js
+++ b/packages/apk-webui/src/app/elf-detail-modal.js
@@ -6,8 +6,12 @@ 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;
@@ -35,45 +39,98 @@ 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");
-
- if (!elements.dialog.open) {
- elements.dialog.showModal();
- }
+ 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.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 +246,28 @@ 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;
+ clearLoadingTimer();
+ clearCloseTimer();
+ dialog.classList.remove("is-open", "is-closing");
const target = restoreFocusTarget;
restoreFocusTarget = null;
target?.focus?.();
@@ -216,6 +283,34 @@ 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 clearLoadingTimer() {
+ if (!loadingTimer) {
+ return;
+ }
+ window.clearTimeout(loadingTimer);
+ loadingTimer = 0;
+}
+
+function clearCloseTimer() {
+ if (!closeTimer) {
+ return;
+ }
+ window.clearTimeout(closeTimer);
+ closeTimer = 0;
+}
+
function renderElfDetailModalShell(t) {
const closeLabel = t("lcappsClose");
return [
@@ -280,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 [
@@ -382,4 +482,3 @@ function renderDataTable(label, columns, rows, total, limit, t, truncated = fals
: "",
].join("");
}
-
diff --git a/packages/apk-webui/src/app/report-renderer.js b/packages/apk-webui/src/app/report-renderer.js
index f3353cd..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,
+ )),
]);
}
@@ -438,7 +444,7 @@ function renderComponentRow(component) {
``,
``,
renderComponentDetails(component),
@@ -446,6 +452,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 = "";
@@ -614,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"));
}
@@ -627,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 [
``,
@@ -703,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/elf-detail-modal.css b/packages/apk-webui/src/elf-detail-modal.css
index e208645..ab1b097 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,28 @@
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-dialog.has-source .elf-detail-panel {
+ height: min(880px, calc(100dvh - clamp(32px, 8vw, 72px)));
}
.elf-detail-header {
@@ -82,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;
@@ -91,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));
@@ -200,6 +243,7 @@
}
.elf-detail-kv {
+ position: relative;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
@@ -208,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);
@@ -405,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);
}
@@ -422,7 +479,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 +493,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/src/report.css b/packages/apk-webui/src/report.css
index 86cf230..d133ac7 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;
}
@@ -753,6 +752,16 @@
line-height: 1.35;
}
+.component-name-prefix {
+ color: var(--muted);
+ font-weight: 600;
+}
+
+.component-name-simple {
+ color: var(--heading-text);
+ font-weight: 700;
+}
+
.component-detail-table .kv-row {
grid-template-columns: minmax(92px, 145px) minmax(0, 1fr);
padding: 9px 12px;
@@ -1079,6 +1088,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;
@@ -1157,9 +1238,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);
}
@@ -1225,6 +1305,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-browser/webui.spec.mjs b/packages/apk-webui/test-browser/webui.spec.mjs
index 540d27d..15fa542 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,56 @@ 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 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;
+ });
+ expect(loadingRenderCount).toBe(0);
const spinnerShape = await body.evaluate((element) => {
const spinner = document.createElement("span");
@@ -114,7 +156,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 +168,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();
diff --git a/packages/apk-webui/test/report-renderer.test.mjs b/packages/apk-webui/test/report-renderer.test.mjs
index 5386cf9..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);
setupRenderer("sdk", {}, {
locale: "zh-Hans",
@@ -91,6 +92,47 @@ test("native SDK summary localizes legacy stored detail text", () => {
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, `ELF);
+ assert.match(html, /sdk-preview-item--activity" data-sdk-preview-kind="activity"[\s\S]*sdk-preview-kind">Activity);
+ assert.match(html, /sdk-preview-item--service" data-sdk-preview-kind="service"[\s\S]*sdk-preview-kind">Service);
+ assert.match(html, /sdk-preview-item--receiver" data-sdk-preview-kind="receiver"[\s\S]*sdk-preview-kind">Receiver);
+ assert.match(html, /sdk-preview-item--provider" data-sdk-preview-kind="provider"[\s\S]*sdk-preview-kind">Provider);
+ assert.match(html, /sdk-preview-item--service" data-sdk-preview-kind="service"[\s\S]*sdk-preview-kind">Service[\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) => (
@@ -119,6 +161,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) => ({
@@ -211,10 +272,45 @@ 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);
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("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", () => {
diff --git a/packages/apk-webui/test/style-boundaries.test.mjs b/packages/apk-webui/test/style-boundaries.test.mjs
index 4abbfc2..b915f62 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,
@@ -38,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);
});
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..195be5c 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
@@ -150,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
@@ -177,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/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);
});
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" },
+ ]);
});