From d5addb9fe467e78822754d91f915800818143d3e Mon Sep 17 00:00:00 2001 From: "Nikita Savinov (night agent)" Date: Fri, 29 May 2026 04:25:14 +0300 Subject: [PATCH] =?UTF-8?q?Add=20Compose=20hierarchy=20inspector=20(previe?= =?UTF-8?q?w)=20=E2=80=94=20Tools=20=E2=86=92=20YALI=20=E2=86=92=20Inspect?= =?UTF-8?q?=20Compose=20Hierarchy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new menu action that captures the current screen via `uiautomator dump` and analyses it with a Compose-aware heuristic to produce a summary dialog. The action is independent of the existing capture flow and does not modify any code paths used by Launch YALI. What the new action does: - enumerates connected devices via the existing ConnectedDeviceInfoProvider; - runs `adb shell uiautomator dump`, pulls the XML and parses it with a SAX handler; - classifies nodes as "Compose candidates" by two rules: 1. any node whose ancestor is `AndroidComposeView` / `ComposeView`; 2. any `android.view.View` with empty resource-id and a non-empty text or content-description (typical for Compose semantics leaves); - displays a Swing dialog with the summary stats (total / visible / Compose roots / Compose candidates / nodes with text / nodes with content-desc / nodes with resource-id), a tree of Compose candidates and three side tables (top classes, top texts, top content-descriptions); - `Copy summary to clipboard` button serialises stats as JSON-like text. Files added: - docs/COMPOSE_INSPECTOR_SPEC.md — technical spec covering the proper App Inspection / compose-ui-inspection.jar approach used by Android Studio and explaining why this iteration ships the UI Automator-based Layer 0 (with a roadmap for Layers 1 and 2). - proto/compose_layout_inspection.proto — vendored protocol file (Apache 2.0, originates from AOSP / Android Studio source). Not used at build time yet, kept for the next iteration. - src/main/kotlin/com/github/grishberg/android/li/compose/* — implementation: - InspectComposeHierarchyAction.kt — the action; - ComposeHierarchyAnalyzer.kt — SAX parser + heuristic classifier; - ComposeInspectDialog.kt — Swing UI; - model/ComposeCandidateNode.kt — TreeNode for the Compose candidate tree; - model/ComposeHierarchySummary.kt — summary data class. - src/main/resources/META-INF/plugin.xml — registers the new action under the existing `YALI.TopMenu` group, no other changes. Why UI Automator first: The full Compose tree (with @Composable names, source file / line, parameter values, recomposition counts) requires Android Studio's App Inspection framework: a transport daemon pushed to the device deploys `compose-ui-inspection.jar` into the target process via JVMTI, and the IDE talks to it over RSocket-on-UDS using the protobuf in `proto/compose_layout_inspection.proto`. That path involves Studio internal APIs in `com.android.tools.idea.appinspection.*`, which are unstable across Studio releases and require non-trivial wiring. UI Automator is a much simpler proxy: it walks the accessibility tree, which exposes Compose nodes that opt in via `Modifier.semantics { ... }`. We lose internal Compose details but get bounds, text, content-desc, resource-id and clickable/focusable info — enough to ship a useful summary today and to validate the UI surface before committing to the full agent integration. See docs/COMPOSE_INSPECTOR_SPEC.md for the staged roadmap. --- docs/COMPOSE_INSPECTOR_SPEC.md | 262 +++++++++++++++ proto/compose_layout_inspection.proto | 304 ++++++++++++++++++ .../li/compose/ComposeHierarchyAnalyzer.kt | 230 +++++++++++++ .../li/compose/ComposeInspectDialog.kt | 188 +++++++++++ .../compose/InspectComposeHierarchyAction.kt | 126 ++++++++ .../li/compose/model/ComposeCandidateNode.kt | 45 +++ .../compose/model/ComposeHierarchySummary.kt | 18 ++ src/main/resources/META-INF/plugin.xml | 4 + 8 files changed, 1177 insertions(+) create mode 100644 docs/COMPOSE_INSPECTOR_SPEC.md create mode 100644 proto/compose_layout_inspection.proto create mode 100644 src/main/kotlin/com/github/grishberg/android/li/compose/ComposeHierarchyAnalyzer.kt create mode 100644 src/main/kotlin/com/github/grishberg/android/li/compose/ComposeInspectDialog.kt create mode 100644 src/main/kotlin/com/github/grishberg/android/li/compose/InspectComposeHierarchyAction.kt create mode 100644 src/main/kotlin/com/github/grishberg/android/li/compose/model/ComposeCandidateNode.kt create mode 100644 src/main/kotlin/com/github/grishberg/android/li/compose/model/ComposeHierarchySummary.kt diff --git a/docs/COMPOSE_INSPECTOR_SPEC.md b/docs/COMPOSE_INSPECTOR_SPEC.md new file mode 100644 index 0000000..4666c9e --- /dev/null +++ b/docs/COMPOSE_INSPECTOR_SPEC.md @@ -0,0 +1,262 @@ +# YALI Compose Inspector — техническое задание + +Документ описывает архитектуру и план реализации расширения YALI, которое умеет +извлекать иерархию Jetpack Compose из запущенного на устройстве приложения и +показывать сводную информацию о ней. + +## 1. Контекст + +Текущий YALI вытаскивает View hierarchy через два независимых канала: + +1. **Legacy `ViewDebug` протокол** (`ClientWindow.loadWindowData` → + `ViewNodeParser`) — даёт классический `View` tree и скриншот. +2. **UI Automator dump** (`adb shell uiautomator dump` → SAX-парсер + `HierarchyDumpParser`) — даёт XML accessibility-дерева, в т.ч. для + Compose-нод (Compose рисует семантику через accessibility-бридж). + +Оба способа не дают полноценную Compose-иерархию: ViewDebug видит +`AndroidComposeView` как один лист, UI Automator показывает только листы с +merged semantics — без названий `@Composable`, без файла/строки исходника, без +параметров. + +## 2. Эталонная реализация (Android Studio) + +Android Studio с релиза 2021.1 использует **App Inspection framework**: + +- На устройстве работает **transport daemon** (`/data/local/tmp/perfd`), который + IDE деплоит при первом подключении. +- IDE через transport деплоит в процесс приложения dex-агент + **`compose-ui-inspection.jar`** (берётся либо из артефакта + `androidx.compose.ui:ui-inspection`, либо из bundled `plugins/android/resources` + Android Studio). +- Агент инжектится в JVM целевого процесса через JVMTI и регистрируется + под идентификатором **`layoutinspector.compose.inspection`**. +- Связь IDE ↔ агент — protobuf-сообщения через transport (RSocket поверх UDS). + +### 2.1. Протокол + +Файл протокола: `compose/ui/ui-inspection/src/main/proto/layout_inspector_compose.proto` +в AOSP (`platform/frameworks/support`). Java-пакет — +`layoutinspector.compose.inspection.LayoutInspectorComposeProtocol`. + +Ключевые сообщения: + +- **`Command { GetComposablesCommand | GetParametersCommand | … }`** — + запрос от IDE. +- **`Response { GetComposablesResponse | GetParametersResponse | … }`** — + ответ агента. +- **`GetComposablesResponse`** содержит `repeated StringEntry strings` (таблица + строк, в неё ссылаются `int32`-поля типа `name`, `filename`) и + `repeated ComposableRoot roots`. +- **`ComposableNode`** — узел Compose-дерева: + - `sint64 id` — уникальный id. + - `repeated ComposableNode children`. + - `int32 package_hash`, `int32 filename`, `int32 line_number`, `int32 offset`, + `int32 name` — источник (ссылки в string table). + - `Bounds bounds` (Rect `layout` + Quad `render` с трансформациями). + - `int32 flags` — битовая маска (`SYSTEM_CREATED`, + `HAS_MERGED_SEMANTICS`, `HAS_UNMERGED_SEMANTICS`, `INLINED`, + `NESTED_SINGLE_CHILDREN`, `HAS_DRAW_MODIFIER`, `HAS_CHILD_DRAW_MODIFIER`). + - `int64 view_id` — id хост-View. + - `int32 recompose_count`, `int32 recompose_skips`, `sint32 anchor_hash`. + +Полный proto-файл хранится в репозитории — `proto/compose_layout_inspection.proto`. + +### 2.2. Поток в Studio + +1. Studio запрашивает список процессов через transport, фильтрует debuggable. +2. Пользователь выбирает процесс → Studio проверяет версию + `androidx.compose.ui:ui` (минимум `1.2.1`). +3. Studio вызывает `apiServices.launchInspector(LaunchParameters)` — + transport деплоит `compose-ui-inspection.jar` в процесс приложения. +4. Studio отправляет `Command{GetComposables}` для каждого root View. Агент + ходит по `AndroidComposeView`, через рефлексию получает `SlotTree` + + `SemanticsOwner`, собирает дерево, сериализует в protobuf. +5. Дерево соединяется с View tree (по `view_id` у root) и рисуется. + +## 3. Что доступно YALI + +YALI — это плагин для Android Studio (`bundledPlugin("org.jetbrains.android")`). +В принципе у плагина есть доступ ко всем bundled-API Studio, включая +`com.android.tools.idea.appinspection.api.AppInspectionApiServices`. + +Но привязка к этим внутренним API: + +- **хрупкая** — пакеты `com.android.tools.idea.appinspection.*` меняются от + версии к версии (новые поля в `LaunchParameters`, переименования + `InspectorClientLaunchMonitor`). +- **слабо документирована** — нет публичного компилируемого SDK. +- **требует точного матчинга версий** транспорта и `compose-ui-inspection.jar`. + +Альтернативный путь — собственный JDWP-канал. Но рабочий JDWP-канал к +App Inspection-агенту не существует: агент устанавливается через JVMTI и +слушает Unix domain socket, не JDWP DDMS chunks (попытка такого подхода видна +в существующей ветке `compose` и не работает). + +## 4. Стратегия реализации + +Целевая архитектура — **многоуровневая**, чтобы дать пользу сразу и постепенно +докручивать качество. + +### 4.1. Layer 0 — UI Automator + Compose-aware анализ (MVP, эта итерация) + +**Что:** новое action `Inspect Compose Hierarchy` в меню `Tools → YALI`. По +выбору устройства YALI запускает существующий `HierarchyDump` +(`adb shell uiautomator dump`), парсит, анализирует и показывает в диалоге: + +- общую статистику дерева (всего нод, видимых нод, активити, окон); +- сводку **Compose-нод**: эвристика — нода с + `class="android.view.View"` (Compose semantic) **или** содержащая + предка `androidx.compose.ui.platform.AndroidComposeView`; +- топ-список текстов, content-description'ов, кликабельных нод; +- распределение классов в дереве; +- сами Compose-ноды в JTree (с координатами, текстом, content-desc). + +**Зачем:** + +- работает на любом debuggable-устройстве API ≥ 21 без агентов; +- ничего не ломает в существующем flow YALI; +- даёт пользователю real-life диагностический инструмент уже сейчас; +- независимо от версии Compose и от внутренних API Android Studio; +- является «единым каналом данных», который сложно сделать сильно лучше без + агента. + +**Ограничения:** + +- видим только нод с **merged semantics**; чисто декоративные Compose-узлы + без `Modifier.semantics{}` не попадают. +- нет имён `@Composable` функций, нет файла/строки. + +### 4.2. Layer 1 — встроенный App Inspection (следующая итерация) + +Через bundled-API Studio: + +```kotlin +val apiServices = AppInspectionDiscoveryService.getInstance(project).apiServices +val messenger = apiServices.launchInspector( + LaunchParameters( + processDescriptor, + inspectorId = "layoutinspector.compose.inspection", + jar = AppInspectorJar( + name = "compose-ui-inspection.jar", + developmentDirectory = null, + releaseDirectory = "plugins/android/resources/app-inspection/", + ), + projectName = project.name, + minLibraryVersion = "1.2.1", + force = true, + ) +) +val response = messenger.sendRawCommand( + Command.newBuilder().apply { + getComposablesBuilder.apply { + rootViewId = viewId + skipSystemComposables = true + generation = 0 + extractAllParameters = false + } + }.build().toByteArray() +) +val parsed = Response.parseFrom(response) +``` + +На этом уровне получаем полноценный `ComposableNode` tree с именами, +координатами, флагами и recomposition counts. + +**Что нужно сделать в этой итерации:** + +1. Подключить `protobuf` gradle-plugin, сгенерировать java-классы из + `proto/compose_layout_inspection.proto`. +2. Реализовать `AppInspectionLauncher` на тонком reflection wrapper над + `com.android.tools.idea.appinspection.*` (чтобы плагин компилировался + против разных версий Studio). +3. Реализовать `ComposeTreeFetcher` — sendCommand → parse → mapping + в внутренний `ComposeViewNode`. +4. Прикрутить `ComposeViewNode` к существующему `TreeMerger`, чтобы + Compose-ноды соединялись с View-нодами по `view_id`. + +### 4.3. Layer 2 — UI рендеринг и фичи (на будущее) + +- Отрисовка Compose-нод в `LayoutPanel` поверх скриншота (Quad-трансформации + из `Bounds.render`). +- Boostrapping параметров (`GetParametersCommand`) с лениво подгружаемыми + значениями. +- Highlight recompositions (`UpdateSettingsCommand` → + `highlight_recompositions = true`). +- Прыжок в исходник по клику (использовать `package_hash` + `filename` + + `line_number` → `PsiManager.findFile`). + +## 5. Архитектура MVP (Layer 0) + +``` +com.github.grishberg.android.li.compose +├── InspectComposeHierarchyAction.kt // меню Tools → YALI → Inspect Compose +├── ComposeInspectDialog.kt // Swing-окно со сводкой + tree +├── ComposeHierarchyAnalyzer.kt // эвристика + статистика +└── model + ├── ComposeHierarchySummary.kt // total/visible/clickable/textCount... + └── ComposeCandidateNode.kt // подсвеченные Compose-кандидаты +``` + +`InspectComposeHierarchyAction`: + +1. Получает `IDevice` через существующий `ConnectedDeviceInfoProvider`. +2. Если устройств несколько — показывает `ChooseDeviceDialog` (минимальный + `JList`). +3. Запускает `HierarchyDump.getHierarchyDump()` (переиспользует существующий + класс — он уже умеет `uiautomator dump` + `pullFile`). +4. Парсит через `HierarchyDumpParser` (тоже существующий). +5. Передаёт корень в `ComposeHierarchyAnalyzer`. +6. Открывает `ComposeInspectDialog` с результатом. + +`ComposeHierarchyAnalyzer`: + +- Обход дерева `DumpViewNode`. +- Накопление: + - `totalNodes` — всего; + - `visibleNodes` — у которых width>0 и height>0; + - `clickableNodes`, `focusableNodes` — UI Automator при `--compressed` режиме + их не отдаёт, поэтому в MVP считаем по другим эвристикам (текст / нон-нулл + content-desc); + - `composeCandidates` — ноды, где + `name == "android.view.View"` И `id == null` И + (есть `text` ИЛИ есть `content-desc`) + либо предок которых — `AndroidComposeView`/`ComposeView`. + - `classDistribution` — гистограмма по класс-имени; + - `topTexts` / `topContentDescs` — топ-30 (для быстрой ориентации в экране). + +`ComposeInspectDialog`: + +- Верхняя панель — сводка строк (`Total: 142, Compose candidates: 37, …`). +- Левая половина — `JTree` Compose-кандидатов с координатами и текстом. +- Правая половина — `JTable` распределения классов. +- Снизу — кнопка `Copy summary to clipboard` (JSON) и `Refresh`. + +## 6. Совместимость и риски + +- YALI на master уже Kotlin 2.2, Java 21, IntelliJ Platform Gradle Plugin 2.10 + и Android Studio API 2025.3.1.1 (sinceBuild `253.28294.334`). Все новые файлы + следуют этим версиям. Никаких новых зависимостей в MVP не нужно — задача + решается уже подключённым `kotlinx.coroutines` + bundled IntelliJ Swing. +- UI Automator dump может быть пустым на некоторых устройствах (особенно с + кастомными прошивками или в момент анимации). Action отдельно сообщает + пользователю через `NotificationHelper.error`. +- На устройствах без debuggable-процессов action всё равно работает — UI + Automator слушает Accessibility, а не debug-канал. + +## 7. План работ + +- [x] Исследование протокола App Inspection / Compose UI Inspection. +- [x] Документ ТЗ (этот файл). +- [x] Layer 0 implementation: action + analyzer + dialog. +- [x] Регистрация в `plugin.xml`. +- [x] Локальный билд `./gradlew buildPlugin`. +- [ ] (next iteration) Layer 1: protobuf, AppInspection launcher. +- [ ] (next iteration) Layer 2: визуализация поверх скриншота. + +## 8. Источники + +- AOSP `androidx.compose.ui:ui-inspection` — реализация on-device агента. +- Android Studio plugin `layout-inspector` (JetBrains/android, + `layout-inspector/src/com/android/tools/idea/layoutinspector/pipeline/appinspection/compose/`). +- Существующая ветка `origin/compose` этого репозитория (отсюда взят proto-файл). diff --git a/proto/compose_layout_inspection.proto b/proto/compose_layout_inspection.proto new file mode 100644 index 0000000..5288589 --- /dev/null +++ b/proto/compose_layout_inspection.proto @@ -0,0 +1,304 @@ +syntax = "proto3"; +package layoutinspector.compose.inspection; +option java_package = "layoutinspector.compose.inspection"; +option java_outer_classname = "LayoutInspectorComposeProtocol"; + +// ======= MESSAGES ======= + +// A mapping of |string| to |int32|, so strings can be efficiently reused across nodes +// Any time a text value in any of these messages has an |int32| type, it means it will do a +// lookup in a string table on the client. +message StringEntry { + int32 id = 1; + string str = 2; +} + +message Point { + int32 x = 1; + int32 y = 2; +} + +message Rect { + int32 x = 1; + int32 y = 2; + int32 w = 3; + int32 h = 4; +} + +// A Quad holds the 4 corners of a polygon in drawing order, that represent the transformed shape +// of a Rect after applying some affine or perspective transformations. +message Quad { + sint32 x0 = 1; + sint32 y0 = 2; + sint32 x1 = 3; + sint32 y1 = 4; + sint32 x2 = 5; + sint32 y2 = 6; + sint32 x3 = 7; + sint32 y3 = 8; +} + +message Bounds { + // The bounds of some element in the layout tree + Rect layout = 1; + // Bounds transformed in preparation for final rendering + Quad render = 2; +} + +message ComposableRoot { + // The ID of the View this Composable tree is rooted underneath + int64 view_id = 1; + // All composables owned by this view (usually just one but could be more) + repeated ComposableNode nodes = 2; + // All views owned by this view that should be hidden + repeated int64 views_to_skip = 3; +} + +message ComposableNode { + sint64 id = 1; + repeated ComposableNode children = 2; + + // The hash of the file's package, for disambiguating same filenames in different folders + int32 package_hash = 3; + int32 filename = 4; // The file this Composable is defined in + int32 line_number = 5; // The line number within the file + int32 offset = 6; // Offset into the file for the exact character position + + int32 name = 7; + + Bounds bounds = 8; + + enum Flags { + NONE = 0; + SYSTEM_CREATED = 0x1; + HAS_MERGED_SEMANTICS = 0x2; + HAS_UNMERGED_SEMANTICS = 0x4; + INLINED = 0x8; + // If a node has the NESTED_SINGLE_CHILDREN flag, it means the children should be + // interpreted as a subtree of single nodes. + NESTED_SINGLE_CHILDREN = 0x10; + // This node (or a child that was filtered out) has a DrawModifierNode + HAS_DRAW_MODIFIER = 0x20; + // This node has a system child with a DrawModifierNode + HAS_CHILD_DRAW_MODIFIER = 0x40; + } + int32 flags = 9; + + int64 view_id = 10; + + // The number of recompositions detected since the last counter reset + int32 recompose_count = 11; + + // The number of recomposition skips detected since the last counter reset + int32 recompose_skips = 12; + + // The unique id of an anchor. This used to be its hashcode. See InspectorNode.anchorId. + sint32 anchor_hash = 13; +} + +// In Android, a resource id is a simple integer. This class holds the namespace, type, and name +// of such a resource id. +// For example, with "@android:id/textView": +// type: id +// namespace: android +// name: textView +message Resource { + int32 type = 1; + int32 namespace = 2; + int32 name = 3; +} + +// Data that helps us identify a lambda block in code +message LambdaValue { + int32 package_name = 1; // the package part of the containing class name + int32 file_name = 2; // the file name of the containing class + int32 lambda_name = 3; // the name of the lambda class + int32 function_name = 4; // the function name if this is a function reference + int32 start_line_number = 5; // the line number of the start of the lambda + int32 end_line_number = 6; // the line number of the end of the lambda +} + +// Parameters to a @Composable function +message Parameter { + enum Type { + UNSPECIFIED = 0; + STRING = 1; + BOOLEAN = 2; + DOUBLE = 3; + FLOAT = 4; + INT32 = 5; + INT64 = 6; + COLOR = 7; + RESOURCE = 8; + DIMENSION_DP = 9; + DIMENSION_SP = 10; + DIMENSION_EM = 11; + LAMBDA = 12; + FUNCTION_REFERENCE = 13; + ITERABLE = 14; + } + + Type type = 1; + int32 name = 2; + repeated Parameter elements = 3; + ParameterReference reference = 4; + + // For elements inside another Parameter instance, this index refer to the + // "natural" index of the parent composite value in the agent. + // + // We record this to be able to identify a reference to another parameter in + // the client and agent. Note that e.g: + // - null elements in a List are omitted + // A reference will indicate the index among all values, such that we don't + // have to count nulls during a GetParameterDetailsCommand. + sint32 index = 5; + + oneof value { + int32 int32_value = 11; + int64 int64_value = 12; + double double_value = 13; + float float_value = 14; + Resource resource_value = 15; + LambdaValue lambda_value = 16; + } +} + +// A reference to a "part" of a parameter value +message ParameterReference { + enum Kind { + UNSPECIFIED = 0; + NORMAL = 1; + MERGED_SEMANTICS = 2; + UNMERGED_SEMANTICS = 3; + } + + sint64 composable_id = 1; + + // Identifies an index into a ParameterGroup + int32 parameter_index = 2; + + // If the parameter value is a composite value such as: + // List + // then: + // composite_index[0] is the index into the List + // composite_index[1] is the index into the fields in MyClass + // composite_index[2] is the index into the field found by [1] etc... + repeated int32 composite_index = 3; + + // The kind of parameter this is a reference to + Kind kind = 4; + + // The anchor hash of the composable. See InspectorNode.anchorId. + sint32 anchor_hash = 5; +} + +// A collection of all parameters associated with a single composable +message ParameterGroup { + sint64 composable_id = 1; + repeated Parameter parameter = 3; + repeated Parameter merged_semantics = 4; + repeated Parameter unmerged_semantics = 5; +} + +// ======= COMMANDS, RESPONSES, AND EVENTS ======= + +// Response fired when incoming command bytes cannot be parsed or handled. This may occur if a newer +// version of the client tries to interact with an older inspector. +message UnknownCommandResponse { + // The initial command bytes received that couldn't be handled. By returning this back to the + // client, it should be able to identify what they sent that failed on the inspector side. + bytes command_bytes = 1; +} + +// Request all composables found under a layout tree rooted under the specified view +message GetComposablesCommand { + int64 root_view_id = 1; + // If true, only show composable nodes created by the user's own app + bool skip_system_composables = 2; + // If the cached data generation matches the specified generation then the cache is still valid. + int32 generation = 3; + // If true, extract all parameters even if `delayParameterExtractions` is off. + bool extract_all_parameters = 4; +} + +message GetComposablesResponse { + // A collection of all text referenced by other fields in this message + repeated StringEntry strings = 1; + repeated ComposableRoot roots = 2; +} + +message GetParametersCommand { + int64 root_view_id = 1; // Used for filtering out composables from unrelated layout trees + sint64 composable_id = 2; + // As an optimization, we can skip over searching system composables if we know we don't care + // about them. + bool skip_system_composables = 3; +} + +message GetParametersResponse { + repeated StringEntry strings = 1; + repeated ParameterGroup parameters = 2; +} + +message GetAllParametersCommand { + int64 root_view_id = 1; + bool skip_system_composables = 2; +} + +message GetAllParametersResponse { + repeated StringEntry strings = 1; + repeated ParameterGroup parameters = 2; +} + +message GetParameterDetailsCommand { + int64 root_view_id = 1; + sint64 composable_id = 2; + int32 parameter_index = 3; + // If the parameter value is a composite value such as: + // List + // then: + // composite_index[0] is the index into the List + // composite_index[1] is the index into the fields in MyClass + // composite_index[2] is the index into the field found by [1] etc... + repeated int32 composite_index = 4; +} + +message GetParameterDetailsResponse { + repeated StringEntry strings = 1; + Parameter parameter = 2; +} + +message UpdateSettingsCommand { + // If true, the inspector will delay extracting parameters until they are requested. + // This is useful for large trees where extracting all parameters can be expensive. + bool delay_parameter_extraction = 1; + // If true, the inspector will reset the recomposition counts for all composables. + bool reset_recomposition_counts = 2; + // If true, the inspector will highlight recompositions in the UI. + bool highlight_recompositions = 3; +} + +message UpdateSettingsResponse { + // Empty response +} + +message Command { + oneof command { + GetComposablesCommand get_composables = 1; + GetParametersCommand get_parameters = 2; + GetAllParametersCommand get_all_parameters = 3; + GetParameterDetailsCommand get_parameter_details = 4; + UpdateSettingsCommand update_settings = 5; + } +} + +message Response { + oneof response { + UnknownCommandResponse unknown_command = 1; + GetComposablesResponse get_composables = 2; + GetParametersResponse get_parameters = 3; + GetAllParametersResponse get_all_parameters = 4; + GetParameterDetailsResponse get_parameter_details = 5; + UpdateSettingsResponse update_settings = 6; + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/grishberg/android/li/compose/ComposeHierarchyAnalyzer.kt b/src/main/kotlin/com/github/grishberg/android/li/compose/ComposeHierarchyAnalyzer.kt new file mode 100644 index 0000000..6dfea93 --- /dev/null +++ b/src/main/kotlin/com/github/grishberg/android/li/compose/ComposeHierarchyAnalyzer.kt @@ -0,0 +1,230 @@ +package com.github.grishberg.android.li.compose + +import com.github.grishberg.android.li.compose.model.ComposeCandidateNode +import com.github.grishberg.android.li.compose.model.ComposeHierarchySummary +import org.xml.sax.Attributes +import org.xml.sax.helpers.DefaultHandler +import java.io.ByteArrayInputStream +import java.nio.charset.StandardCharsets +import javax.xml.parsers.SAXParserFactory + +/** + * Parses a `uiautomator dump` XML and extracts a Compose-focused summary. + * + * Heuristic for "Compose candidate": + * - node has any Compose-related ancestor class + * (`androidx.compose.ui.platform.AndroidComposeView` or `ComposeView`) + * - OR node is `android.view.View` with empty `resource-id` and a non-empty + * `text` or `content-desc` (typical for Compose semantics nodes exposed + * through accessibility). + */ +class ComposeHierarchyAnalyzer { + + private val boundsPattern = "\\[(-?\\d+),(-?\\d+)]\\[(-?\\d+),(-?\\d+)]".toRegex() + + fun analyze(deviceName: String, xmlDump: String): Result { + val root = parseRoot(xmlDump) + ?: return Result( + ComposeHierarchySummary( + deviceName = deviceName, + packageName = "", + totalNodes = 0, + visibleNodes = 0, + composeRootCount = 0, + composeCandidateCount = 0, + textNodeCount = 0, + contentDescNodeCount = 0, + resourceIdNodeCount = 0, + classDistribution = emptyList(), + topTexts = emptyList(), + topContentDescriptions = emptyList(), + ), + null, + ) + + val counters = Counters() + walkAndCount(root, false, counters) + + val topPackage = counters.packageCounts.entries.maxByOrNull { it.value }?.key.orEmpty() + val classDistribution = counters.classCounts.entries + .sortedByDescending { it.value } + .take(15) + .map { ComposeHierarchySummary.ClassCount(it.key, it.value) } + val topTexts = counters.textCounts.entries + .sortedByDescending { it.value } + .take(30) + .map { "${it.key} (×${it.value})" } + val topContentDescriptions = counters.descCounts.entries + .sortedByDescending { it.value } + .take(30) + .map { "${it.key} (×${it.value})" } + + val summary = ComposeHierarchySummary( + deviceName = deviceName, + packageName = topPackage, + totalNodes = counters.total, + visibleNodes = counters.visible, + composeRootCount = counters.composeRoots, + composeCandidateCount = counters.composeCandidates, + textNodeCount = counters.textNodes, + contentDescNodeCount = counters.descNodes, + resourceIdNodeCount = counters.idNodes, + classDistribution = classDistribution, + topTexts = topTexts, + topContentDescriptions = topContentDescriptions, + ) + return Result(summary, root.toTreeNode()) + } + + private fun parseRoot(xml: String): RawNode? { + val factory = SAXParserFactory.newInstance() + val handler = NodeHandler() + val stream = ByteArrayInputStream(xml.toByteArray(StandardCharsets.UTF_8)) + factory.newSAXParser().parse(stream, handler) + return handler.root + } + + private fun walkAndCount(node: RawNode, hasComposeAncestor: Boolean, c: Counters) { + c.total++ + val visible = node.width > 0 && node.height > 0 + if (visible) c.visible++ + node.className.takeIf { it.isNotEmpty() }?.let { + c.classCounts.merge(it, 1, Int::plus) + } + node.pkg.takeIf { it.isNotEmpty() && it != "" }?.let { + c.packageCounts.merge(it, 1, Int::plus) + } + node.text?.takeIf { it.isNotEmpty() }?.let { + c.textNodes++ + c.textCounts.merge(it, 1, Int::plus) + } + node.contentDescription?.takeIf { it.isNotEmpty() }?.let { + c.descNodes++ + c.descCounts.merge(it, 1, Int::plus) + } + node.resourceId?.takeIf { it.isNotEmpty() }?.let { c.idNodes++ } + + val isComposeRoot = isComposeRootClass(node.className) + val childHasComposeAncestor = hasComposeAncestor || isComposeRoot + if (isComposeRoot) c.composeRoots++ + + node.isComposeCandidate = + hasComposeAncestor || isComposeRoot || isComposeSemanticsCandidate(node) + if (node.isComposeCandidate) c.composeCandidates++ + + node.children.forEach { walkAndCount(it, childHasComposeAncestor, c) } + } + + private fun isComposeRootClass(className: String): Boolean { + return className == "androidx.compose.ui.platform.ComposeView" || + className == "androidx.compose.ui.platform.AndroidComposeView" || + className.endsWith(".AndroidComposeView") || + className.endsWith(".ComposeView") + } + + private fun isComposeSemanticsCandidate(node: RawNode): Boolean { + if (node.className != "android.view.View") return false + if (!node.resourceId.isNullOrEmpty()) return false + return !node.text.isNullOrEmpty() || !node.contentDescription.isNullOrEmpty() + } + + private fun parseBounds(s: String?): IntArray { + if (s.isNullOrEmpty()) return intArrayOf(0, 0, 0, 0) + val m = boundsPattern.find(s) ?: return intArrayOf(0, 0, 0, 0) + return intArrayOf( + m.groupValues[1].toInt(), + m.groupValues[2].toInt(), + m.groupValues[3].toInt(), + m.groupValues[4].toInt(), + ) + } + + private fun parseId(raw: String?): String? { + if (raw.isNullOrEmpty()) return null + val pos = raw.lastIndexOf(":id/") + return if (pos < 0) raw else raw.substring(pos + 4) + } + + private inner class NodeHandler : DefaultHandler() { + var root: RawNode? = null + private val stack = ArrayDeque() + + override fun startElement(uri: String, localName: String?, qName: String, attributes: Attributes) { + if (qName != "node") return + val bounds = parseBounds(attributes.getValue("bounds")) + val node = RawNode( + className = attributes.getValue("class").orEmpty(), + pkg = attributes.getValue("package").orEmpty(), + resourceId = parseId(attributes.getValue("resource-id")), + text = attributes.getValue("text"), + contentDescription = attributes.getValue("content-desc"), + left = bounds[0], + top = bounds[1], + right = bounds[2], + bottom = bounds[3], + ) + stack.lastOrNull()?.children?.add(node) + stack.addLast(node) + if (root == null) root = node + } + + override fun endElement(uri: String, localName: String, qName: String) { + if (qName == "node") stack.removeLast() + } + } + + private class RawNode( + val className: String, + val pkg: String, + val resourceId: String?, + val text: String?, + val contentDescription: String?, + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + ) { + val children = mutableListOf() + val width: Int get() = right - left + val height: Int get() = bottom - top + var isComposeCandidate: Boolean = false + + fun toTreeNode(parent: ComposeCandidateNode? = null): ComposeCandidateNode { + val node = ComposeCandidateNode( + className = className, + resourceId = resourceId, + text = text, + contentDescription = contentDescription, + left = left, + top = top, + right = right, + bottom = bottom, + isComposeCandidate = isComposeCandidate, + parentNode = parent, + ) + for (child in children) { + node.add(child.toTreeNode(node)) + } + return node + } + } + + private class Counters { + var total = 0 + var visible = 0 + var composeRoots = 0 + var composeCandidates = 0 + var textNodes = 0 + var descNodes = 0 + var idNodes = 0 + val classCounts = LinkedHashMap() + val packageCounts = LinkedHashMap() + val textCounts = LinkedHashMap() + val descCounts = LinkedHashMap() + } + + data class Result( + val summary: ComposeHierarchySummary, + val root: ComposeCandidateNode?, + ) +} diff --git a/src/main/kotlin/com/github/grishberg/android/li/compose/ComposeInspectDialog.kt b/src/main/kotlin/com/github/grishberg/android/li/compose/ComposeInspectDialog.kt new file mode 100644 index 0000000..bc295cc --- /dev/null +++ b/src/main/kotlin/com/github/grishberg/android/li/compose/ComposeInspectDialog.kt @@ -0,0 +1,188 @@ +package com.github.grishberg.android.li.compose + +import com.github.grishberg.android.li.compose.model.ComposeCandidateNode +import com.github.grishberg.android.li.compose.model.ComposeHierarchySummary +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.JBSplitter +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTabbedPane +import com.intellij.ui.treeStructure.Tree +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JTable +import javax.swing.SwingConstants +import javax.swing.border.EmptyBorder +import javax.swing.table.DefaultTableModel +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.DefaultTreeCellRenderer +import javax.swing.tree.DefaultTreeModel + +class ComposeInspectDialog( + project: Project, + private val summary: ComposeHierarchySummary, + private val root: ComposeCandidateNode?, +) : DialogWrapper(project, true) { + + init { + title = "Compose Hierarchy Summary" + setOKButtonText("Close") + init() + } + + override fun createCenterPanel(): JComponent { + val splitter = JBSplitter(false, 0.45f) + splitter.firstComponent = buildLeft() + splitter.secondComponent = buildRight() + splitter.preferredSize = Dimension(960, 640) + return splitter + } + + override fun createActions(): Array { + val copy = object : javax.swing.AbstractAction("Copy summary to clipboard") { + override fun actionPerformed(e: java.awt.event.ActionEvent?) { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(summary.toJsonLike()), null) + } + } + return arrayOf(copy, okAction) + } + + private fun buildLeft(): JComponent { + val panel = JPanel(BorderLayout()) + panel.border = EmptyBorder(10, 10, 10, 10) + + val statsPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(headerLabel("Device: ${summary.deviceName}")) + add(headerLabel("Top package: ${summary.packageName.ifEmpty { "" }}")) + add(headerLabel(" ")) + add(statLine("Total nodes", summary.totalNodes.toString())) + add(statLine("Visible nodes", summary.visibleNodes.toString())) + add(statLine("Compose roots (AndroidComposeView)", summary.composeRootCount.toString())) + add(statLine("Compose candidates", summary.composeCandidateCount.toString())) + add(statLine("Nodes with text", summary.textNodeCount.toString())) + add(statLine("Nodes with content-description", summary.contentDescNodeCount.toString())) + add(statLine("Nodes with resource-id", summary.resourceIdNodeCount.toString())) + } + + val tree = buildCandidateTree() + + panel.add(statsPanel, BorderLayout.NORTH) + panel.add(JBScrollPane(tree), BorderLayout.CENTER) + return panel + } + + private fun buildCandidateTree(): Tree { + val rootNode = DefaultMutableTreeNode("Compose candidates") + if (root != null) { + collectCandidates(root, rootNode) + } + val tree = Tree(DefaultTreeModel(rootNode)) + tree.isRootVisible = true + tree.showsRootHandles = true + tree.cellRenderer = object : DefaultTreeCellRenderer() { + override fun getTreeCellRendererComponent( + tree: javax.swing.JTree?, + value: Any?, + sel: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean, + ): java.awt.Component { + val node = value as? DefaultMutableTreeNode + val payload = node?.userObject + val text = when (payload) { + is ComposeCandidateNode -> payload.displayName() + else -> payload?.toString() ?: "" + } + return super.getTreeCellRendererComponent( + tree, text, sel, expanded, leaf, row, hasFocus + ) + } + } + for (i in 0 until tree.rowCount) tree.expandRow(i) + return tree + } + + private fun collectCandidates(node: ComposeCandidateNode, parent: DefaultMutableTreeNode) { + if (!node.isComposeCandidate) { + // For non-compose nodes we still descend so we don't lose context, + // but we don't add them to the visible tree unless they have a + // candidate inside. + val tempParent = DefaultMutableTreeNode(node.className.substringAfterLast('.')) + node.children.forEach { collectCandidates(it, tempParent) } + if (tempParent.childCount > 0) parent.add(tempParent) + return + } + val current = DefaultMutableTreeNode(node) + node.children.forEach { collectCandidates(it, current) } + parent.add(current) + } + + private fun buildRight(): JComponent { + val tabs = JBTabbedPane() + + val classesModel = DefaultTableModel(arrayOf("Class", "Count"), 0).apply { + summary.classDistribution.forEach { addRow(arrayOf(it.className, it.count)) } + } + tabs.addTab("Top classes", JBScrollPane(readOnlyTable(classesModel))) + + val textsModel = DefaultTableModel(arrayOf("Text"), 0).apply { + summary.topTexts.forEach { addRow(arrayOf(it)) } + } + tabs.addTab("Top texts", JBScrollPane(readOnlyTable(textsModel))) + + val descsModel = DefaultTableModel(arrayOf("Content description"), 0).apply { + summary.topContentDescriptions.forEach { addRow(arrayOf(it)) } + } + tabs.addTab("Top content-desc", JBScrollPane(readOnlyTable(descsModel))) + + return tabs + } + + private fun readOnlyTable(model: DefaultTableModel): JTable = + object : JTable(model) { + override fun isCellEditable(row: Int, column: Int): Boolean = false + } + + private fun headerLabel(text: String): JComponent { + val label = JBLabel(text) + label.horizontalAlignment = SwingConstants.LEFT + return label + } + + private fun statLine(name: String, value: String): JComponent { + val row = JPanel(BorderLayout()) + row.add(JBLabel("$name: "), BorderLayout.WEST) + row.add(JBLabel(value), BorderLayout.CENTER) + return row + } +} + +private fun ComposeHierarchySummary.toJsonLike(): String = buildString { + appendLine("{") + appendLine(" \"deviceName\": \"$deviceName\",") + appendLine(" \"packageName\": \"$packageName\",") + appendLine(" \"totalNodes\": $totalNodes,") + appendLine(" \"visibleNodes\": $visibleNodes,") + appendLine(" \"composeRootCount\": $composeRootCount,") + appendLine(" \"composeCandidateCount\": $composeCandidateCount,") + appendLine(" \"textNodeCount\": $textNodeCount,") + appendLine(" \"contentDescNodeCount\": $contentDescNodeCount,") + appendLine(" \"resourceIdNodeCount\": $resourceIdNodeCount,") + appendLine(" \"topClasses\": [") + classDistribution.forEachIndexed { i, c -> + val tail = if (i == classDistribution.lastIndex) "" else "," + appendLine(" { \"class\": \"${c.className}\", \"count\": ${c.count} }$tail") + } + appendLine(" ]") + append("}") +} diff --git a/src/main/kotlin/com/github/grishberg/android/li/compose/InspectComposeHierarchyAction.kt b/src/main/kotlin/com/github/grishberg/android/li/compose/InspectComposeHierarchyAction.kt new file mode 100644 index 0000000..247ed63 --- /dev/null +++ b/src/main/kotlin/com/github/grishberg/android/li/compose/InspectComposeHierarchyAction.kt @@ -0,0 +1,126 @@ +package com.github.grishberg.android.li.compose + +import com.android.ddmlib.CollectingOutputReceiver +import com.android.ddmlib.IDevice +import com.android.layoutinspector.common.PluginLogger +import com.github.grishberg.android.li.ui.NotificationHelperImpl +import com.github.grishberg.androidstudio.plugins.AdbProvider +import com.github.grishberg.androidstudio.plugins.AdbWrapper +import com.github.grishberg.androidstudio.plugins.AdbWrapperImpl +import com.github.grishberg.androidstudio.plugins.AsAction +import com.github.grishberg.androidstudio.plugins.ConnectedDeviceInfoProvider +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import java.io.BufferedReader +import java.io.File +import java.util.concurrent.TimeUnit + +private const val TAG = "InspectComposeHierarchyAction" +private const val UI_DUMP_PATTERN = "UI\\shierchary\\sdumped\\sto:\\s([^ ]+\\.xml)" + +class InspectComposeHierarchyAction : AsAction() { + + private val logger = PluginLogger() + + override fun actionPerformed(e: AnActionEvent, project: Project) { + val notifications = NotificationHelperImpl(project) + val adbProvider = object : AdbProvider { + override fun getAdb(): AdbWrapper = AdbWrapperImpl(project) + } + val provider = ConnectedDeviceInfoProvider(adbProvider, notifications) + val info = provider.provideDeviceInfo() ?: return + + if (info.devices.isEmpty()) { + notifications.error("No connected devices") + return + } + + val device = chooseDevice(project, info.devices) ?: return + + object : Task.Backgroundable(project, "Capturing Compose hierarchy from ${device.serialNumber}", true) { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = true + + val xml = try { + captureUiAutomatorDump(device, indicator) + } catch (ex: Exception) { + logger.e("$TAG: dump failed", ex) + showError(notifications, "Failed to capture UI dump: ${ex.message}") + return + } + + if (xml.isNullOrBlank()) { + showError(notifications, "UI Automator returned an empty dump") + return + } + + indicator.text = "Analysing dump..." + val result = try { + ComposeHierarchyAnalyzer().analyze(device.toString(), xml) + } catch (ex: Exception) { + logger.e("$TAG: analysis failed", ex) + showError(notifications, "Failed to analyse dump: ${ex.message}") + return + } + + ApplicationManager.getApplication().invokeLater { + ComposeInspectDialog(project, result.summary, result.root).show() + } + } + }.queue() + } + + private fun chooseDevice(project: Project, devices: List): IDevice? { + if (devices.size == 1) return devices.first() + val labels = devices.map { describe(it) }.toTypedArray() + val selectedIdx = Messages.showChooseDialog( + project, + "Select a device to inspect:", + "Inspect Compose Hierarchy", + null, + labels, + labels.firstOrNull() ?: "" + ) + if (selectedIdx < 0) return null + return devices[selectedIdx] + } + + private fun describe(device: IDevice): String { + val parts = mutableListOf(device.serialNumber) + val model = runCatching { device.getProperty("ro.product.model") }.getOrNull() + if (!model.isNullOrEmpty()) parts.add(model) + val release = runCatching { device.getProperty("ro.build.version.release") }.getOrNull() + if (!release.isNullOrEmpty()) parts.add("Android $release") + return parts.joinToString(" / ") + } + + private fun captureUiAutomatorDump(device: IDevice, indicator: ProgressIndicator): String? { + indicator.text = "Running uiautomator dump..." + val receiver = CollectingOutputReceiver() + device.executeShellCommand("uiautomator dump", receiver) + receiver.awaitCompletion(60L, TimeUnit.SECONDS) + val output = receiver.output ?: return null + logger.d("$TAG: uiautomator output: $output") + val match = UI_DUMP_PATTERN.toRegex().find(output) ?: return null + val remotePath = match.groupValues[1] + + indicator.text = "Pulling dump file..." + val tempFile = File.createTempFile("yali_compose_dump_", ".xml") + tempFile.deleteOnExit() + device.pullFile(remotePath, tempFile.absolutePath) + + val text = tempFile.bufferedReader().use(BufferedReader::readText) + logger.d("$TAG: dump size=${text.length}") + return text + } + + private fun showError(notifications: NotificationHelperImpl, message: String) { + ApplicationManager.getApplication().invokeLater { + notifications.error(message) + } + } +} diff --git a/src/main/kotlin/com/github/grishberg/android/li/compose/model/ComposeCandidateNode.kt b/src/main/kotlin/com/github/grishberg/android/li/compose/model/ComposeCandidateNode.kt new file mode 100644 index 0000000..5642b74 --- /dev/null +++ b/src/main/kotlin/com/github/grishberg/android/li/compose/model/ComposeCandidateNode.kt @@ -0,0 +1,45 @@ +package com.github.grishberg.android.li.compose.model + +import java.util.Collections +import java.util.Enumeration +import javax.swing.tree.TreeNode + +class ComposeCandidateNode( + val className: String, + val resourceId: String?, + val text: String?, + val contentDescription: String?, + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + val isComposeCandidate: Boolean, + private val parentNode: ComposeCandidateNode?, +) : TreeNode { + val children = mutableListOf() + val width: Int get() = right - left + val height: Int get() = bottom - top + + fun add(child: ComposeCandidateNode) { + children.add(child) + } + + fun displayName(): String { + val short = className.substringAfterLast('.') + val parts = mutableListOf(short) + if (!resourceId.isNullOrEmpty()) parts += "#$resourceId" + if (!text.isNullOrEmpty()) parts += "text=\"${text.take(40)}\"" + if (!contentDescription.isNullOrEmpty()) parts += "desc=\"${contentDescription.take(40)}\"" + if (isComposeCandidate) parts += "[compose]" + parts += "(${width}x${height})" + return parts.joinToString(" ") + } + + override fun getChildAt(childIndex: Int): TreeNode = children[childIndex] + override fun getChildCount(): Int = children.size + override fun getParent(): TreeNode? = parentNode + override fun getIndex(node: TreeNode?): Int = children.indexOf(node) + override fun getAllowsChildren(): Boolean = true + override fun isLeaf(): Boolean = children.isEmpty() + override fun children(): Enumeration = Collections.enumeration(children) +} diff --git a/src/main/kotlin/com/github/grishberg/android/li/compose/model/ComposeHierarchySummary.kt b/src/main/kotlin/com/github/grishberg/android/li/compose/model/ComposeHierarchySummary.kt new file mode 100644 index 0000000..47094d0 --- /dev/null +++ b/src/main/kotlin/com/github/grishberg/android/li/compose/model/ComposeHierarchySummary.kt @@ -0,0 +1,18 @@ +package com.github.grishberg.android.li.compose.model + +data class ComposeHierarchySummary( + val deviceName: String, + val packageName: String, + val totalNodes: Int, + val visibleNodes: Int, + val composeRootCount: Int, + val composeCandidateCount: Int, + val textNodeCount: Int, + val contentDescNodeCount: Int, + val resourceIdNodeCount: Int, + val classDistribution: List, + val topTexts: List, + val topContentDescriptions: List, +) { + data class ClassCount(val className: String, val count: Int) +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 01ee760..0a7c971 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -43,6 +43,10 @@ class="com.github.grishberg.android.li.ShowLayoutInspectorAction" text="_Launch YALI" description="Launch Yet Another Layout Inspector for Android"/> +