
Привет, Хабр!
Я занимаюсь программированием под Android с 2015 года. За это время много где поработал и много чего повидал: галеры, крупный ритейл, потом долгий путь в банках и финтехе. Задачи были совершенно разнообразные: от бизнес-фич и кастомных view до SDK, безопасности и 3D-сцен на OpenGL. Я ловил баги, которые жили только на прошивках конкретных вендоров, и читал стектрейсы, уходящие куда-то вглубь com.android.internal. Но за всё это время я ни разу не собирал исходники AOSP и не пытался кастомизировать сборку (хотя пару раз приходилось смотреть некоторые части исходников ОС). И тут я решил закрыть этот пробел, отправившись, как говорится в классической шутке, в увлекательное путешествие на “15 минут”… И вот что из этого вышло.
Поначалу задача выглядела сугубо инженерной, и мотивация у неё была такая же простая:
Любопытство. Я убеждён, что многих инженеров из нашей индустрии двигает вперёд простое человеческое любопытство: “а что там внутри?”, “а как это работает?”, “а что будет, если тут поменять знак?”…
Безопасность. Как я уже говорил, одна из моих областей интересов это безопасность приложений. А чтобы разбираться в безопасности приложений, нужно понимать, на чём и по каким принципам они работают. Пока не видел систему изнутри, это магия. Собрал
userdebug, сделалadb root, и магия становится инженерией.Вендорские прошивки. Почти любой баг “только на устройствах X” начинается с того, что вендор что-то поменял в AOSP. Чтобы понять, что поменяли и как это чинить, хорошо бы знать, как было.
Когда работал в ритейле, разрабатывал приложение, которое активно работало с камерой. Было это давно: Camera2 ещё не существовало, был только первый Camera API, воспоминание о котором одновременно вызывает и чувство ностальгии, и пробуждает детские страхи 😃 Приложение прошло тестирование, уехало в продакшен, и… посыпались краши. Ровно на двух моделях стоковых Samsung.
Стектрейс взрывался где-то в нативе и ничем не мог помочь. После глубокого ресёрча оказалось, что виноват… режим баланса белого. Пользователь выбирал вручную: дневной свет, сумерки, лампы накаливания и т. д. Список режимов мы честно брали у системы. Так вот, камера этих телефонов не поддерживала один из режимов, вендор его вырезал, но не до конца: в списке, который отдавала система, он остался. Приложение спрашивало “что умеешь?”, система отвечала “вот это”, а потом падала, когда её просили сделать ровно то, что она пообещала.
Вот тут-то и пригождается понимание того, где заканчивается AOSP и начинается вендорская “доработка”.
И вот выдались относительно спокойные выходные: жена с детьми уехала к родственникам, и я, в хорошем расположении духа, бодрый, весёлый и энергичный, сел и приступил к “ковырянию” AOSP. С чего начинается инженерное путешествие? Всегда по-разному. В этот раз я решил начать с документации и открыл source.android.com, где и увидел пошаговую инструкцию. Поставь пакеты, скачай, lunch, m, запусти. Кажется, работы на один вечер, а у меня все выходные впереди. В голове уже ураганом неслись мысли о том, как я кастомизирую сборку ОС под себя…
“Но жестоко просчитался пресловутый мистер Пек” (с) В. Высоцкий. Эта инструкция оказалась картой сокровищ из фильма про пиратов: крестик на месте клада стоит, пунктир нарисован, а то, что между ними острова, рифы, ловушки и устаревшие пометки предыдущих экспедиций, мелким шрифтом не написано. Инженерная задача на вечер превратилась в настоящее приключение в духе “Острова сокровищ”, только вместо пиастров в сундуке лежит собственная сборка Android.
Дальше будет судовой журнал этого плавания. Каждая ловушка в нём настоящая: я в неё попал, подорвался и нашёл, как пройти дальше. А там, где официальная документация говорит одно, а сборка Android 17 делает другое, я так прямо и опишу.
По пути мы:
соберём Android 17 на маке, где его собрать “нельзя”, причём бесплатно;
запустим свою сборку в эмуляторе Android Studio и получим root;
поменяем строчку в системной утилите и узнаем, сколько стоит пересборка;
подкрутим параметры ART сразу для всех приложений в системе;
поставим свою анимацию загрузки и соберём список мест, где документация врёт.
Подготовка к походу, или что такое AOSP
AOSP (Android Open Source Project) - открытая часть Android: ядро, нативные библиотеки, ART, системные сервисы, фреймворк, системные приложения и инструменты сборки. Всё это лежит на android.googlesource.com.
Чего на этой карте нет, лучше знать до отплытия:
Google Mobile Services. Ни Play Market, ни Play Services, ни “Войти через Google”. Клад будет “голым” Android.
Драйверов реальных устройств. Блобы вендоров в AOSP не лежат, поэтому первое плавание почти всегда заканчивается в бухте эмулятора.
Оболочек вендоров. One UI, HyperOS и прочие надстройки на этот остров не завозили.
Свежая новость: Google теперь ведёт разработку во внутреннем репозитории и с 2026 года публикует исходники по расписанию, во втором и четвёртом кварталах. Ориентир один: ветка android-latest-release, она всегда указывает на последний опубликованный релиз. В моё путешествие это был android17-release.
Посмотреть кишки. Если хочется просто почитать код, плыть никуда не надо: на cs.android.com есть поиск по всему AOSP с переходами к определениям и историей файлов. Дальше статья для тех, кто хочет потрогать вживую.
Можно ли внести свой вклад? Можно, хотя с переходом Google на закрытую разработку процесс стал менее прозрачным. Официально AOSP живёт не на GitHub, а на android.googlesource.com. На GitHub есть только неофициальные зеркала, пулл-реквесты туда не принимают. Изменения отправляют на ревью в Gerrit (android-review.googlesource.com). Сначала нужно подписать CLA (Individual Contributor License Agreement, а если вклад от компании, то корпоративный). Дальше всё стандартно: ветка через repo start, коммит с git commit -s, отправка через repo upload. Изменения нужно делать поверх android-latest-release: aosp-main с марта 2025 года доступен только для чтения. Патч смотрят инженеры Google, и если он принят, его переносят во внутреннюю ветку. Отдельного уведомления о решении не будет, результат появится в одном из следующих опубликованных релизов. Подробности в официальной инструкции: Submit code changes.
Снаряжение корабля
Прежде чем выходить в море, знакомимся с командой.
repo, боцман. AOSP это не один репозиторий, а сотни git-проектов. Их список записан в манифесте (XML-файле), а repo умеет разом всё скачать, обновить и переключить. Справочник команд: Repo command reference, исходники самого repo: git-repo.
Soong и Android.bp, штурман. Собственная система сборки Android. Soong написан на Go и пришёл на смену Android.mk (Make). Android.bp это декларативный файл в синтаксисе Blueprint, похожий на JSON. В нём одинаково описываются модули на любом языке: cc_library, java_library, android_app, rust_binary. По духу ближе к build.gradle, только без кода. Soong читает эти файлы и генерирует граф для Ninja. Обзор: Build overview, подробности: Soong README.
Make и Kati, старый лоцман. Наследие прошлого: часть модулей и почти вся конфигурация продуктов (.mk-файлы устройств, PRODUCT_* переменные) до сих пор живут в make-файлах. Обычный GNU Make для дерева такого размера слишком медленный, поэтому Google написал Kati, клон GNU Make на C++. Он ничего не собирает сам, а, как и Soong, переводит make-файлы в граф для Ninja. Новое на Android.mk писать не надо. Kati: google/kati.
Ninja и siso, гребцы. Низкоуровневые исполнители. Ninja написан на C++ и знаком по CMake и Chromium: он не разбирается в языках и модулях, а просто получает готовый граф “из этих файлов вот этой командой получить вот этот файл” и выполняет его максимально параллельно, пересобирая только то, что изменилось. siso это его замена от команды Chromium, написана на Go, понимает тот же формат и умеет удалённое выполнение. В Android 17 по умолчанию работает именно siso, хотя в логах по старой памяти пишет “ninja”. Напрямую командовать им не придётся, но познакомиться доведётся. При неприятных обстоятельствах. Ссылки: Ninja, Siso.
envsetup.sh, lunch, m, капитанские команды. Это не отдельные программы, а bash-функции. source build/envsetup.sh добавляет их в текущий шелл. lunch выбирает, что собираем: устройство, конфигурацию релиза и вариант сборки. Ближайшая аналогия для Android-разработчика: выбор build variant в Android Studio. m запускает сборку из любого места дерева, это примерно как ./gradlew в корне проекта. Для отдельных модулей есть mm (модули в текущем каталоге) и mmm <путь>, справка по всему зоопарку открывается командой hmm. Подробно: Build Android.
Prebuilts, судовые запасы. Готовые бинарники инструментов, которые лежат прямо в дереве AOSP как обычные git-репозитории: clang, JDK, Python, Go, build-tools и десятки других. Так сборка не зависит от того, что установлено в системе, и у всех получается одинаковый результат. По смыслу похоже на то, как Gradle скачивает строго заданную версию AGP и Kotlin, только здесь всё уже лежит в исходниках. Свою Java ставить не нужно. Запомните слово prebuilts, оно ещё рванёт. Список: platform/prebuilts.
adb вы знаете. На своей userdebug-сборке у него открываются возможности, которых на обычном телефоне нет: adb root, adb remount (системные разделы становятся доступны для записи) и adb sync (залить собранные файлы прямо в работающую систему).
Android Emulator тот же, что в Android Studio: он основан на QEMU и умеет запускать не только образы Google, но и собранные самостоятельно. Им мы и воспользуемся.
Cuttlefish это виртуальное устройство Google для Linux с KVM. Оно работает без окна, экран открывается в браузере, и по устройству ближе к настоящему телефону, поэтому его используют в CI и для разработки самой платформы. На маке он не запускается, так что в этом плавании не понадобится.
Мой корабль не пройдёт? Ага, щас!
Огромные сборки для меня не новость. В начале карьеры, ещё до Android, я собирал большие CAD-системы на C++ под разные ОС и разрядности и до сих пор помню баги, которые жили только на одной ОС и только в 32 битах. Так что к долгой сборке я морально готов.
Мой корабль: MacBook на M5 Max, 18 ядер, 48 ГБ памяти. Открываю требования к судну и вижу:
Требование | Документация | Мой мак |
|---|---|---|
Процессор | “A 64-bit x86 system” | ARM |
Память | “A minimum of 64 GB of RAM” | 48 ГБ |
Диск | “At least 400 GB of free disk space” | больше терабайта, хоть тут повезло |
ОС | 64-битный Linux | macOS |
И отдельной строкой: “Android OS development on macOS isn’t supported as of June 22, 2021”. Три мимо из четырёх. Ещё не отчалили, а первая ловушка уже щёлкнула.
Ловушка №1. ARM-мак
Варианты, которые я перебрал на берегу:
Собирать прямо на macOS. Не поддерживается с Android 11. К тому же файловая система мака по умолчанию не различает регистр букв, а в AOSP есть файлы, имена которых отличаются только регистром.
VirtualBox, который у меня давно стоит. На Apple Silicon он работает как гипервизор без эмуляции процессора, поэтому запускает только ARM64-системы, а Rosetta внутри него недоступна. Prebuilts (clang, JDK, Go,
aapt2) при этом собраны подlinux-x86, и в ARM64-Linux они просто не запустятся.Полная эмуляция x86 через QEMU. Запустится, но каждая x86-инструкция будет эмулироваться программно, и сборка растянется на недели.
Арендовать x86-машину в облаке. Быстро и просто, но платить я принципиально не стал: интересно было собрать на том, что уже стоит на столе.
И тут нашёлся проход между рифами: Rosetta для Linux. Rosetta 2 это транслятор, который Apple выпустила в 2020 году при переходе с Intel на собственные ARM-процессоры. Он переводит машинный код x86-64 в ARM64: большую часть заранее, при первом запуске программы, а динамически сгенерированный код на лету. Именно поэтому старые Intel-приложения на M-маках просто работают. Начиная с macOS 13 Apple умеет отдавать Rosetta и внутрь Linux-виртуалок: там она подключается как обработчик x86-бинарников через binfmt_misc, и x86-программы не эмулируются, а переводятся так же, как на самом маке. Это умеют виртуалки на Apple Virtualization Framework: OrbStack, UTM в режиме Apple Virtualization, Parallels, Colima с --vz-rosetta. Как это выглядит на практике, покажу чуть ниже: в OrbStack достаточно создать машину с --arch amd64, и Rosetta подключится к ней сама, без ручной настройки.
На этом маршруте я был не первым. Один мореплаватель собрал Android 15 на M1 Pro с 16 ГБ памяти и 32 ГБ swap в OrbStack за 12,5 часов. Другой ходил к Android 14 на M3 Max. Их отметки на карте очень помогли, хотя, как выяснилось, не все из них до сих пор верны.

