В одной из прошлых статей было рассказано, как начать разработку собственного плагина для Android Studio (или IntelliJ Idea). В этой статье окунёмся немного глубже и создадим более сложные и, хочется верить, ещё более полезные инструменты для повседневной работы в IDE.
Как известно, плагины служат для расширения возможностей среды разработки (в нашем случае пусть это будет Android Studio, или просто «Студия»). Под расширением мы будем подразумевать интеграцию некоторой функциональности, облегчающей или сокращающей рутинные задачи, которые Студия и так умеет решать, либо добавит новые, которые нужны нам для решения каких-то проектно-зависимых задач. Также плагин можно использовать для фиксации некоторых настроек Студии, чтобы, например, облегчить команде соблюдение внутреннего code style, если он отличается от общепринятого.

Подготовка
Начнём с создания проекта плагина. Тут сложностей не должно возникнуть, однако есть нюансы.
Во-первых, сразу рекомендую перейти на вторую версию плагина для разработки плагинов (IntelliJ Platform Gradle Plugin). Это существенно облегчит следующий шаг.
Во-вторых, следует очень внимательно подтягивать новые версии зависимых библиотек и плагинов. Самое простое — взять последние стабильные. Как минимум, понадобится Android и Kotlin. Однако, следует учитывать совместимость этих плагинов с версией Студии, для которой мы его разрабатываем. Гипотетический пример: вышла Студия с поддержкой K2, мы обновили таргет, однако другие плагины не обновляли, или у них просто нет поддержки K2. В результате, плагин работать не будет, поэтому версии следует поднимать синхронно. Если конфликта версий избежать не получается, то можно временно ограничить его доступность в Gradle, выбрав некий диапазон:
tasks {
patchPluginXml {
sinceBuild.set("232")
untilBuild.set("253.*")
}
}
Таргет можно выбрать как для Cтудии, так и для IntelliJ Idea. Второе несколько проще в понимании и поддержке, но раз мы нацелились на Cтудию, то выбираем её. Про то, как это сделать, есть отдельная статья. Тут нужно обратить внимание, что версия Студии, которая выводится через About Android Studio
, не та, что нам нужна; правильная тут.
Кроме объявления в Gradle, все плагины регистрируются в файле plugin.xml
в разделе depends. Также вам могут понадобиться «встроенные» плагины IntelliJ Idea, самым наглядным из которых является Git4Idea
, который отвечает за работу с Git в IDE. Если таковые нужны, подключаем их через bundledPlugins
. Кстати, раз уж упомянули K2, то для его поддержки плагином нужно добавить расширение в plugin.xml
(при условии, что код совместим, подробнее тут):
<extensions defaultExtensionNs="org.jetbrains.kotlin">
<supportsKotlinPluginMode supportsK2="true"/>
</extensions>
В конце концов, в файле build.gradle
нашего плагина должно получиться примерно следующее (версии на момент чтения статьи, скорее всего, будут неактуальные):
dependencies {
intellijPlatform {
androidStudio("2025.1.1.13")
bundledPlugin("org.jetbrains.android")
//intellijIdeaCommunity("2025.1")
bundledPlugins(listOf("Git4Idea"))
//https://plugins.jetbrains.com/plugin/22989-android/versions
plugin("org.jetbrains.android:251.26927.70")
//https://plugins.jetbrains.com/plugin/13123-terminal/versions
plugin("org.jetbrains.plugins.terminal:251.26094.87")
//https://plugins.jetbrains.com/plugin/6954-kotlin/versions/stable
plugin("org.jetbrains.kotlin:252.23591.19-IJ")
}
}
На самом деле возможности плагинов почти не ограничены, однако, так как мы встраиваемся в сложнейший продукт, мы хотим максимально переиспользовать и дополнять готовые инструменты. Сложность состоит в том, что открытый API этих инструментов зачастую довольно ограничен, поэтому для решения задач иногда приходится брать всё лучшее и садиться за изобретение велосипедов. Следовательно, для начала работы нам также может понадобиться скачать публичный репозиторий intellij-community.
Сложно представить задачу плагина, которая не использовала бы визуальный интерфейс для работы или отображения результата, поэтому вооружимся также документацией по DSL, с помощью которой будем верстать всякие диалоги с пользователем.
Постановка задачи
Для демонстрации разработки плагина возьмём пару типичных задач из повседневной рутины среднестатистического разработчика в крупном проекте. Сразу оговорюсь, что эти задачи совсем не обязательно решать именно таким образом. Кроме того, примеры намеренно упрощены.
Итак, представим, что у нас есть большой Android-проект, который имеет несколько flavors
, несколько flavorDimensions
, а также несколько buildTypes
. Получается довольно внушительный список Gradle-задач для сборки apk/bundle, UI-тестов и т. п. Прибавляем к этому ещё всевозможные параметры, например, minifyEnabled
, и получаем примерные очертания первой задачи для плагина: если от нас требуется что-то отличное от нажатия зелёной стрелочки в Студии, то это превращается в скрупулёзное составление правильной задачи в терминале или поиске этой задачи из большого множества созданных заранее конфигураций. Таким образом, попробуем создать некий диалог, в котором пользователь сможет выбрать flavor
, натыкать какие-то параметры и по клику запустить UI-тест. Запускать будем через терминал в Cтудии, для работы с ним может потребоваться подключить плагин terminal.
Затем мы поймём, что подавляющее количество локальных запусков UI-тестов делается на некой «типичной» конфигурации, меняются только классы или методы тестов, поэтому попробуем сделать аналог зелёной стрелочки в редакторе Студии (в элементе с номерами строк, он называется Gutter).
В качестве разминки начнём с простой кнопки в интерфейсе Студии для запуска какого-нибудь Shell-скрипта.
Запускатель скриптов
Для создания кнопки в Студии пройдём уже известным маршрутом: создадим Action и зарегистрируем его в plugin.xml
. Здесь нужно сделать две вещи. Первое — понять, мешает ли незавершённая индексация (Dumb Mode) в Студии работе нашей функциональности. Если да, то вместо AnAction
нужно наследоваться от DumbAwareAction
.
class RunScriptAction : DumbAwareAction() {
override fun actionPerformed(event: AnActionEvent) {
val project = event.project ?: return
SilentProcessRunner().run(
project = project,
command = "sh ./some_script.sh param=1"
)
}
}
Далее, нужно определиться с тем, куда именно будет встраиваться наша кнопка. Так как скрипт будет что-то запускать, логичнее всего кнопку убрать в меню Run, однако их несколько разных групп. Например, Android.InstantRunActions
— кнопочки в панели инструментов (обычно в правом верхнем углу), RunContextGroup
— меню в группе Run во всплывающем окне при клике по имени класса в редакторе, RunToolbarMainActionGroup
— тоже кнопочки в панели инструментов, но левее, где выбор конфигурации и зелёная стрелочка, ну и т. д. Выявлять правильные группы довольно муторно, самый простой способ: в проекте intellij-community искать вхождения названий кнопок и меню в строковых константах, а затем искать использования этих констант в аналогах plugin.xml
(иногда ИИ угадывают, но обычно это самые простые варианты).
Также необходимо иметь в виду, что интерфейс IntelliJ Idea и Android Studio может различаться. Давайте возьмём простейший вариант — RunMenu
(главное меню в левом верхнем углу, пункт Run), создадим Group для своих Action и добавим разделитель для красоты. С помощью anchor
можно настроить порядок относительно других элементов группы.
<actions>
<group id="ru.domclick.example_plugin.RunGroup">
<separator/>
<add-to-group group-id="RunMenu" anchor="first"/>
</group>
<action id="ru.domclick.example_plugin.RunScriptAction"
class="ru.domclick.example_plugin.RunScriptAction"
text="Запустить script">
<add-to-group group-id="ru.domclick.example_plugin.RunGroup" anchor="first"/>
</action>
</actions>

