Всем привет! Меня зовут Данил Киселев, я Android-разработчик в AGIMA. В этой статье расскажу, как мы реализовали собственный инструмент для доставки сборок Android-приложений. Цель статьи — сэкономить время команд, которые занимаются разработкой Android-проектов и у которых пока нет подобного решения. Также к статье я прикрепил репозиторий с кодом проекта. Вы можете использовать его как стартовую версию и дорабатывать под свои нужды.
В условиях блокировки официального сервиса Firebase App Distribution в России, перед нашим отделом мобильной разработки встала задача создать собственный инструмент для доставки сборок Android-приложений тестировщикам. Да, мы могли бы пользоваться Firebase App Distribution, используя VPN, но это не очень удобно. VPN-сервисы ненадежны, потому что подвержены блокировкам. К тому же создание собственного решения позволяет добавлять новые функции и адаптировать инструмент под конкретные нужды команды.
На многих проектах огромное количество времени уходило на то, чтобы достать сборку из GitLab или собрать ее вручную и отправить тестировщику. Также это сильно отвлекает от работы над задачами. Решением стала разработка инструмента, который будет принимать, хранить и раздавать файлы сборок.
Основными требованиями к новому инструменту были:
Получать файлы сборок от GitLab, сохранять их и автоматически рассылать участникам проекта.
Предоставлять доступ к списку сборок и информации об этих сборках.
Что немаловажно — обеспечивать возможность быстрой интеграции с различными проектами и независимую работу с ними. Наша компания является студией заказной разработки и одновременно работает над множеством проектов.
Выбор Ktor
Ktor был выбран не случайно: это фреймворк на Kotlin, а так как мы решили разработать инструмент силами Android-отдела, выбор был очевиден. Благодаря этому любой разработчик, знакомый с Kotlin, сможет быстро разобраться в кодовой базе, поддерживать инструмент и интегрировать его с различными проектами.
Выбор Telegram
В качестве интерфейса мы решили выбрать Telegram. Наши рабочие чаты чаще всего находятся именно там, поэтому не нужно далеко ходить за сборками. Кроме того, Telegram Bot API предоставляет много возможностей, хоть и имеет некоторые ограничения. Telegram позволил реализовать интуитивное взаимодействие с инструментом с помощью inline-кнопок. Благодаря этому удалось добиться эффекта перехода по страницам.
Процесс рассылки сборок
Отправка файла
Для начала реализуем отправку сборки из GitLab. В файле gitlab.yaml нужно создать этап upload qa build, который будет запускаться после этапа сборки, находить сборку и отправлять ее по нужному адресу.
В блоке before_script нам нужно установить curl с помощью пакетного менеджера apk.
В блоке script с помощью команды fastlane release_notes мы создаем файл changelog.txt, который будет содержать список последних коммитов. Далее нужно найти пути к файлам, которые будем отправлять — changelog.txt и файл сборки. И с помощью ранее установленного curl отправляем файлы и имя проекта на наш сервер:
upload qa build:
stage: upload
needs:
- job: produce qa build
artifacts: true
variables:
FLAVOR: debug
only:
- develop
- /^release.*$/
tags:
- android
before_script:
- apk add --update curl && rm -rf /var/cache/apk/*
script:
- fastlane release_notes
- changelog_path=$(find . -name "changelog.txt")
- apk_path=$(find app/build/outputs/apk/gorzdrav/$FLAVOR -name "*.apk")
- curl -F file=@$apk_path -F file=@$changelog_path -F "appName=$APP_NAME" http://{Сюда нужно вставить url вашего сервера}/uploadBuild
Получение файла на сервере
Теперь нужно определить запрос, который будет ожидать файл сборки, название проекта и файл changelog.txt со списком последних коммитов. Этот запрос можно доработать, чтобы он мог принимать дополнительную информацию. Количество файлов, которое может принять запрос, уже не ограничено — все файлы, которые придут с GitLab, сохранятся вместе со сборкой:
fun Route.uploadBuildModule() {
val controller by inject<UploadController>()
post("/uploadBuild") {
val multipart = call.receiveMultipart()
val parts = multipart.readAllParts()
val parameters = parts.filterIsInstance<PartData.FormItem>()
val files = parts.filterIsInstance<PartData.FileItem>()
val appName = parameters.firstOrNull { it.name == APP_NAME_KEY }?.value
controller.saveFiles(files, appName)
}
}
Хранение файлов
Файлы мы получили, теперь их необходимо сохранить. Метод saveFiles обрабатывает файлы, поступающие от GitLab:
override suspend fun saveFiles(
files: List<PartData.FileItem>,
appName: String?
) {
val dateTime = getCurrentDateTime()
val dateTimeString = dateTime.toDataString(pattern = DATE_TIME_FORMAT)
val dateTimeStringDisplay =
dateTime.toDataString(pattern = DATE_TIME_FORMAT_FOR_DISPLAY)
val appDirPath = "$ROOT_PACKAGE/$appName/$dateTimeString"
files.forEach { filePart ->
val fileName = filePart.originalFileName
val file = File("$appDirPath/$fileName")
val freeSpace = getFreeSpace().bytesToMb()
try {
file.saveFileFromPart(filePart)
} catch (e: IOException) {
e.printStackTrace()
if (freeSpace < MIN_FREE_SPACE) {
telegramBot
.sendTextToTestUser(MEMORY_AUTO_CLEARING)
val report = buildStore.clearOldBuilds(appName)
telegramBot
.sendTextToTestUser("Отчет: $report")
telegramBot
.sendTextToTestUser(RESENDING)
}
} finally {
file.saveFileFromPart(filePart)
}
}
}
Для каждого проекта создается отдельный каталог с именем, которое мы получили от GitLab, а в нем — подкаталоги с нашими сборками. Имя подкаталога соответствует времени создания сборки. Внутри каждого подкаталога находится сам файл сборки и файл changelog.txt. Тут же будут сохраняться все другие файлы, которые пришли с GitLab. Возможно, лучшим решением было бы хранить эту структуру в базе данных, но пока это реализовано таким образом:
Также, в случае если памяти для сохранения файла недостаточно, вызовется метод clearOldBuilds и удалит часть старых сборок.
Рассылка сборок пользователям
После успешного сохранения файлов необходимо отправить их пользователям. Для этого в запросе мы отправляли имя проекта. С помощью этого имени мы можем получить список пользователей, которые подписаны на проект. От пользователя нам нужно знать только его chatId — по нему мы сможем отправить сообщение. Далее мы проходимся по списку этих id и рассылаем всем уведомление о новой сборке.
Тут мы столкнулись с ограничением Telegram API — максимальный размер файла, который мы можем отправить, 50 MB. Поэтому решили отправлять ссылку на скачивание файла с нашего сервера и ссылку на просмотр changelog.txt.
val apkFile = File(appDirPath).findFileByExtensions(EXTENSIONS_LIST)
if (apkFile != null) {
val textMessage = configureBuildMessage(
title = NEW_BUILD,
time = dateTimeStringDisplay,
appName = appName,
apkDir = dateTimeString,
apkName = apkFile.name,
)
if (appName != null) telegramBot.sendTextToUsersFromProject(textMessage, appName)
}
Метод sendTextToUsersFromProject получает список пользователей из указанного проекта и направляет им подобное сообщение:
По этим ссылкам пользователь может скачать сборку и посмотреть файл changelog.txt со списком коммитов.
Запрос для скачивания файла:
fun Route.downloadFileModule() {
val controller by inject<DownloadFileController>()
get("/$APKS_PATH/{$APP_NAME_KEY}/{$DATE_TIME_KEY}/{$FILE_NAME_KEY}") {
val appName = call.parameters[APP_NAME_KEY]
val dateTime = call.parameters[DATE_TIME_KEY]
val fileName = call.parameters[FILE_NAME_KEY]
val file = controller.getFile(
appName = appName,
dateTime = dateTime,
fileName = fileName
)
call.response.header(
"Content-Disposition",
"attachment; filename=\"${file.name}\""
)
call.respondFile(file)
}
}
Запрос для показа файла с коммитами пользователю:
fun Route.showTextFileModule() {
val controller by inject<ShowTextFileController>()
get("/$SHOW_TEXT_PATH/{$APP_NAME_KEY}/{$DATE_TIME_KEY}/{$FILE_NAME_KEY}") {
val appName = call.parameters[APP_NAME_KEY]
val dateTime = call.parameters[DATE_TIME_KEY]
val fileName = call.parameters[FILE_NAME_KEY]
val file = controller.getFile(
appName = appName,
dateTime = dateTime,
fileName = fileName
)
val text = file.readText()
if (text.isEmpty()) {
call.respondText("Нет информации", ContentType.Text.Plain)
} else {
call.respondText(text, ContentType.Text.Plain)
}
}
}
Интерфейс и взаимодействие с ботом
Главный раздел
В самом начале пользователя встречает список проектов, на которые он подписан. У Telegram нет ограничений на количество кнопок в сообщении, так что проблем с отображением большого количества проектов не возникнет. Тут же можно получить инструкции по интеграции с ботом, зарегистрироваться или подписаться на проект.
Регистрация проекта
Для регистрации проекта нужно ввести его имя. Именно это имя мы будем передавать в запросе из GitLab. Также нужно ввести свое имя для этого проекта. Дополнительно можно ввести список необязательных полей, которые нужны для возможности запуска пайплайнов прямо из бота: ссылка на GitLab, триггер-токен, ID проекта. После регистрации бот вернет нам токен, с помощью которого другие пользователи смогут присоединиться к проекту.
Подписка на проект
После создания проекта нужно предоставить токен доступа для коллег, чтобы они могли подписаться на проект. Они введут соответствующую команду с токеном и своим именем для этого проекта, и после подписки он будет отображаться на главной странице.
Управление проектом
При переходе к проекту открывается набор действий. Тут мы можем запустить сборку проекта, получить последнюю сборку или последние 10 сборок. Также в зависимости от статуса пользователя в проекте будут отображаться дополнительные опции. Например, кнопки с возможностью удалить старые сборки, удалить проект и список пользователей отобразятся только у администратора.
При переходе к списку пользователей у администратора есть возможность посмотреть информацию по ним, поменять их статус в проекте или удалить из проекта.
Взаимодействие с Telegram API
Для взаимодействия с Telegram API мы пользовались этой библиотекой: https://github.com/InsanusMokrassar/ktgbotapi. Ее преимущество в том, что она использует Kotlin Coroutines.
Вот так выглядит обработка простой команды, которую пользователь введет в боте:
command(HELP_COMMAND) {
bot.sendMessage(
chatId = it.chat.id,
text = CHOOSE_PROJECT_MESSAGE,
replyMarkup =
getProjectsListButtons(
ChatToProjectTable.getProjectsForChat(it.chat.id.chatId)
)
)
}
А вот так выглядит реализация кнопок. Кнопка — это часть сообщения, и у нее есть callback data, с помощью которой мы можем обрабатывать нажатия:
fun getProjectDeleteConfirmButtons(projectName: String): InlineKeyboardMarkup {
return InlineKeyboardMarkup(
keyboard = matrix {
row {
+CallbackDataInlineKeyboardButton(
text = DELETE_PROJECT_CONFIRM.getDisplayText(),
callbackData = "$DELETE_PROJECT_CONFIRM:$projectName"
)
+CallbackDataInlineKeyboardButton(
text = DELETE_PROJECT_CANCEL.getDisplayText(),
callbackData = "$DELETE_PROJECT_CANCEL:$projectName"
)
}
}
)
}
Далее мы можем обработать callbackData:
onDataCallbackQuery { callback: DataCallbackQuery ->
val callbackCommand = callback.data
when (callbackCommand) {
DELETE_PROJECT_CONFIRM -> {
}
}
}
Вот так в итоге выглядит навигация в боте с помощью inline кнопок:
Подробнее про функциональность библиотеки можно почитать в документации: https://docs.inmo.dev/tgbotapi/index.html.
Запуск сборок прямо из бота
Также мы реализовали функционал, который позволяет тестировщикам самостоятельно запускать сборки без доступа к GitLab. Чтобы создать и запустить пайплайн по нажатию кнопки, нужно выполнить подобный Post-запрос в Gitlab с нашего сервера:
“https://gitlab.example.com/api/v4/projects/<project_id>/trigger/pipeline”
Для этого мы должны знать ссылку на наш Gitlab и ID проекта. В запрос нужно передать два параметра: имя ветки, на которой мы хотим запустить пайплайн, и Trigger Token, который мы сгенерировали для проекта.
Инструкция по получению Trigger Token в GitLab
Trigger Token позволяет запускать CI/CD pipeline через API. Ниже приведу пошаговую инструкцию, как получить его для вашего проекта в GitLab.
Откройте проект, для которого вы хотите создать триггер. В меню слева выберите пункт "Settings" и дальше "CI/CD".
Прокрутите вниз до раздела "Pipeline Triggers" и нажмите "Expand". Нажмите кнопку "Add Trigger".
Заполните поле с описанием. Нажмите "Add Trigger" для создания.
После создания триггера вы увидите его в списке. Там будет отображен токен триггера. Этот токен можно использовать для запуска pipeline через API.
Все эти данные мы можем указать при регистрации проекта.
А вот как выглядит пример запроса в коде Kotlin:
class GitLabApiImpl : GitLabApi {
override suspend fun triggerPipeline(
projectId: Int,
refName: String,
triggerToken: String,
gitLabBaseUrl: String
): TriggerResult {
val url = "$gitLabBaseUrl/api/v4/projects/$projectId/trigger/pipeline"
val client = HttpClient(CIO)
return try {
val response = client.post(url) {
parameter(TOKEN_KEY, triggerToken)
parameter(REF_KEY, refName)
}
val status = response.status
TriggerResult(
success = status.isSuccess(),
message = "${status.value} ${status.description}"
)
} catch (e: Exception) {
println("Error: ${e.message}")
TriggerResult(
success = false,
message = "Упс... Произошла неизвестная ошибка \uD83D\uDE31"
)
}
}
companion object {
private const val TOKEN_KEY = "token"
private const val REF_KEY = "ref"
}
}
Также нам нужно внести некоторые изменения в файл gitlab.yaml. Тут нужно добавить правило в стейдж build.
- if: '$CI_PIPELINE_SOURCE == "trigger"' — это значит, что стейдж запустится, если пайплайн был создан с помощью триггера.
rules:
- if: '$CI_PIPELINE_SOURCE == "trigger"'
when: always
allow_failure: true
- if: '$CI_COMMIT_REF_NAME == "develop"'
when: manual
allow_failure: true
В официальной документации можно подробнее ознакомится с Pipeline Triggers: https://docs.gitlab.com/ee/ci/triggers.
Также при реализации подобного функционала важно учесть, что если доступ к GitLab предоставляется только через корпоративный VPN, то для выполнения запроса на Ubuntu должен быть запущен этот VPN.
Как вы можете это использовать
Вы можете скачать исходный код проекта, внести необходимые настройки и развернуть сервер, чтобы начать пользоваться инструментом и дорабатывать его под свои нужды!
Вам потребуется:
Ввести API-ключ вашего Telegram-бота
Подготовить сервер на базе Ubuntu и настроить его.
Задеплоить сервер, указав его хост, имя пользователя и пароль.
Первый пункт самый легкий — нужно зарегистрировать бота и получить API-ключ. Инструкция: https://handbook.tmat.me/ru/dev/botfather.
Далее просто заходите в файл AppConstants и подставляете свой API-ключ в константу TG_API_KEY.
Для двух следующих пунктов я подготовил более подробную инструкцию. Разберу ее ниже.
Инструкция по деплою сервера
Чтобы развернуть инструмент, вам понадобится сервер на базе Ubuntu. Настройка Ubuntu для работы с Ktor может показаться сложной, особенно если у вас нет большого опыта работы с Linux. Но, следуя данной инструкции, мне удалось успешно настроить сервер:
https://gist.github.com/philipplackner/bbb3581502b77edfd2b71b7e3f7b18bd.
Хост сервера нужно подставить вместо 11.111.11.1 во всем проекте. Воспользуйтесь поиском и замените на ваше значение.
Также на Ubuntu нужно установить и настроить PostgreSQL. Вот инструкции, которыми можно воспользоваться:
После этого нужно обновить файл DbConstants с данными о вашей базе данных. Чтобы увидеть информацию по базе данных, воспользуйтесь командой \conninfo.
Пример:
object DbConstants {
const val DB_USERNAME = "{Имя пользователя бд}" // Пример postgresql
const val DB_PASSWORD = "{Пароль от вашей юд}" // Пример password
const val DB_URI =
"{Url базы данных}" // Пример: jdbc:postgresql://localhost:5432/builddistributiondb
}
После настройки Ubuntu необходимо добавить задачу deploy в build.gradle. Эта задача подключается к серверу по SSH, отправляет на него исполняемый файл build-distribution.jar и запускает сервис, который этот файл выполняет. Сервис создается на этапе настройки Ubuntu по указанной выше инструкции. Пароль для подключения считывается из файла keys/password в корне проекта. Вставьте пароль от вашего сервера в этот файл.
Инструкцию и скрипт я взял из этого гайда: https://youtu.be/sKCCwl5lNBk. Настройка сервера начинается с 1:33 минуты.
task("deploy") {
dependsOn("clean", "shadowJar")
ant.withGroovyBuilder {
doLast {
val knownHosts = File.createTempFile("knownhosts", "txt")
val user = "{Имя пользователя}"
val host = "{Хост вашего сервера}"
val password = File("keys/password").readText() // файл с паролем к серверу
val jarFileName = "com.kiselev.build-distribution-all.jar"
try {
"scp"(
"file" to file("build/libs/$jarFileName"),
"todir" to "$user@$host:/root/build-distribution",
"password" to password,
"trust" to true,
"knownhosts" to knownHosts
)
"ssh"(
"host" to host,
"username" to user,
"password" to password,
"trust" to true,
"knownhosts" to knownHosts,
"command" to "mv /root/build-distribution/$jarFileName /root/build-distribution/build-distribution.jar"
)
"ssh"(
"host" to host,
"username" to user,
"password" to password,
"trust" to true,
"knownhosts" to knownHosts,
"command" to "systemctl stop build-distribution"
)
"ssh"(
"host" to host,
"username" to user,
"password" to password,
"trust" to true,
"knownhosts" to knownHosts,
"command" to "systemctl start build-distribution"
)
} finally {
knownHosts.delete()
}
}
}
}
Вывод
Создание собственного инструмента для доставки сборок Android-приложений оказалось отличным решением после блокировки Firebase App Distribution. Теперь мы не тратим много времени на то, чтобы вручную отправлять сборки тестировщикам. Инструмент можно легко и быстро интегрировать с проектами с минимальной зависимостью от сторонних сервисов, таких, как VPN-сервисы.
Использование Telegram сделало всё максимально удобным и быстрым. Telegram позволил создать интуитивный способ взаимодействия с сервером. Интеграция с GitLab и возможность запускать сборки прямо из бота сэкономили нам массу времени и сил.
Как я уже говорил, наш инструмент легко интегрируется с множеством проектов, что особенно важно для студий заказной разработки, работающих с различными клиентами. Уверен, мы не первые, кто пришел к такому решению, но цель этой статьи — помочь другим командам, особенно небольшим, сделать процесс доставки сборок более эффективным, удобным и быстрым.
В заключение я напомню, что прикрепил к статье исходный код этого инструмента, чтобы любой желающий мог использовать его и адаптировать под свои нужды:
https://github.com/kiselyv77/com.kiselev.build-distribution.
Будет здорово, если какой-то разработчик или компания доработает наш инструмент, добавит новые крутые функции и тоже выложит в открытый доступ.
Спасибо, что прочитали! Надеюсь, кому-то эта статья поможет улучшить и ускорить процессы разработки и тестирования.
P. S. Пишите вопросы в комментариях — обязательно отвечу. И оставьте отзыв о нашем инструменте, если попробуете его у себя в команде.
А еще приглашаю на канал Саши Ворожищева, руководителя отдела мобильной разработки AGIMA. Найдете там много полезностей.