Мак работает на arm64, а Linux внутри виртуалки считает себя x86_64. Когда запускается x86-программа, например clang из prebuilts, Linux сам отдаёт её Rosetta, и та переводит код для ARM-процессора
Решение: OrbStack, внутри Ubuntu 24.04 под x86-64. uname -m там честно говорит x86_64, хотя под капотом ARM и Rosetta.
brew install --cask orbstack
orb create --arch amd64 ubuntu:noble aospНайм команды
Ловушка №2. Шестнадцать гигабайт
Машина создана, радуюсь, и выясняю, что OrbStack по умолчанию выдаёт ей 16 ГБ памяти. Для AOSP это даже не смешно. Поднял до 36 ГБ, оставив маку 12 на жизнь:
orb config set memory_mib 36864
orb stop && orb start # настройка применится после перезапуска
orb -m aosp bash -c 'uname -m; nproc; free -g; df -h /'Проверяем, что получилось: архитектура, число ядер, память и диск внутри машины. Скриншот я сделал уже после сборки, поэтому на диске заняты 229 ГБ:

Это ресурсы Linux-машины, как она видит их сама: архитектура x86_64, 17 ядер, 35 ГБ памяти (из выделенных 36). Диск на 1,2 ТБ это не отданный машине терабайт, а максимальный размер её динамического диска: место на маке расходуется только по мере записи, здесь это 229 ГБ
36 ГБ меньше официальных 64. Забегая вперёд: хватило, пиковое потребление памяти одним процессом за всю сборку дошло до 35 ГБ, swap не понадобился. Но запас был впритык.
Полезная мелочь на будущее: файловая система машины видна с мака по пути ~/OrbStack/aosp/. Готовый образ потом не придётся никуда копировать, его можно забрать прямо из ~/OrbStack/aosp/home/<вы>/aosp/out.
Ловушка №3. Куда делись мои контейнеры?
Эта ловушка срабатывает ещё до сборки. При установке OrbStack делает свой Docker-контекст активным. До этого у меня стояла Colima (бесплатная консольная утилита, которая запускает Docker на маке внутри лёгкой Linux-виртуалки), и все мои контейнеры внезапно “пропали”. На самом деле ничего не пропало: просто docker теперь обращается к другому движку. Возвращаем прежний и запрещаем OrbStack переключать его снова:
docker context use colima
orb config set docker.set_context falseПогрузка припасов
С этого момента и до конца сборки все команды выполняются не на маке, а внутри Linux-виртуалки aosp в OrbStack. Заходим в неё с мака командой orb -m aosp и ставим пакеты строго по документации, тут без сюрпризов:
sudo apt-get update
sudo apt-get install -y git-core gnupg flex bison build-essential zip curl zlib1g-dev \
libc6-dev-i386 x11proto-core-dev libx11-dev lib32z1-dev libgl1-mesa-dev libxml2-utils \
xsltproc unzip fontconfig python3 python-is-python3 rsync tmux time
sudo curl -s https://storage.googleapis.com/git-repo-downloads/repo -o /usr/local/bin/repo
sudo chmod a+x /usr/local/bin/repo
repo version | head -2
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
git config --global color.ui falseК списку из документации я добавил три пакета: python-is-python3 (часть скриптов зовёт просто python), tmux и time. Последние два нужны для долгой сборки: tmux переживёт закрытый терминал, а /usr/bin/time -v в конце честно покажет время и пиковую память. repo беру не из пакетов, а свежий с серверов Google: repo version должен показать 2.4 или новее.
Ловушка №4. repo sync
Грузим исходники ровно так, как велит документация:
mkdir -p ~/aosp ~/logs && cd ~/aosp
repo init --partial-clone --no-use-superproject \
-b android-latest-release \
-u https://android.googlesource.com/platform/manifest
repo sync -c -j8Сам я запускал это не голыми командами, а скриптом внутри tmux, с логом в файл, чтобы утром было что читать. Для статьи я его причесал и дописал в конец повтор для упавших репозиториев (в тот раз я запускал его руками, о чём ниже). Так получился vm-sync.sh. Он и остальные скрипты из статьи лежат в репозитории aosp-on-apple-silicon, полный список в конце:
tmux new -s sync 'bash vm-sync.sh 2>&1 | tee ~/logs/sync.log'vm-sync.sh: repo init, repo sync и повтор для упавших репозиториев#!/bin/bash
# Исходники держим внутри Linux-машины (регистрозависимая ФС, быстрее, чем общая папка мака).
# Запускать в tmux, чтобы пережить обрыв сессии: mkdir -p ~/logs; tmux new -s sync 'bash vm-sync.sh 2>&1 | tee ~/logs/sync.log'
mkdir -p ~/aosp ~/logs && cd ~/aosp
echo "init start $(date -Is)"
/usr/bin/time -v repo init --partial-clone --no-use-superproject \
-b android-latest-release -u https://android.googlesource.com/platform/manifest
echo "sync start $(date -Is)"
/usr/bin/time -v repo sync -c -j8
echo "sync rc=$? $(date -Is)"
# Повтор для упавших
repo sync -c -j2 prebuilts/tools system/extras
du -sh ~/aosp--partial-clone говорит git не тащить сразу все объекты всех репозиториев, а докачивать их по требованию. Документация советует его как средство от плохой сети.
Сначала приятное: документация обещает “a little over an hour”, и это правда. У меня вышел 1 час 6 минут, 124 ГБ, около 30 МБ/с.
Теперь неприятное: repo sync закончился ошибками “Cannot initialize work tree” в трёх репозиториях. Двое, prebuilts/tools и system/extras, спокойно догрузились повторным repo sync -c -j2 prebuilts/tools system/extras. Простое правило: упал repo sync, запусти ещё раз, а если сыплются 429 и таймауты, уменьши -j.
А вот третий не сдался.
Ловушка №5. Тяжёлый шланг
prebuilts/clang/host/linux-x86 это каталог с готовыми компиляторами на десятки гигабайт. При partial clone в локальной копии не хватает деревьев, и git при checkout начинает докачивать недостающие объекты по одному за запрос, по полсекунды на каждый. Объектов тысячи. Примерно через шесть минут repo сдаётся, и сколько его ни перезапускай, результат тот же.
Забавно получается: флаг, который документация советует как раз против проблем с сетью, на самом тяжёлом репозитории эту проблему и создаёт. На страницах Download и Troubleshoot sync про это ни слова.
Обход: скачать этот репозиторий отдельно и неглубоко.
cd ~/aosp/prebuilts/clang/host
rmdir linux-x86 2>/dev/null || rm -rf linux-x86 # недоделанный checkout
git clone --depth=1 --single-branch -b android17-release \
https://android.googlesource.com/platform/prebuilts/clang/host/linux-x86 linux-x86
Конец загрузки: три репозитория не распаковались, clang скачан отдельно
14 минут и 23 ГБ в один поток. Ветку в -b берите ту, на которую указывает ваш android-latest-release. Всего исходники заняли около 150 ГБ: 109 ГБ рабочих файлов и 39 ГБ git-объектов в .repo.
Выбираем, что собирать
Пока ничего не горит, разберёмся с lunch. Формат команды:
lunch <продукт>-<release config>-<вариант сборки>Продукт это устройство. Для эмулятора есть sdk_phone64_x86_64 и sdk_phone64_arm64, для Cuttlefish aosp_cf_x86_64_only_phone. Мне на маке нужен ARM-образ: эмулятор на Apple Silicon запускает ARM64-образы нативно, без трансляции кода. Да, собирать ARM-образ мы будем на “x86” машине, которая сама работает на ARM.