Теперь осталось только найти API для запуска скриптов и других команд. В процессе поиска попадались разные варианты, решили остановиться на связке GeneralCommandLine
(здесь конфигурируется любая команда, какую можно запускать в обычном терминале) и ProcessOutput
(нужен для получения результата). Здесь нужно иметь в виду, что при запуске команд в системе, то есть за пределами Студии (например, мы делали скрипт для очистки Gradle-кешей), в результате может приходить «Permission denied». Ничего лучше не придумали, кроме как сделать повторный запуск команды в таком случае. Это, как правило, помогает.
class SilentProcessRunner {
fun run(project: Project, command: String, onComplete: () -> Unit = {}) {
val commandLine = GeneralCommandLine()
.withWorkDirectory(project.basePath)
.withParameters("-c", command)
.withExePath("sh")
val output: ProcessOutput = ExecUtil.execAndGetOutput(commandLine)
if (output.exitCode == 0) {
onComplete.invoke()
} else {
Messages.showErrorDialog(
/* project = */ project,
/* message = */ "Ошибка выполнения команды (код ${output.exitCode}):\n${output.stderr}",
/* title = */ "Ошибка"
)
}
}
}
Диалог для автоматизации запуска UI-тестов
Теперь добавим кнопку, по клику на которую сверстаем диалог для указания всевозможных параметров запуска UI‑теста. Кнопку уже разобрали, перейдём сразу к диалогу (кстати, это не обязательно делать именно диалогом). После нескольких итераций различных реализаций на текущий момент остановились на DialogBuilder
, у него всё довольно просто: устанавливаем заголовок, добавляем кнопки (addOkAction
и addCancelAction
), основной контент верстается внутри centerPanel
, затем вызываем show
(или showNotModal
, если хотите оставить возможность взаимодействовать со Студией, например, скопировать имя класса из редактора).
DialogBuilder()
.title("Запускатель UI-тестов")
.apply {
removeAllActions()
addOkAction().apply {
setText("Запуск")
setOkOperation {
dialogWrapper.close(/* exitCode = */ 1)
runTestCommand(project, testPackage, testFunction, flavor, isMinifyEnabled)
}
}
addCancelAction().setText("Отмена")
}
.centerPanel(
panel {...}
.apply {
preferredSize = Dimension(/* width = */ 700, /* height = */ 300)
}
)
.showNotModal()
Основной контент диалога будет располагаться в DSL-конструкции panel
. Вёрстка не должна вызвать какие-то сложности: есть ряд контейнеров (row
, column
, group
и т.п.) и есть готовые компоненты (label
, textField
, radioButton
, checkBox
и т.п.), в билдерах которых есть знакомые всем свойства. Состояние передаём через функцию bind
, в примере будет buttonsGroup
, в котором будем выбирать flavor
, то есть при клике какой-либо радио-кнопки должен сняться предыдущий выбор.
group("Расположение теста") {
row {
label("Ввести в поле ниже имя класса")
}
row {
classTextField = textField()
.align(Align.FILL)
.label("Имя класса с пакетом")
.onChanged { input ->
testPackage = input.text.trim()
}
}
}
group("Flavor") {
buttonsGroup {
row {
radioButton("Flavor1", "Flavor1")
.selected(true)
.onChanged {
flavor = "Flavor1"
}
}
row {
radioButton("Flavor2", "Flavor2")
.onChanged {
flavor = "Flavor2"
}
}
}.bind({ flavor }, { flavor = it })
row {
checkBox("Включить minify (R8)")
.onChanged {
isMinifyEnabled = it.isEnabled
}
}
}