Release config это набор значений для флагов фич. Часть нового кода в AOSP спрятана за флагами запуска (feature launch flags), и release config решает, какие из них в этой сборке включены, а какие нет. Так в одном дереве уживаются и готовые фичи, и недоделанные. Для публичного AOSP документация называет конфиг aosp_current, его и берём. Другие имена, которые попадаются в старых статьях (например trunk_staging), относятся к внутренней разработке Google, и на публичной ветке я бы на них не рассчитывал.
Вариант сборки решает, будет ли у нас root:
Вариант | Для чего |
| Особенности |
|---|---|---|---|
| Продакшен, то, что ставится на телефоны пользователей | Нет |
|
| Как | Да | Почти продакшен-поведение плюс root и отладочные инструменты |
| Ежедневная разработка платформы | Да | Максимум отладки, поведение дальше всего от продакшена |
Для изучения системы лучший выбор userdebug: система ведёт себя почти как продакшен, но root у нас в кармане. user нужен, чтобы увидеть систему глазами обычного приложения, и тем, кто занимается безопасностью, полезно собрать оба и сравнить, что видит root-детект.
Переключение между user и userdebug пересобирает огромное число модулей. Если хотите держать обе сборки, разведите их по разным каталогам через export OUT_DIR=out_user: места уйдёт вдвое больше, зато переключаться можно мгновенно. Сам я собирал только userdebug.
# envsetup.sh это скрипт самого AOSP из дерева исходников:
# он добавляет в текущий шелл функции lunch, m, mm, mmm и другие
source build/envsetup.sh
lunch sdk_phone64_arm64-aosp_current-userdebugМелочи, на которых спотыкаются все. Окружение живёт в конкретном шелле: открыли новую вкладку, значит снова
envsetupиlunch. СвойJAVA_HOMEв.bashrcможет помешать JDK из prebuilts. А “застывшая” на одном проценте сборка обычно не застыла: загляните вhtop, если CPU занят, ждите.
Сборка
Ловушка №6. Устаревший совет
nsjail это песочница, в которой система сборки запускает часть шагов. Под Rosetta и в лёгких виртуалках она не работает. Авторы, собиравшие Android 14 и 15, оставили простой совет: переименуй бинарник, и дело с концом.
# НЕ делайте так в Android 17
mv prebuilts/build-tools/linux-x86/bin/nsjail prebuilts/build-tools/linux-x86/bin/nsjail.oldЯ сделал. Через полторы минуты Soong упал. Оказалось, в Android 17 он ссылается на этот файл как на исходник тестового модуля (build/soong/genrule/Android.bp, модуль nsjail_genrule_test_gen_with_nsjail), и без файла анализ ломается.