Следующим шагом нужно сформировать команду и запустить её в терминале Студии. На этом останавливаться не будем, просто сборка строки вида ./gradlew :app:connected${flavor}${dimention}DebugAndroidTest
... Прежде чем запустить команду в терминале, хотелось бы открыть «окошко» этого терминала в Студии, создать или перейти на вкладку, где крутятся тесты, после этого вставить явным образом команду и видеть процесс и результат выполнения.

Окно в Студии называется ToolWindow
, получить его можно через ToolWindowManager
, указав идентификатор терминала. Открывается оно методом activate
.
Далее нужно определить, существует ли вкладка для тестов или её нужно создать. Делать это будем через window.contentManager
методом findContent
, куда передадим имя вкладки. Если её не существует, то создадим через createShellWidget
, иначе делаем её активной. Команду запускаем методом sendCommandToExecute
.
val window = ToolWindowManager.getInstance(project)
.getToolWindow(TerminalToolWindowFactory.TOOL_WINDOW_ID) ?: return
val terminalView = TerminalToolWindowManager.getInstance(project)
val contentManager = window.contentManager
val content = contentManager.findContent(terminalTabName)
val widget = if (content != null) {
contentManager.setSelectedContent(content, true)
TerminalToolWindowManager.findWidgetByContent(content)
?: terminalView.createShellWidget(
/* workingDirectory = */ project.basePath,
/* tabName = */ terminalTabName,
/* requestFocus = */ true,
/* deferSessionStartUntilUiShown = */ true
)
} else {
terminalView.createShellWidget(
/* workingDirectory = */ project.basePath,
/* tabName = */ terminalTabName,
/* requestFocus = */ true,
/* deferSessionStartUntilUiShown = */ true
)
}
widget.sendCommandToExecute(command)
window.activate({}, true)
К сожалению, у нас не получилось сделать какой-нибудь адекватный callback завершения работы команды: не получалось связать вместе виджет терминала, процесс, где выполняется команда, и ProcessHandler
для обработки результата. Если знаете, как это делается — пишите.
Также тут нужно иметь в виду, что текущая реализация TerminalWidget
относительно новая, часть API по терминалу всё ещё отсылается к старым ShellTerminalWidget
и JBTerminalWidget
, между собой они несовместимы (будет бросаться исключение).
Добавляем запуск UI-теста в Gutter
Теперь мы не хотим ничего кликать вручную, а хотим одной кнопкой запустить стандартную конфигурацию (стандартную для нашего проекта, а не Студии, они различаются, именно поэтому нам нужен плагин), причём автоматически вставить туда класс или метод. Для этого нам понадобится LineMarkerProvider. Его реализацию нужно зарегистрировать в extensions
в plugins.xml
:
<codeInsight.lineMarkerProvider language="kotlin"
implementationClass="ru.domclick.example_plugin.AndroidUiTestLineMarkerProvider"/>
В реализации переопределяем метод getLineMarkerInfo
, в аргументах которого будет PsiElement
— основной класс описания структуры и семантики кода через PSI (также эта концепция рассматривается в статье про Lint, а во второй части — особенности работы в K2). Этот элемент как раз и описывает, что находится в каждой строке кода. Нам необходимо «отсеить» всё, что не относится к UI-тестам (тут мы решили смотреть, в какой папке лежит физический файл кода, то есть UI-тесты у нас всегда лежат в папке androidTest в проекте), а также те строки, которые не содержат имя класса или метода с аннотацией Test
. Когда все условия соблюдены, мы можем показать иконку, вернув в методе объект LineMarkerInfo
, где также реализуем действие по клику: передадим названия класса и метода в команду, запустив её в терминале, как уже делали ранее.
class AndroidUiTestLineMarkerProvider : LineMarkerProvider {
override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? {
if ((element as? KtElement)?.containingClassOrObjectOrSelf() != null) return null
val className = (element.parent as? KtClass)?.name
val methodName = (element.parent as? KtNamedFunction)?.kotlinFqName?.shortName()?.asString()
val hasTestAnnotation = (element.parent as? KtNamedFunction)
?.hasAnnotation(ClassId(FqName("org.junit"), Name.identifier("Test"))) == true
val isAndroidTestFile = element.containingFile.virtualFile?.canonicalPath?.contains("/src/androidTest/") == true
return if (isAndroidTestFile && ((className == element.text && className!!.contains("Test"))
|| (methodName == element.text && hasTestAnnotation))
) {
LineMarkerInfo(
/* element = */ element,
/* range = */ element.textRange,
/* icon = */ AllIcons.RunConfigurations.TestState.Run,
/* tooltipProvider = */ {
when {
methodName != null -> "Запустить тест: $methodName"
className != null -> "Запустить тест: $className"
else -> ""
}
},
/* navHandler = */ { _, _ -> runAndroidUiTest(element, methodName) },
/* alignment = */ GutterIconRenderer.Alignment.RIGHT,
/* accessibleNameProvider = */ { "" }
)
} else {
null
}
}
}