Обратите внимание на соседнюю строку: “Build sandboxing disabled due to nsjail error”. Сборка уже сама всё поняла
Возвращаю файл на место и выясняю, что делать ничего не нужно: сборка сама пишет “Build sandboxing disabled due to nsjail error” и отключает песочницу. Мораль этой ловушки дороже самой ловушки: чужие советы верны для той версии, на которой их проверяли. Не применяйте старые обходы, пока не увидели проблему своими глазами.
Ловушка №7. dexpreopt
Второй совет предшественников: в таких виртуалках падает dex2oat при предварительной компиляции байткода в образ (dexpreopt). Тут я решил не рисковать семичасовой сборкой и сразу отключил:
m emu_img_zip WITH_DEXPREOPT=falseЧестно признаюсь: проверять, упадёт ли Android 17 без этого флага, я не стал. Цена флага небольшая, первая загрузка эмулятора и первый запуск приложений чуть медленнее. Если проверите, расскажите в комментариях.
emu_img_zip это цель, которая не только собирает образ, но и упаковывает его в архив для Android Studio. Сама сборка началась после 7 минут анализа Android.bp: под Rosetta даже Soong думает неторопливо.
Ловушка №8. Мак уснул
Сборка идёт ночью, я сплю, и мак тоже хочет спать. А OrbStack, когда мак засыпает, ставит машину на паузу. Не даём маку уснуть:
caffeinate -ims -t 172800 &И не закрывайте крышку без внешнего монитора: тогда мак уснёт всё равно, caffeinate не спасёт.
Ловушка №9. siso и код 133
2 часа 52 минуты, 79 400 действий из 163 437. Сборка просто встала на середине. Ни одной упавшей команды, лог обрывается на полуслове, код выхода 133. Это siso получил SIGTRAP под Rosetta, то есть siso уснул прямо посреди работы. SIGTRAP это сигнал-“ловушка”: им отладчик останавливает процесс на точке останова, а если отладчика нет, процесс от него просто завершается. Откуда 133: по соглашению Unix-шеллов, если процесс убит сигналом, код выхода равен 128 плюс номер сигнала. У SIGTRAP номер 5 (это видно в kill -l), отсюда 128 + 5 = 133.

Настоящий лог той ночи: 48%, “exit status 133”, 2 часа 52 минуты коту под хвост. На самом деле нет
Сначала стало грустно, потом смешно: лечится повторным m. Всё уже собранное сохраняется, сборка продолжается с того же места. Чтобы ночью не караулить такие падения, вторую попытку я запустил уже в цикле. Сама сборка лежит в скрипте vm-build.sh (envsetup, lunch, m emu_img_zip WITH_DEXPREOPT=false и строка build rc=$? в конце), а цикл смотрит в лог и перезапускает сборку, только если siso упал именно с кодом 133. Любую другую ошибку он честно оставляет мне на утро:
# так выглядит скрипт vm-build-loop.sh: он запускает vm-build.sh и повторяет его,
# если siso упал с кодом 133
for i in 1 2 3 4 5 6 7 8; do
echo "=== attempt $i $(date -Is)"
bash ~/vm-build.sh > ~/logs/build.log 2>&1
cat ~/logs/build.log >> ~/logs/build-all.log
grep -q "build rc=0" ~/logs/build.log && { echo "=== OK $(date -Is)"; break; }
grep -q "ninja failed with: exit status 133" ~/logs/build.log \
|| { echo "=== real failure $(date -Is)"; break; }
doneЭтот цикл лежит в скрипте vm-build-loop.sh и запускается тоже в tmux: tmux new -s build 'bash vm-build-loop.sh 2>&1 | tee ~/logs/build-loop.log'. Да, siso пишет про себя “ninja failed”: старое имя в логах ещё не сменили.
Вторая попытка шла ещё 4 часа 39 минут, и второго падения не случилось: циклу так и не пришлось ничего перезапускать. CPU в среднем был занят на 1150% из 1700% возможных, темп держался 430-630 действий в минуту на C/C++ и проседал до 270 на TensorFlow и линковке. Итого около 7,5 часа. Документация обещает 6 часов на 6-ядерной машине с 64 ГБ, так что Rosetta своё отгребла честно.

Вторая попытка: 95 993 шага за 4:38:50. Архив тут весит 804 МБ, потому что снимок сделан после пересборки с правками, о которых речь пойдёт ниже, исходный был 842 МБ
Каталог out вырос до 90 ГБ, всё дерево до 237 ГБ (109 ГБ рабочих файлов, 39 ГБ .repo и 90 ГБ out). В out лежит заветный out/target/product/emu64a/sdk-repo-linux-system-images.zip размером 842 МБ.
Образ
Ловушка №10. Как этот образ запустить?
Цель уже близко. Открываю страницу документации про эмулятор и вижу там… инструкцию из какой-то другой эпохи. Подробности в разделе про ошибки документации ниже, а сначала то, что сработало.
Распаковываем архив в SDK. В имени архива написано linux, но сами образы от хоста не зависят:
SDK="$HOME/Library/Android/sdk"
OUT="$HOME/OrbStack/aosp/home/$(orb -m aosp whoami)/aosp/out/target/product/emu64a"
IMG="$SDK/system-images/aosp-local-17/default"
mkdir -p "$IMG" && (cd "$IMG" && unzip -q -o "$OUT/sdk-repo-linux-system-images.zip")
cat "$IMG/arm64-v8a/source.properties" # внутри появилась папка arm64-v8aКопировать архив из машины не нужно: мак видит её диск напрямую через ~/OrbStack/aosp/.
Пробую создать AVD штатно и заодно открываю Device Manager в Android Studio:
avdmanager create avd -n AOSP17_local -k "system-images;aosp-local-17;default;arm64-v8a"
# Package path is not validAndroid Studio вторит ему по-своему: “Missing system image aosp-local-17/default/arm64-v8a”. Образ лежит на месте, а инструменты уверяют, что его нет.
Причина в описании пакета. SDK-инструменты узнают образ не по папке и не по source.properties, а по файлу package.xml, который обычно кладёт рядом SDK Manager при скачивании. В архиве из сборки AOSP лежит только старый source.properties, а package.xml нет. Документация предлагает выложить zip на свой сервер и подключить его как сайт обновлений в SDK Manager, но поднимать свой сервер ради одного образа не хотелось. Проще написать опись самому:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<ns2:repository xmlns:ns2="http://schemas.android.com/repository/android/common/02" xmlns:ns14="http://schemas.android.com/sdk/android/repo/sys-img2/05">
<license id="aosp-local-license" type="text">Locally built AOSP image. Apache License 2.0 and the licenses listed in NOTICE.txt.</license>
<localPackage path="system-images;aosp-local-17;default;arm64-v8a" obsolete="false">
<type-details xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="ns14:sysImgDetailsType">
<api-level>37.0</api-level>
<extension-level>22</extension-level>
<base-extension>true</base-extension>
<tag><id>default</id><display>Default Android System Image</display></tag>
<vendor><id>aosp-local</id><display>Local AOSP build</display></vendor>
<abi>arm64-v8a</abi>
<abis>arm64-v8a</abis>
</type-details>
<revision><major>1</major></revision>
<display-name>AOSP 17 local build ARM 64 v8a System Image</display-name>
<uses-license ref="aosp-local-license"/>
</localPackage>
</ns2:repository>
Файл кладётся в $IMG/arm64-v8a/package.xml. Главное в нём path (он должен совпадать с путём папки, только через точку с запятой), api-level 37.0, тег default и ABI. Проверяем, что описание приняли:
sdkmanager --list_installed | grep aosp-localПакет появился в списке, и с этого момента и avdmanager, и Device Manager видят образ как родной. Дальше AVD можно создать мышкой в Android Studio. Я всё же описал его руками, чтобы было видно, что внутри: это два файла в ~/.android/avd/.
AOSP17_local.ini (путь здесь нужен абсолютный):
avd.ini.encoding=UTF-8
path=/Users/<вы>/.android/avd/AOSP17_local.avd
path.rel=avd/AOSP17_local.avd
target=android-37.0Обратите внимание на target: Android 17 это API 37.
AOSP17_local.avd/config.ini, ровно тот, с которым я работал:
avd.ini.encoding=UTF-8
AvdId=AOSP17_local
avd.ini.displayname=AOSP 17 (local build)
image.sysdir.1=system-images/aosp-local-17/default/arm64-v8a/
tag.id=default
tag.display=Default
abi.type=arm64-v8a
hw.cpu.arch=arm64
target=android-37.0
hw.ramSize=4096
hw.cpu.ncore=4
hw.lcd.width=1280
hw.lcd.height=2856
hw.lcd.density=480
hw.gpu.enabled=yes
hw.gpu.mode=auto
hw.keyboard=yes
disk.dataPartition.size=6G
PlayStore.enabled=false
fastboot.forceColdBoot=yesКлючевые здесь image.sysdir.1, abi.type, tag.id, hw.cpu.arch и target: без них эмулятор образ не найдёт. Размеры экрана, ядра и память можно менять по вкусу. Весь этот путь от распаковки до загруженного эмулятора собран в скрипт mac-install-image.sh: он распаковывает образ в SDK, пишет package.xml, создаёт AVD, запускает эмулятор, сам дожидается sys.boot_completed и печатает отпечаток сборки.
Ура, клад!
emulator -avd AOSP17_local -writable-system -no-snapshot-writable-system пригодится чуть ниже, когда будем заливать файлы прямо в работающую систему.
Холодная загрузка: 12,4 секунды. И вот он, результат: Android 17, собранный на ноутбуке, которому по документации это не положено.

Проверяем, что образ наш, а не скачанный:
adb shell getprop ro.build.fingerprint
# Android/sdk_phone64_arm64/emu64a:17/CP2A.260605.016/eng.red_by:userdebug/test-keyseng.red_by в отпечатке это моё имя пользователя на машине сборки. Своего рода автограф. Его же видно в настройках, в разделе “About emulated device”:


Android 17, патч безопасности от 5 июня 2026, ядро 6.12, и в номере сборки моё имя
И root, ради которого всё затевалось:
adb root
adb shell whoami
# rootНа user-сборке тот же adb root ответит “adbd cannot run as root in production builds”. Это та самая запертая дверь, в которую упирается любой, кто пытается “просто посмотреть” на продакшен-устройство.
Итог: около часа на загрузку исходников, около 7,5 часа сборки, четверть терабайта на диске, десять ловушек, 3,5 литра чая и 0 рублей сторонним сервисам.
Быстрая пересборка
Первый вопрос после запуска: если поменяю одну строчку, собирать ещё семь часов? Нет, но с оговоркой, которой нет в документации.
Проверил на живом примере. Взял утилиту service (та, что отвечает на adb shell service list) и поменял в frameworks/native/cmds/service/service.cpp одну строку вывода. Было Found 316 services:, стало [red_byte] Сервисов найдено: 316:
Vector<String16> services = sm->listServices();
- aout << "Found " << services.size() << " services:" << endl;
+ aout << "[red_byte] Сервисов найдено: " << services.size() << endl;Собираем только этот модуль:
m serviceИмя модуля ищется в Android.bp рядом с кодом, в поле name. Ещё есть mm (модули в текущей директории) и mmm <путь>.
Первый m service шёл 13 минут. Сама компиляция и линковка заняли 20 секунд и 12 действий, а всё остальное время ушло на разовый перезапуск анализа: Soong перечитывает Android.bp (около 7 минут под Rosetta), Kati перечитывает make-файлы (ещё около 5). Следующая правка того же файла собралась уже за 30 секунд.
Доставить изменение в эмулятор можно двумя путями. Пересобрать образ (m emu_img_zip после такой правки занял у меня 14 минут 40 секунд, из них сама сборка чуть больше минуты) и перезапустить эмулятор. Или залить файл прямо в работающий эмулятор. Для этого нужен userdebug и эмулятор, запущенный с -writable-system. Первый remount попросит перезагрузку:
# на маке; OUT тот же, что при установке образа
adb root && adb remount && adb reboot # первый remount просит перезагрузку
adb root && adb remount
adb push "$OUT/system/bin/service" /system/bin/service
adb shell service list | head -1
# [red_byte] Сервисов найдено: 316![Терминал: m service за 30 секунд, adb remount, adb push, service list печатает “[red_byte] Сервисов найдено: 316 Терминал: m service за 30 секунд, adb remount, adb push, service list печатает “[red_byte] Сервисов найдено: 316](https://habrastorage.org/r/w1560/extt/aa/83/11/aa8311f1a25cce149992551c6964125c.png)
Вторая правка: 30 секунд сборки, доли секунды на доставку, и система говорит по-русски моими словами
Файл доехал за 3 секунды. Если поменяли много всего, вместо поштучного push есть ANDROID_PRODUCT_OUT="$OUT" adb sync system.
Главный вывод: под Rosetta дорогая часть пересборки не компиляция, а анализ. Компилировать почти нечего, а вот перечитать десятки тысяч описаний модулей приходится каждый раз, когда меняется цель lunch или make-файл. Смена lunch-цели, repo sync на новую версию и правка .mk означают минуты анализа. А m clean стирает всё собранное, и начинать придётся с нуля, так что жмите его, только когда ничего больше не помогает.
Настраиваем ART для всей системы
Раз у нас есть своя сборка, можно покрутить то, что приложению в обычной жизни недоступно: параметры самой виртуальной машины. Для Android-разработчика это как получить доступ к флагам JVM, только сразу для всех приложений на устройстве.
Как это устроено. Все приложения в Android запускаются форком процесса zygote. А zygote при старте поднимает ART с опциями, которые собирает из системных свойств dalvik.vm.* (смотрите AndroidRuntime::startVm в frameworks/base/core/jni/AndroidRuntime.cpp). Полный список опций виден в logcat строками вида zygote64: option[N]=....
Свойство | Опция ART | Что значит |
|---|---|---|
|
| начальный размер кучи |
|
| максимум кучи для приложений с |
|
| максимум для обычного приложения, именно его вернёт |
|
| насколько плотно GC старается заполнять кучу |
|
| строгая проверка JNI-вызовов |
| что угодно | произвольные опции через пробел |
Значения по умолчанию на моей сборке: heapstartsize 8m, heapgrowthlimit 192m, heapsize 576m, heaptargetutilization 0.75.
Чтобы видеть эффект, я написал крошечную программу:
public class ArtInfo {
public static void main(String[] a) throws Exception {
Runtime r = Runtime.getRuntime();
System.out.println("maxMemory = " + (r.maxMemory() >> 20) + " MiB");
System.out.println("totalMemory = " + (r.totalMemory() >> 20) + " MiB");
System.out.println("vm = " + System.getProperty("java.vm.version"));
}
}Компилируем в dex (javac, затем d8 из build-tools), кладём в /data/local/tmp/ и запускаем через app_process, который поднимает ART с теми же системными свойствами, что и zygote:
javac --release 8 ArtInfo.java && d8 --min-api 30 --output . ArtInfo.class
adb push classes.dex /data/local/tmp/artinfo.dex
adb shell CLASSPATH=/data/local/tmp/artinfo.dex app_process / ArtInfo
# maxMemory = 192 MiB
# totalMemory = 8 MiBБыстро, на ходу
На userdebug с root свойства меняются прямо на ходу:
adb root
adb shell setprop dalvik.vm.heapgrowthlimit 384m
adb shell setprop dalvik.vm.heapstartsize 32m
adb shell setprop dalvik.vm.checkjni true
adb shell setprop dalvik.vm.extra-opts '"-verbose:gc -XX:LowMemoryMode"'app_process подхватывает новые значения сразу:
adb shell CLASSPATH=/data/local/tmp/artinfo.dex app_process / ArtInfo
# maxMemory = 384 MiB
# totalMemory = 32 MiBЧтобы их увидели обычные приложения, перезапускаем zygote (на эмуляторе 12 секунд):
adb shell 'stop; start'
adb shell 'logcat -d | grep -E "CheckJNI|zygote64: option\["'После этого в logcat появляются AndroidRuntime: CheckJNI is ON, zygote64: option[...]=-XX:HeapGrowthLimit=384m и -XX:LowMemoryMode, а GC начинает отчитываться о каждой сборке строками вроде “Background concurrent mark compact GC freed 45MB”. Для отладки утечек и JNI-ошибок в своих приложениях очень полезный режим. Подвох: всё это живёт до перезагрузки.
Навсегда, в прошивку
Чтобы параметры стали частью прошивки, добавляем их в make-файл продукта. Для нашего эмулятора это device/generic/goldfish/64bitonly/product/sdk_phone64_arm64.mk:
PRODUCT_VENDOR_PROPERTIES += \
dalvik.vm.heapgrowthlimit=256m \
dalvik.vm.heapstartsize=16m \
dalvik.vm.heaptargetutilization=0.6Значения по умолчанию задаются в frameworks/native/build/phone-xhdpi-2048-dalvik-heap.mk через ?=, то есть “если никто не задал иначе”. Обычное = сильнее, и скрипт post_process_props.py сам закомментирует старые строки в vendor/build.prop с честной подписью: “Removed by post_process_props.py because overridden by dalvik.vm.heapgrowthlimit=256m”.
Пересобирать весь образ ради одного файла не обязательно. В Android 17 vendor/build.prop всё ещё собирает Make, отдельного модуля под него нет, поэтому цель указывается путём:
m out/target/product/emu64a/vendor/build.prop
adb root && adb remount
adb push $ANDROID_PRODUCT_OUT/vendor/build.prop /vendor/build.prop
adb rebootЕсли анализ прогрет, сборка занимает 26 секунд, а после правки .mk первый прогон снова около 13 минут. После перезагрузки zygote стартует с -Xms16m, -XX:HeapGrowthLimit=256m, -XX:HeapTargetUtilization=0.6, а ArtInfo показывает maxMemory = 256 MiB.