К сожалению, на данный момент пока не получилось скрывать стандартный LineMarker, так что если есть идеи или предложения, как это сделать, поделитесь, пожалуйста.
Заключение
После описанных действий должен получиться плагин, решающий поставленные задачи и сокращающий рутину. При этом задачи могут быть самые разные, в каждом проекте свои, начиная от перевода скриптов в удобный интерфейс на Kotlin, и заканчивая доработкой инструментария под ваши нужды. В процессе работы погрузились немного глубже в разработку плагинов и некоторые неочевидные возможности, выжили и готовы пойти ещё дальше.
В следующей статье будут другие интересные задачи. Например, попробуем препарировать CheckinHandler
, чтобы изменить и расширить проверки, которые мы можем делать до коммита.
Несколько замечаний в качестве бонуса:
В некоторых случаях может понадобиться что‑то сделать при запуске плагина (или Студии). Для этого можно реализовать
ProjectActivity
, и зарегистрировать вextensions
черезpostStartupActivity.
Для сборки плагина служит команда
./gradlew buildPlugin
. Собранный архив лежит вbuild/distributions
.Если для каких‑то действий требуется перезапуск Студии (например, уже упомянутая ранее очистка кешей Gradle), это можно сделать так:
ApplicationManagerEx.getApplicationEx().restart(true).
Для запуска сборок можно пойти другим путём, через создание кастомных конфигураций, для этого тоже есть публичный API.