Прошивка приняла: свойства, ART и zygote согласны друг с другом. Кроме heapsize, о нём ниже
Ловушка №11. heapsize на эмуляторе
Ловушка, которая ждала уже в самом конце. На снимке выше видно строку [ro.boot.dalvik.vm.heapsize]: [576m]. Сколько ни пиши dalvik.vm.heapsize в build.prop, на эмуляторе остаются его 576m. Значение переопределяет загрузчик через ro.boot.dalvik.vm.heapsize, а эмулятор вычисляет его из объёма RAM виртуального устройства. Поэтому heapsize на эмуляторе меняется в настройках AVD, а не в прошивке.
Бонус: своя анимация загрузки и имя устройства
Раз сборка своя, грех не поставить на неё свою подпись. Две правки в make-файле продукта, и эмулятор загружается с моей анимацией и называется по-моему.
Откуда берётся надпись “android”. Если в прошивке нет своего bootanimation.zip, система рисует переливающееся слово “android” из двух картинок:
frameworks/base/core/res/assets/images/android-logo-mask.png
frameworks/base/core/res/assets/images/android-logo-shine.pngmask задаёт форму надписи, shine это блик, который едет под маской. В нашем образе в /product/media своего архива нет, там лежит только папка audio, поэтому и показывается стандартная надпись. Значит, достаточно подложить туда свой bootanimation.zip: он главнее этих картинок.
Свой bootanimation.zip. Внутри файл desc.txt и папки с кадрами PNG. Мой вариант: Android-байкер в круге въезжает слева с наклоном, клюёт носом при торможении, а под ним появляется крупная красная “R”. Она гаснет, и из её центра по буквам разъезжается надпись red_byte, перекрашиваясь из красного в зелёный. Дальше байкер покачивается на месте с полосами скорости, пока система не загрузится. Кадры я нарисовал небольшим Kotlin-скриптом bootanim-gen.main.kts (лежит рядом с остальными скриптами). Для запуска нужны только JDK и Kotlin (brew install kotlin): на вход картинка с байкером, на выходе папка с desc.txt и кадрами.
kotlin bootanim-gen.main.kts biker-src.png bootanimationbootanim-gen.main.kts: генератор кадров анимации#!/usr/bin/env kotlin
// Генератор кадров bootanimation: байкер въезжает слева, тормозит и “едет на месте”.
// Запуск: kotlin bootanim-gen.main.kts <картинка.png> <папка-вывода>
// В папке появятся desc.txt, part0/ (въезд и надпись, 45 кадров) и part1/ (цикл, 30 кадров).
// Упаковка без сжатия: cd <папка-вывода> && zip -0 -q -X -r ../bootanimation.zip desc.txt part0 part1
import java.awt.BasicStroke
import java.awt.Color
import java.awt.Font
import java.awt.RenderingHints
import java.awt.geom.Ellipse2D
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import kotlin.math.PI
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.sin
val src = ImageIO.read(File(args.getOrElse(0) { "biker-src.png" }))
val out = File(args.getOrElse(1) { "anim" })
val W = 1080
val H = 1260
val D = 760.0 // диаметр круга на кадре
val CX = 540.0 // центр круга
val CY = 440.0 // от верхнего края (в Java2D ось Y смотрит вниз)
val FPS = 30
// Вырезаем круг: всё за пределами круга прозрачное, белый фон исходника уходит
val disc: BufferedImage = run {
val s = src.width
val img = BufferedImage(s, s, BufferedImage.TYPE_INT_ARGB)
val g = img.createGraphics()
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val r = s / 2.0 - 3 // внешний край чёрной обводки
g.clip = Ellipse2D.Double(s / 2.0 - r, s / 2.0 - r, 2 * r, 2 * r)
g.drawImage(src, 0, 0, null)
g.dispose()
img
}
val green = Color(0x3D, 0xDC, 0x84) // фирменный зелёный Android
val font = Font(Font.MONOSPACED, Font.BOLD, 104)
// Полосы скорости слева от круга: байкер едет вправо, воздух уносится назад, влево
fun speedLines(g: java.awt.Graphics2D, x0: Double, y0: Double, phase: Double, alpha: Double) {
val rows = listOf(Triple(-250.0, 220.0, 0.0), Triple(-150.0, 320.0, 0.35), Triple(-40.0, 260.0, 0.7),
Triple(70.0, 340.0, 0.15), Triple(180.0, 240.0, 0.55), Triple(260.0, 180.0, 0.85))
g.stroke = BasicStroke(7f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)
for ((dy, len, off) in rows) {
val p = (phase + off) % 1.0
val xr = x0 - D / 2 + 40 - p * 420
val a = (alpha * (1 - p) * 0.85).coerceIn(0.0, 1.0)
g.color = Color(1f, 1f, 1f, a.toFloat())
val y = y0 - dy // dy задан “вверх”, переводим в координаты Java2D
g.drawLine(xr.toInt(), y.toInt(), (xr - len).toInt(), y.toInt())
}
}
val red = Color(0xE5, 0x39, 0x35)
val TEXT = "red_byte"
fun lerp(a: Double, b: Double, t: Double) = a + (b - a) * t
fun mix(c1: Color, c2: Color, t: Double) = Color(
lerp(c1.red.toDouble(), c2.red.toDouble(), t).toInt(),
lerp(c1.green.toDouble(), c2.green.toDouble(), t).toInt(),
lerp(c1.blue.toDouble(), c2.blue.toDouble(), t).toInt())
fun smooth(t: Double) = t.coerceIn(0.0, 1.0).let { it * it * (3 - 2 * it) }
// Надпись во время въезда: сначала красная “R”, потом из неё по буквам
// расползаются в стороны буквы red_byte и по дороге зеленеют.
// s: 0..0.3 “R” проявляется, 0.3..1 буквы разъезжаются по местам.
fun intro(g: java.awt.Graphics2D, s: Double) {
g.font = font
val fm = g.fontMetrics
val baseline = H - 150
val total = fm.stringWidth(TEXT)
val left = (W - total) / 2.0
val center = W / 2.0
if (s < 0.3) {
val a = smooth(s / 0.3)
val big = font.deriveFont((104 * lerp(1.6, 1.25, a)).toFloat())
g.font = big
val w = g.fontMetrics.stringWidth("R")
g.color = Color(red.red, red.green, red.blue, (255 * a).toInt())
g.drawString("R", (center - w / 2).toFloat(), baseline.toFloat())
return
}
val k = smooth((s - 0.3) / 0.7)
// “R” гаснет и сжимается, пока буквы выезжают
if (k < 1) {
g.font = font.deriveFont((104 * 1.25 * (1 - 0.5 * k)).toFloat())
val w = g.fontMetrics.stringWidth("R")
g.color = Color(red.red, red.green, red.blue, (255 * (1 - k)).toInt())
g.drawString("R", (center - w / 2).toFloat(), baseline.toFloat())
}
g.font = font
var x = left
for (ch in TEXT) {
val cw = fm.charWidth(ch)
val targetCenter = x + cw / 2.0
// каждая буква стартует из центра; дальние начинают чуть позже
val delay = kotlin.math.abs(targetCenter - center) / (total / 2.0) * 0.25
val kk = smooth((k - delay) / (1 - delay))
val cx = lerp(center, targetCenter, kk)
val c = mix(red, green, kk)
g.color = Color(c.red, c.green, c.blue, (255 * smooth(kk * 3)).toInt())
g.drawString(ch.toString(), (cx - cw / 2).toFloat(), baseline.toFloat())
x += cw
}
}
fun frame(dx: Double, dy: Double, angleDeg: Double, phase: Double, linesAlpha: Double, textAlpha: Double,
introStep: Double? = null): BufferedImage {
val img = BufferedImage(W, H, BufferedImage.TYPE_INT_RGB) // фон чёрный, как у экрана загрузки
val g = img.createGraphics()
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC)
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON)
val x = CX + dx
val y = CY - dy
speedLines(g, x, y, phase, linesAlpha)
val saved = g.transform
g.translate(x, y)
g.rotate(-angleDeg * PI / 180)
g.drawImage(disc, (-D / 2).toInt(), (-D / 2).toInt(), D.toInt(), D.toInt(), null)
g.transform = saved
if (introStep != null) {
intro(g, introStep)
} else if (textAlpha > 0) {
g.font = font
g.color = Color(green.red, green.green, green.blue, (255 * textAlpha.coerceIn(0.0, 1.0)).toInt())
val text = "red_byte"
val w = g.fontMetrics.stringWidth(text)
g.drawString(text, (W - w) / 2, H - 150)
}
g.dispose()
return img
}
fun save(img: BufferedImage, part: String, i: Int) {
val dir = File(out, part).apply { mkdirs() }
ImageIO.write(img, "png", File(dir, "%05d.png".format(i)))
}
// part0 (1,5 с): за первую секунду байкер въезжает слева и тормозит с лёгким клевком,
// а под ним красная “R” по буквам расползается в зелёную надпись red_byte
val INTRO = FPS * 3 / 2
for (i in 0 until INTRO) {
val t = (i / (FPS - 1.0)).coerceAtMost(1.0) // фаза въезда
val e = 1 - (1 - t).pow(3) // ease-out
val dx = -(W / 2.0 + D / 2 + 40) * (1 - e)
val lean = when {
t < 0.85 -> -6 * (1 - t / 0.85)
t < 1.0 -> 2.5 * sin((t - 0.85) / 0.15 * PI)
else -> 0.0
}
val s = i / (INTRO - 1.0) // фаза надписи
save(frame(dx, 0.0, lean, i / 10.0, 1 - t * 0.4, 1.0, introStep = s), "part0", i)
}
// part1: “едет на месте”, бесшовный цикл
for (i in 0 until FPS) {
val t = i / FPS.toDouble()
val bob = 3 * sin(2 * PI * t * 3) + 1.5 * sin(2 * PI * t * 5)
val ang = 0.6 * sin(2 * PI * t * 2)
save(frame(0.0, bob, ang, t * 3, 0.6, 1.0), "part1", i)
}
// desc.txt: размер и FPS, затем части: p <повторы> <пауза> <папка>, 0 повторов = до конца загрузки
File(out, "desc.txt").writeText("$W $H $FPS\np 1 0 part0\np 0 0 part1\n")
println("ok: ${File(out, "part0").list()!!.size} + ${File(out, "part1").list()!!.size} кадров в ${out.path}")bootanimation.zip
├── desc.txt
├── part0/ # 45 кадров: въезд и надпись
└── part1/ # 30 кадров: цикл до конца загрузкиdesc.txt:
1080 1260 30
p 1 0 part0
p 0 0 part1Первая строка задаёт ширину, высоту и FPS. Дальше части в формате p <сколько раз> <пауза> <папка>: part0 играется один раз, а ноль повторов у part1 означает “крутить, пока не загрузится”. Архив собирается без сжатия, иначе вместо анимации будет чёрный экран:
cd bootanimation && zip -0 -r ../bootanimation.zip desc.txt part0 part175 несжатых PNG весят 79 МБ. Тут стоит помнить про место: super-раздел эмулятора занимает 1800 МиБ, и образы после добавления архива заняли 1775,6 из них, запаса осталось меньше 25 МиБ. Если захотите анимацию длиннее, уменьшайте кадры (например, до 540×630) или укорачивайте part1, иначе архив просто перестанет влезать.
Подключаем в продукт. Архив кладём в device/generic/goldfish/redbyte/bootanimation.zip, а в device/generic/goldfish/64bitonly/product/sdk_phone64_arm64.mk добавляем:
PRODUCT_COPY_FILES += \
device/generic/goldfish/redbyte/bootanimation.zip:$(TARGET_COPY_OUT_PRODUCT)/media/bootanimation.zip
PRODUCT_MODEL := RedByte PhonePRODUCT_MODEL это то, что приложения видят в Build.MODEL. А вот PRODUCT_NAME и PRODUCT_DEVICE трогать не надо: на них завязаны пути в out/, конфиги и lunch.
Пересобираем образ:
m emu_img_zip WITH_DEXPREOPT=false15 минут 30 секунд, из них сама сборка 1 минута 32 секунды и 54 шага, остальное снова анализ Kati и Soong после правки .mk. Потом я переделал саму анимацию и заменил только zip, не трогая .mk. Такая пересборка заняла 1 минуту 18 секунд и 10 шагов: анализ Kati и Soong не перезапускался. Архив образа с анимацией весит 922 МБ.
Ловушка напоследок. Если раньше вы делали adb remount и заливали файлы через adb push (как в разделе про быструю пересборку), на эмуляторе остался overlay с вашими старыми файлами. Он переживает замену образа и перекрывает новые файлы. Поэтому новый образ запускаем с чистыми данными:
emulator -avd AOSP17_local -wipe-dataПроверяем:

Модель, архив в /product/media и его desc.txt прямо с устройства


Заодно хороший повод проверить, как ваша аналитика или антифрод реагируют на неизвестную модель устройства.
Другие платформы
Linux на x86-64. Родная платформа, документация работает почти как написана. Ловушки №4 и №5 (repo sync и clang) ждут и вас, а маковские №1-3 и №6-9 скорее всего обойдут стороной. Эмулятор запускается командой emulator прямо из дерева после lunch, нужен KVM.
Windows. В документации AOSP Windows не упоминается вовсе. Путь есть через WSL2: исходники держать внутри файловой системы WSL, а не в /mnt/c, и дать WSL памяти через %UserProfile%\.wslconfig (memory=, swap=). Образ удобнее собрать через m emu_img_zip и запускать в эмуляторе Android Studio под Windows. Сам я этим маршрутом не ходил.
Docker. Если хочется одинакового окружения на разных машинах, сборку можно завернуть в контейнер на ubuntu с теми же пакетами. Дерево храните в Docker volume, а не в bind-mount с хоста: на macOS и Windows bind-mount медленный и с особенностями регистра. И помните, что Docker Desktop на маке и Windows сам живёт в виртуалке, лимиты памяти и диска по умолчанию там смешные.
Где документация врёт
Я сверял страницы source.android.com 13 ноября 2026 года. Все они обновлялись в 2026-м, так что речь не о заброшенных текстах.
Страница про эмулятор (“Use Android Emulator virtual devices”, обновлена 13 июля 2026).
Команда
repo init -uобрывается, URL манифеста просто нет. Скопировать и выполнить её не получится.Цель указана как
lunch sdk_phone_x86_64: старое имя продукта, без release config и варианта, хотя страница Building того же сайта описывает форматпродукт-release-вариант. Рабочая цель для Android 17 у меняsdk_phone64_arm64-aosp_current-userdebug(илиsdk_phone64_x86_64-...для x86-хоста).Сборка через
make -j32вместоm, который везде рекомендуется.Имя архива указано как
sdk-repo-linux-system-images-eng.[username]].zip(со сдвоенной скобкой). У меня файл назывался простоsdk-repo-linux-system-images.zip.Про запуск собранного образа локально ничего нет: предлагается выложить zip на свой сервер и подключить его в SDK Manager. Что в архиве нет
package.xmlи без него Android Studio иavdmanagerобраз не видят, не сказано (ловушка №10).
Download и Troubleshoot sync. Обе страницы советуют
--partial-clone, а вторая прямо как средство от проблем с сетью. Что этот флаг намертво подвешивает checkoutprebuilts/clang/host/linux-x86, не сказано (ловушка №5).Building. Ни слова о том, что исполнителем в Android 17 стал siso, и о его падениях. Впрочем, Rosetta это неподдерживаемый маршрут, так что упрёк условный.
Requirements. “A minimum of 64 GB of RAM”. Строго говоря, не ошибка, а запас: моя сборка уложилась в 36 ГБ без swap. Но рассчитывать на это я бы не стал.
Про первые три пункта я завёл ишью в Google Issue Tracker: страница эмулятора, partial clone и clang, siso на странице Building. Если эти грабли попадались и вам, поставьте им звёздочку: так Google видит, что проблема мешает не одному человеку.
И чтобы быть честным в обе стороны: обещание “чуть больше часа” на repo sync сбылось почти до минуты, а список пакетов для Ubuntu сработал без единой правки.
Скрипты
Всё долгое я запускал скриптами в tmux, короткие команды набирал руками. Для статьи я собрал всё это в аккуратные скрипты с теми же командами и параметрами. Они лежат в репозитории aosp-on-apple-silicon, в таблице перечислены в порядке запуска:
Файл | Где запускать | Что делает |
|---|---|---|
мак | ставит OrbStack, даёт 36 ГБ памяти, создаёт amd64-машину, включает | |
машина | пакеты из документации плюс | |
машина |
| |
машина | отдельный неглубокий | |
машина |
| |
машина | цикл: перезапускает сборку только при коде 133 | |
мак | ставит образ в SDK, пишет | |
машина и мак |
| |
мак |
| |
| мак | рисует кадры и |
| дерево AOSP | параметры ART навсегда в |
| дерево AOSP | та самая строчка в |
| мак | крошечная программа, которая печатает размеры кучи |
Скрипты не волшебные: в них те же команды, что в тексте, плюс логи и замеры времени. Перед запуском прочитайте, особенно mac-orbstack.sh: он ставит программы и меняет настройки OrbStack.
Что не вошло в статью
Мы собрали Android для эмулятора. Прошить реальный телефон это отдельная история: нужны поддерживаемое устройство, разблокированный загрузчик и вендорские блобы.
В нашей сборке нет Google Play Services. Многие приложения, особенно банковские, в такой системе работать не будут или будут работать “странно”.
Сборка на маке через Rosetta это не поддерживаемый маршрут, а мой личный эксперимент. Для Android 17 на M5 Max он сработал, но следующий релиз может расставить новые ловушки. Сами видели, как советы предшественников с Android 14-15 привели меня в ловушку №6.
Маршруты через Windows и Docker я на практике не проверял.
Имена целей и release config меняются от релиза к релизу.
lunchбез аргументов всегда главнее любой статьи, включая эту.
Вместо заключения
Клад найден. Android 17 собран на моём собственном ноутбуке, без единого рубля на облака и аренду железа, причём на маке, где его собирать “нельзя”. Своя сборка запущена в эмуляторе, root получен, системная утилита сломана и починена, ART подкручен для всей системы, а при загрузке теперь едет мой байкер, и телефон называется RedByte Phone. Позади одиннадцать ловушек и ещё одна напоследок в бонусе, а три места, где документация расходится с реальностью, теперь лежат в Google Issue Tracker.
Главный трофей при этом не архив образа на 922 МБ. Чёрный ящик, поверх которого я столько лет писал приложения, стал прозрачнее: теперь видно, откуда берётся build.prop, кто рисует анимацию загрузки и почему adb remount не проходит бесследно.
Белых пятен на карте ещё много. Как устроено дерево исходников и где что искать, как добавить в сборку свой системный сервис, почему SELinux делает root далеко не всемогущим. Но сундук открыт, а дальше каждый копает в свою сторону.
Если вы тоже проходили этот путь и попадали в свои ловушки, приходите в комментарии. Чем громче взорвалось, тем ценнее история.

P.S.
У читателя может возникнуть вопрос: а не проще ли сейчас было обратиться к нейросети, запустить MCP-сервер, который всё сделает за меня, и не мучиться? Отвечу: да, так можно было бы поступить. Наверное, я и сам пошёл бы таким путём, если бы мне нужно было срочно достичь поставленных целей или если бы мне просто было лень… Но, как я уже упоминал в начале статьи, инженером, на мой взгляд, в первую очередь движет любопытство: узнать, как это устроено, из чего состоит и почему работает именно так. А когда всё за тебя делает кто-то другой, новых знаний ты не получаешь - и это просто неинтересно. Любите свою профессию и получайте удовольствие от того, что делаете! Всем удачи!

Комментарии 0