Одно приложение на телефоне, где любой тестировщик ставит и обновляет «По любви» и «По душе». Без входа и без поиска файлов по чатам. Я изучил, как это устроено в TestFlight и похожих сервисах, что разрешает Android и что мешает в России. Ниже: что должно быть в приложении, как сделать его удобным и что поменять в самих сборках.
Дальше это приложение называю хабом (по-английски hub — место, где всё собрано). На телефоне оно может называться «Сборки». От разработчика после сборки нужна одна команда, от тестировщика — одна кнопка.
Одна команда собирает сборку. Она получает новый номер и общую подпись.
В списке у каждой сборки номер, дата, сервер и «что проверить».
Сам заглядывает в список, когда его открывают, и время от времени в фоне. Присылает уведомление.
Нажимает одну кнопку. Если что-то пошло не так, хаб объясняет это простыми словами.
Шесть набросков. Это не макет: макета хаба в Figma нет. Номера задач настоящие, из трекера, для примера.
| Кнопка | Когда появляется |
|---|---|
| Установить | Приложения на телефоне нет |
| Скачивание · 63 % | Файл качается. Если связь оборвётся, загрузка продолжится с того же места. Рядом кнопка «Отмена» |
| Подтвердите установку | Android показал своё окно «Установить?», а его случайно закрыли |
| Устанавливается… | Android ставит сборку |
| Открыть | На телефоне самая свежая сборка |
| Обновить | Вышла сборка новее той, что стоит |
| Повторить | Что-то пошло не так. Причина — одной строкой под кнопкой |
Что стоит на телефоне, хаб каждый раз спрашивает у самого телефона. Поэтому кнопка показывает правду, даже если сборку поставили из Telegram или удалили. Такую схему «одна кнопка на приложение» использует TestFlight.
Старые сборки хаб не выключает по сроку, как TestFlight через 90 дней. У одного разработчика после такого перерыва 90% тестировщиков ушли на магазинную версию и не поставили следующую тестовую.
Без этих изменений хаб не будет удобным. Они полезны и до хаба: страница со списком сборок и QR-кодами уже избавит от поиска по чатам.
| Что | Как сейчас | Как нужно | Что это даёт |
|---|---|---|---|
| Номер сборки | «По любви» и основная ветка: у всех сборок 1. «По душе» в рабочих ветках с 22.09 уже растёт | Растущий номер во всех ветках обоих приложений | Телефон и хаб отличают новую сборку от старой. Журнал ошибок разделяет сборки |
| Ключ подписи | У каждого компьютера свой: у Mac mini один, у вашего Windows другой | Один общий тестовый ключ на всех компьютерах, копия в хранилище паролей | «Обновить» не превращается в «удалите и поставьте заново» |
| Выкладка | Файл руками на российский сервер, ссылка в Telegram | Одна команда: файл, запись в списке сборок, объявление | Ничего не теряется, у каждой сборки есть «Что проверить» |
| Объявление | Ссылка на файл | Ссылка на сборку в хабе, QR-код, запись в задачах трекера | Люди ставят через хаб, а не файлом из чата |
| Размер «По душе» | 79 МБ, из них 46 МБ — карты сразу для четырёх типов процессоров | Отдельный файл под тип процессора, ужатые картинки | Около 25–30 МБ: быстрее качается, меньше места на телефоне |
Файлы должны лежать в России. Замер 22 сентября: с американского сервера сборка качалась примерно со скоростью 100 КБ/с, с российского дев-сервера — 54 МБ/с. С июня 2025 года российские провайдеры обрывают соединения с частью зарубежных хостингов. Российский дев-сервер подходит, нужно только убрать ручную выкладку. Так же сделали в AGIMA, когда перестал работать сервис Google для раздачи сборок.
Это самый трудный шаг, и проходит он до того, как хаб может помочь. Внутренний инструмент Shopify свёл его к одному шагу, и после этого им стали пользоваться вдвое чаще: пришли дизайнеры и продакты. Поэтому страница ведёт человека по шагам, в том порядке, в каком он их увидит:
При отказе Android пишет одну фразу «Приложение не установлено», без причины. Хаб так не делает. Многие проблемы он видит ещё до скачивания: из списка сборок он знает нужную версию Android, подпись и размер. Качать 80 МБ, чтобы узнать, что сборка не встанет, не придётся.
| Что произошло | Что видит тестировщик | Кнопка |
|---|---|---|
| Сборка собрана на другом компьютере (другая подпись) | «Эта сборка подписана иначе, чем установленная. Чтобы поставить её, удалим текущую — вход и данные на этом телефоне пропадут» | Удалить и установить |
| Выбрана более старая сборка | «На телефоне более новая сборка. Чтобы вернуться к старой, удалим текущую — данные пропадут» | Удалить и установить |
| Мало места | «Не хватает места: нужно ещё 120 МБ» | Освободить место |
| Оборвалась связь | «Загрузка прервалась на 63 %. Продолжим с того же места» | Продолжить |
| Файл повредился | «Файл повредился при загрузке. Скачаем заново» | Скачать заново |
| Нажали «Отмена» в окне Android | Это не ошибка. Кнопка снова «Установить», подсказка «Установка отменена» | — |
| Samsung блокирует установку | «Samsung блокирует установку не из Google Play» и три шага с картинками | Открыть настройки |
| Телефон не подходит | «Эта сборка требует Android 12 или новее» | Сообщить разработчикам |
| Нет связи с сервером на мобильном интернете | «Сервер недоступен — возможно, ограничен мобильный интернет. Попробуйте через Wi-Fi» | Повторить |
| Непонятный сбой | «Не получилось установить. Попробуйте ещё раз» | Повторить и «Отправить отчёт» |
Вернуться к старой сборке будет можно двумя путями. Первый: удалить текущую, и тогда данные пропадут, о чём хаб предупредит заранее. Второй: разработчики пересоберут старую версию с новым номером, и она встанет как обычное обновление. Так откат устроен у всех. F-Droid убрал кнопку отката ещё в 2023 году, потому что Android больше этого не позволяет.
| Вариант | Что мешает |
|---|---|
| Firebase App Distribution (Google) | Нужен аккаунт Google. У российских команд сбои с 2022 года, в 2024-м — блокировка |
| RuStore, альфа- и бета-тесты | Нужны VK ID и вход в RuStore. Каждая сборка проходит модерацию |
| Google Play, внутреннее тестирование | У каждого нужен аккаунт Google, тестировщиков не больше 100 |
| Ссылки без входа (Diawi и подобные) | Только веб-страница, без уведомлений и истории. На дешёвых тарифах ссылка живёт от 1 до 15 дней |
| Obtainium, бесплатная «обновлялка» | Следит за нашими сборками без программирования. Но настраивается через шаблоны, показывает непонятные «версии» и молча пропускает сбои. Годится как временная мера для пары технических людей |
| Свой список для F-Droid | Работает только с растущими номерами. Тестировщику придётся поставить чужой «магазин» и добавить в него наш список |
| Zealot, свой сервер раздачи | Тяжёлый сервер, а приложения для Android у него нет |
Там, где есть приложение и уведомления, нужен вход. Там, где входа нет, есть только страница без уведомлений и истории. Хаб без входа закрывает как раз этот пробел. Российские команды, ушедшие с сервиса Google, пришли к тому же набору: список сборок с QR-кодами и история для повторных проверок.
На телефонах с сервисами Google будут ставиться только приложения зарегистрированных разработчиков. С 30 сентября 2026 года это действует в Бразилии, Индонезии, Сингапуре и Таиланде, с 2027 года — по всему миру. Про Россию источники расходятся: юристы пишут, что её не исключат, «Коммерсантъ» — что российские разработчики не смогут пройти проверку. Если правило дойдёт до нас, каждый тестировщик один раз пройдёт «расширенный путь»: включит режим разработчика, перезагрузит телефон и подождёт сутки. Установка по кабелю с компьютера под правило не попадает. Сейчас нужно одно: сохранить ключи подписи, потому что без ключа зарегистрироваться нельзя.
Уведомления через серверы Google в России ненадёжны, поэтому хаб проверяет список сборок сам. Но если хаб больше недели не открывать, Android проверяет его в фоне примерно раз в сутки. Поэтому сообщение в чате и запись в трекере остаются запасными каналами. Тем, кому важно узнавать сразу, хаб предложит выключить для него экономию батареи.
Когда в регионе мобильный интернет работает только по «белым спискам», наш сервер с мобильной сети недоступен. Хаб так и скажет: «Попробуйте через Wi-Fi». Большие файлы в фоне он по умолчанию качает только по Wi-Fi.
Любой, у кого есть хаб, видит все сборки, в том числе те, что работают с копией боевых данных. Подменить сборку при этом нельзя: хаб ставит только сборки из подписанного списка и только с нашей подписью. Файл хаба лучше не выкладывать в открытые места.
Хаб получает право обновлять без лишних вопросов, только когда сам поставит приложение. Поэтому каждый телефон один раз подтвердит обновление. Где стоят сборки с другой подписью, придётся один раз переустановить приложение, и данные пропадут. Лучше собрать всё в один день: включить общий ключ и растущие номера, перестать выкладывать файлы в чат и попросить всех один раз обновиться через хаб.
| Вопрос | Варианты | Рекомендую |
|---|---|---|
| Номер сборки | Растущий во всех ветках / оставить 1 | Растущий. Цена: вернуться к старой сборке можно только удалив текущую или пересобрав старую |
| Какой ключ сделать общим | Ключ Mac mini / новый ключ | Ключ Mac mini: его сборки уже стоят у большинства, переставлять почти ничего не придётся |
| Автообновление по умолчанию | «Спрашивать» / «Автоматически, когда приложение закрыто» | «Спрашивать». Автоматическое каждый включит у себя сам: обновление закрывает приложение посреди проверки |
| Вид хаба | Стандартные элементы Android / макет в Figma | Стандартные для первой версии: сразу крупные кнопки и тёмная тема. Дизайнеру хватит четырёх экранов, если нужен фирменный вид |
| Названия вариантов на рабочем столе | Одинаковые / «По душе · тест» и подобные | Решаете вы: это видимое изменение продукта. Похожий пример уже есть — «По любви · демо» |
| Файлы сборок в Telegram | Продолжать / только ссылки на хаб | Только ссылки: сборка из файла теряет обновления без вопросов |
Растущий номер в обоих приложениях, общий ключ, выкладка одной командой, список сборок и страница с QR-кодами. Это полезно сразу, ещё до хаба.
Карточки, установка и обновление, история, уведомления, понятные ошибки, самообновление и страница первой установки. Перед выпуском — проверка на живых Samsung, Xiaomi и в BlueStacks.
Отзывы со скриншотами в трекер, автообновление при закрытом приложении, лёгкие файлы под тип процессора, «Поделиться» и QR-код.
Три вопроса закроет только проверка на живых устройствах. Что именно Android сообщает, когда установку блокируют Samsung и Xiaomi? Работают ли обновления без вопросов в BlueStacks? Попадёт ли Россия под проверку разработчиков Google в 2027 году?
Этот раздел — для разработчиков. Владельцу читать его не обязательно: все выводы и решения изложены выше. Ссылки на AOSP ведут на зеркала LineageOS (
lineage-24.0— Android 17) иaosp-mirror, потому чтоandroid.googlesource.comво время исследования был недоступен.
Основной путь — только сессии PackageInstaller: интенты ACTION_INSTALL_PACKAGE и ACTION_UNINSTALL_PACKAGE помечены устаревшими с Android 10 (Intent.java). Хаб объявляет REQUEST_INSTALL_PACKAGES. У этого разрешения уровень signature|appop, пользователь выдаёт его как «Установка неизвестных приложений». Хаб проверяет canRequestPackageInstalls(), а при false открывает Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES с данными package:<hub> (PackageManager.java; Settings.java). Результат перепроверяется в onResume, а не берётся из результата активности. Id ожидающей сборки сохраняется до перехода в настройки: на Android 11 выдача разрешения убивает процесс (ReVanced Manager #2138).
Параметры сессии — как у F-Droid и Accrescent: MODE_FULL_INSTALL, setAppPackageName, setSize, setInstallReason(INSTALL_REASON_USER). С API 31 добавляется setRequireUserAction(USER_ACTION_NOT_REQUIRED), с API 33 — setPackageSource(PACKAGE_SOURCE_STORE). Файл пишется через openWrite и fsync, все потоки закрываются до commit() (F-Droid SessionInstallManager.kt; PackageInstaller.java). IntentSender для commit() берётся из изменяемого явного PendingIntent (setPackage(packageName), FLAG_MUTABLE). При target 35+ неизменяемый PendingIntent роняет commit() с IllegalArgumentException. При target 34+ изменяемый неявный падает уже при создании. Приёмник регистрируется с RECEIVER_NOT_EXPORTED (PackageInstallerSession.java; Android 14 behavior changes). На STATUS_PENDING_USER_ACTION хаб сразу запускает Intent.EXTRA_INTENT, если он на экране. Иначе он публикует уведомление с PendingIntent.getActivity. Трамплины через сервис или приёмник запрещены с target 31, а при target 37 IntentSender.sendIntent() требует явного разрешения на запуск активности из фона (Android 12 behavior changes; Android 17 behavior changes).
У сессий есть известные грабли, и зрелые магазины обходят их одинаково.
| Проблема | Обход |
|---|---|
| Окно подтверждения закрыли «Назад» или тапом мимо — колбэка нет (Android 10–13) | На onResume сравнить SessionInfo.progress и открыть окно заново, как F-Droid (AppInstallManager.kt) |
Повторный commit() ждущей сессии даёт второе окно и «Не удалось установить» |
Коммитить по одной сессии и не перекоммичивать (Aurora SessionInstaller.kt) |
Не больше 50 активных сессий на UID без INSTALL_PACKAGES; брошенные сессии закрываются через 3 дня |
При старте закрывать свои старые сессии (PackageInstallerService.java) |
Для одной сессии приходит STATUS_SUCCESS, а затем STATUS_FAILURE_ABORTED |
Обрабатывать итог сессии ровно один раз |
| MIUI/HyperOS с «оптимизацией» ломает сессии и сплиты | Детект ro.miui.ui.version.name и persist.sys.miui_optimization, затем устаревший путь ACTION_VIEW/ACTION_INSTALL_PACKAGE через FileProvider с EXTRA_RETURN_RESULT, как у Droid-ify (InstallerType.kt) |
INSTALL_FAILED_VERIFICATION_FAILURE, в том числе при Advanced Protection |
Тот же устаревший путь, как у F-Droid |
requestUserPreapproval (API 34+) на китайских прошивках отвечает ABORTED или молчит |
Спрашивать только на переднем плане; ABORTED быстрее 250 мс считать «не поддерживается» |
Окно подтверждения не появляется, только когда выполнены все условия сразу. Сессия создана с USER_ACTION_NOT_REQUIRED. Объявлено UPDATE_PACKAGES_WITHOUT_USER_ACTION (уровень normal). Хаб — installer of record (или update owner, если он назначен) либо обновляет сам себя. Разрешение «Установка неизвестных приложений» выдано. TargetSdk ставящегося APK не ниже порога. И тот же установщик не обновлял тихо тот же пакет в последние 30 секунд (PackageInstallerSession.java; SilentUpdatePolicy.java). Первая установка всегда требует пользователя: isInstallerOfRecord предполагает, что пакет уже стоит. Требования быть на переднем плане в коде нет, так что тихое обновление можно закоммитить из фоновой задачи. Installer of record переписывается при каждой установке через сессию (InstallPackageHelper.java). Поэтому первое обновление сборки, поставленной из Telegram, спросит подтверждение, а следующие — нет.
| Версия Android | Минимальный targetSdk ставящегося APK |
|---|---|
| 12, 12L | 29 |
| 13 | 30 |
| 14 | 31 |
| 15 | 33 |
| 16 | 33 по коду релизной ветки; 34 по Javadoc и в 16 QPR2 |
| 17 | 34 по коду AOSP и F-Droid; 35 по справке developer.android.com |
Для Android 16–17 источники по порогу расходятся (android16-release; lineage-24.0; SessionParams). Оба приложения собираются с targetSdk 35 и проходят при любом прочтении (build.gradle.kts «По любви»; build.gradle.kts «По душе»). Порог растёт каждый год. Поэтому targetSdk надо поднимать вовремя, а STATUS_PENDING_USER_ACTION обрабатывать всегда.
Режим «Автоматически, когда приложение закрыто» на API 34+ делается через commitSessionAfterInstallConstraintsAreMet(sessionId, receiver, InstallConstraints.GENTLE_UPDATE, timeout). Вызывать его может только installer of record. Таймаут — до недели, по истечении приходит STATUS_FAILURE_TIMEOUT (PackageInstaller.java). Update ownership (setRequestUpdateOwnership(true), API 34+) включается только при первой установке и требует ENFORCE_UPDATE_OWNERSHIP. Без этого разрешения сервис молча снимает флаг, хотя страница Android 14 называет INSTALL_PACKAGES (PackageInstallerService.java; Android 14 features). После этого установка из Telegram или браузера покажет напоминание, а согласие снимет владение. Включать ownership имеет смысл, когда файлы перестанут ходить по чатам. На уже стоящие приложения он не действует, пока их не переустановят через хаб.
Хаб обновляет сам себя по ветке isSelfUpdate — даже если его поставили из браузера или Telegram. Процесс при этом убивается. Поэтому итог сессии принимает приёмник из манифеста, а на ACTION_MY_PACKAGE_REPLACED хаб показывает «Хаб обновлён» (Intent.java). После любой фоновой установки хаб перечитывает lastUpdateTime и хеш и сообщает итог. Так Obtainium 1.6 закрыл свои «пропавшие» фоновые обновления (Obtainium v1.6.0). Как тихие обновления ведут себя на MIUI/HyperOS, One UI и в BlueStacks, на устройствах не проверено.
До скачивания хаб сверяет запись каталога с телефоном. Он проверяет имя пакета варианта и minSdk против Build.VERSION.SDK_INT (для «По душе» это 31, то есть Android 12). Проверяет, есть ли файл под первый подходящий ABI из Build.SUPPORTED_ABIS. Сравнивает SHA-256 сертификата из каталога с установленным через PackageManager.hasSigningCertificate(pkg, cert, CERT_INPUT_SHA256) и versionCode — с установленным. Свободного места должно быть не меньше 2–3 размеров файла, потому что файл и его копия в сессии живут одновременно (PackageManager.java). После скачивания хаб проверяет SHA-256 файла против каталога. Потом вызывает getPackageArchiveInfo(path, GET_SIGNING_CERTIFICATES); на API 28–29 нужен ещё GET_SIGNATURES, иначе signingInfo будет null (porter PR #112). Результат null значит, что файл битый. Затем снова сверяются имя пакета и сертификат. Файл с FLAG_TEST_ONLY отклоняется: такие APK делает кнопка Run в Android Studio, а assemble* из командной строки — нет (CommonsWare).
Правила понижения версии: меньший versionCode даёт INSTALL_FAILED_VERSION_DOWNGRADE. Равный versionCode допустим, если baseRevisionCode не ниже установленного (PackageManagerServiceUtils.java). setRequestDowngrade — SystemApi, а INSTALL_ALLOW_DOWNGRADE у обычного приложения снимается. Поэтому хаб откатывать не может. Разработчикам остаётся adb install -r -d, который код пропускает для debuggable-сборок. Данные, оставшиеся после удаления с DELETE_KEEP_DATA, тоже блокируют откат.
Публичных статусов установщика мало, и разные причины сливаются в один. Поэтому хаб разбирает токен INSTALL_FAILED_* из EXTRA_STATUS_MESSAGE (формат "<INSTALL_FAILED_…>: <message>") и читает EXTRA_OTHER_PACKAGE_NAME (PackageManager.java; PackageInstaller.java).
| Статус | Типичные INSTALL_FAILED_* |
Что это у нас | Реакция хаба |
|---|---|---|---|
STATUS_FAILURE_ABORTED (3) |
ABORTED, VERIFICATION_FAILURE, VERIFICATION_TIMEOUT |
Отмена в окне; отказ Play Защиты; с 16 QPR2/17 — проверка разработчика (EXTRA_DEVELOPER_VERIFICATION_FAILURE_REASON) |
Отмена — тихо вернуть «Установить»; иначе объяснение и отчёт |
STATUS_FAILURE_BLOCKED (2) |
PRE_APPROVAL_NOT_AVAILABLE; политика устройства; верификатор |
Возможно, Samsung Auto Blocker (не подтверждено) | Карточка «Автоблокировка» со шагами |
STATUS_FAILURE_CONFLICT (5) |
UPDATE_INCOMPATIBLE, CONFLICTING_PROVIDER, DUPLICATE_PERMISSION |
Другой ключ подписи; конфликт authority или разрешения между вариантами | «Подписана иначе» — удалить и поставить; конфликт вариантов — баг сборки |
STATUS_FAILURE_INVALID (4) |
INVALID_APK, PARSE_FAILED_*, VERSION_DOWNGRADE, TEST_ONLY |
Битый файл; откат; сборка из Run | Скачать заново; диалог отката; баг публикации |
STATUS_FAILURE_STORAGE (6) |
INSUFFICIENT_STORAGE |
Мало места | «Освободить место» |
STATUS_FAILURE_INCOMPATIBLE (7) |
OLDER_SDK, NO_MATCHING_ABIS, MISSING_SPLIT, USER_RESTRICTED |
«По душе» на Android ниже 12; не тот ABI | Объяснить ещё до скачивания |
STATUS_FAILURE (1) |
Всё остальное | — | «Повторить» и отчёт с сырым сообщением |
STATUS_FAILURE_TIMEOUT (8, API 34+) |
— | Приложение не закрыли за отведённое время | Тихо перепланировать |
Какие статусы на самом деле приходят при блокировке Samsung, MIUI и Play Защитой, не документировано. Это надо снять на устройствах и логировать.
С Android 11 хаб не видит даже приложения, которые поставил сам. Автоматическая видимость односторонняя: установленное приложение видит свой установщик, но не наоборот (AppsFilterUtils.java; Package visibility). Хаб не публикуется в Google Play, поэтому проще объявить QUERY_ALL_PACKAGES. У этого разрешения уровень normal, его объявляют F-Droid, Droid-ify, Aurora и Obtainium (core AndroidManifest.xml). Альтернатива — <queries> со всеми вариантами. Это com.polyubvi.app с суффиксами .dusha, .dushalove, .dev и com.podushe.app с суффиксами .local, .dusha, .dushalove, .dev (build.gradle.kts «По любви»; build.gradle.kts «По душе»). С <queries> каждый новый вариант требует обновления хаба.
Из PackageInfo хаб берёт longVersionCode, versionName, firstInstallTime и lastUpdateTime. Из getInstallSourceInfo() (API 30) он узнаёт installer of record, с API 34 — ещё и update owner. Подпись сверяется через hasSigningCertificate. Идентификатор сборки хаб читает из <meta-data> (флаг GET_META_DATA), который Gradle вписывает через manifestPlaceholders (InstallSourceInfo.java).
Старые сборки с номером 1 уже стоят на телефонах, и по номеру их не различить. Их хаб опознаёт по содержимому. Файл base.apk из applicationInfo.sourceDir доступен на чтение всем (режим 0644) (PackageManagerService.java). Можно посчитать SHA-256 файла или, как Firebase App Distribution, SHA-256 от CRC32 всех записей ZIP (ReleaseIdentifier.java). Результат сверяется с полем legacyHash каталога; на HyperOS и One UI это не проверено. Изменения, сделанные мимо хаба, он ловит двумя способами: перечитывает пакеты в каждом onResume и слушает PACKAGE_ADDED/PACKAGE_REPLACED динамическим приёмником, как Obtainium (MainActivity.kt).
Запуск идёт через getLaunchIntentForPackage. Удаление — через PackageInstaller.uninstall() с REQUEST_DELETE_PACKAGES, и система всегда спрашивает подтверждение через STATUS_PENDING_USER_ACTION (PackageInstallerService.java). Стереть чужие данные нельзя. Вариант clearApplicationUserData с именем пакета скрыт и требует CLEAR_APP_USER_DATA (ActivityManager.java). Поэтому хаб только открывает ACTION_APPLICATION_DETAILS_SETTINGS с package:.
На API 34+ загрузку по нажатию кнопки лучше запускать как задачу пользовательской передачи данных (UIDT). Для неё нужны JobInfo.Builder.setUserInitiated(true) и разрешение RUN_USER_INITIATED_JOBS. Планировать задачу можно только пока хаб на экране. Ещё нужны setRequiredNetwork, setEstimatedNetworkBytes и setNotification(…, JOB_END_NOTIFICATION_POLICY_DETACH). Такие задачи не подпадают под квоты корзин энергосбережения (UIDT). Google Maps, перейдя на них, получил на 10% более надёжные загрузки (Android Developers Blog). Ниже API 34 подходит срочная задача WorkManager с setForeground(ForegroundInfo(…, FOREGROUND_SERVICE_TYPE_DATA_SYNC)) и разрешением FOREGROUND_SERVICE_DATA_SYNC, как у Aurora (DownloadWorker.kt). При target 35 фоновый dataSync ограничен 6 часами в сутки (FGS timeout). В Android 16 квоты времени распространяются и на задачи WorkManager и DownloadManager (Android 16). Системный DownloadManager не годится. Он пишет только во внешнюю память, не проверяет целостность и удаляет невидимые загрузки через 7 дней (DownloadManager.java).
Сама загрузка идёт через OkHttp в filesDir/apks/<sha256>.part. Докачка — через Range: bytes=N- с If-Range (ETag). Её можно принимать только при ответе 206. Иначе полный ответ допишется к куску и испортит файл — код Aurora прямо предупреждает об этом. Кусок сохраняется, если загрузку остановила система, и удаляется только при отмене пользователем. Затем хаб проверяет SHA-256, переименовывает и разбирает файл, копирует его в сессию с setSize, удаляет после STATUS_SUCCESS и периодически чистит остатки. На время установки нужно около двух размеров файла. Если хаб на target 37 будет качать из локальной сети, например с Mac mini, понадобится ACCESS_LOCAL_NETWORK (Android 17). Публичный HTTPS-хост этого не требует.
Каталог хаб запрашивает условным GET (If-None-Match) при каждом открытии. Кроме того, его запрашивает периодическая задача WorkManager с ограничением NetworkType.CONNECTED: минимум раз в 15 минут, разумно раз в 1–6 часов (WorkManager). Почти всегда ответ — 304 размером несколько сотен байт. Как часто фоновая работа идёт на самом деле, решают корзины App Standby:
| Корзина | Обычные задачи | Срочные задачи | Сеть в фоне |
|---|---|---|---|
| Active | 20 мин за 60 мин (с Android 16) | 30 мин в сутки | Есть |
| Working set | 10 мин за 4 ч | 15 мин в сутки | Есть |
| Frequent | 10 мин за 12 ч | 10 мин в сутки | Есть |
| Rare | 10 мин в сутки | 10 мин в сутки | «Disabled» |
| Restricted | Раз в сутки до 10 мин | 5 мин в сутки | «Disabled» |
В Restricted приложение попадает после 8 дней без взаимодействия на Android 13+ (на Android 12 — после 45 дней). Нажатие на уведомление возвращает его в Active (App Standby Buckets; Power management limits). Хаб, который открывают только по сигналу из чата, почти всё время будет в Rare или Restricted. Поэтому стоит предложить пользователю ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS — вне Google Play это не нарушает правил. FCM не используется: доставка в России в 2026 году ненадёжна, а у части Huawei нет сервисов Google, так что хаб не должен от них зависеть (Хабр). POST_NOTIFICATIONS (Android 13+) запрашивается после первой удачной установки. Перед отправкой уведомления проверяется areNotificationsEnabled() (Notification permission).
У хаба три канала уведомлений. «Новые сборки» — с обычной важностью. «Загрузка и установка» — с низкой, для текущего прогресса. «Ошибки установки» — с обычной. На каждое приложение одно уведомление, и оно обновляется на месте с тем же id. Когда уведомлений несколько, показывается сводка группы (Notification groups). Поля каталога mandatory, minSupportedBuild и withdrawn хаб проверяет на каждом опросе.
Растущий номер уже есть в рабочих ветках «По душе», например в feature/ios-cmp. versionCode там равен числу минут от 2026-01-01T00:00:00Z до момента сборки. versionName строится как APP_VERSION-ГГММДД.ЧЧММ по московскому времени плюс хвост варианта. Управляют этим свойства -PBUILD_TIME (ISO-момент; тот же момент даёт тот же номер) и -PAPP_VERSION. Эту схему надо перенести в main и в android/ («По любви»), где сейчас versionCode = 1 и versionName = "1.0.0" (build.gradle.kts «По любви»).
Запас у схемы огромный. В конце сентября 2026 года номер около 386 000, а предел int — 2 147 483 647, потолок Google Play — 2 100 000 000 (Version your app). Схема yyMMddHHmm не годится: она переполнила int ещё в 2022 году. Число коммитов тоже не годится: сборки идут из разных веток, а на Mac mini нет .git. Две сборки в одну минуту получат равные номера, и это допустимо: сборка с равным номером ставится поверх. Часы машин сборки должны быть синхронизированы. Откат делается пересборкой старого коммита с новым BUILD_TIME; в каталоге такая сборка получает поле rebuildOf.
В BuildConfig полезно добавить BUILD_NUMBER, GIT_BRANCH и BUILD_TIME рядом с уже существующим GIT_SHA. Но только для сборок на раздачу: метка времени в каждой сборке ломает инкрементальную компиляцию. Идентификатор сборки стоит вписать в манифест: <meta-data android:name="…build_id" android:value="${buildId}"/>. Тогда хаб прочитает его, не запуская приложение. Оба приложения задают release = APPLICATION_ID@VERSION_NAME+VERSION_CODE и dist = GIT_SHA, поэтому в main все сборки попадают в GlitchTip одним релизом (Telemetry.kt «По душе»; Telemetry.kt «По любви»). После переноса номера релизы разделятся сами. Публикация будет создавать релиз GlitchTip с коммитом и писать строку sentryRelease в каталог (GlitchTip CLI).
Общим тестовым ключом становится ~/.android/debug.keystore с Mac mini. SHA-256 его сертификата начинается с A3:AA:35:8D, срок действия — до 2056 года. keytool -storepasswd и -keypasswd меняют только пароли, а не сертификат, так что уже стоящие сборки продолжат обновляться. В Gradle нужен отдельный signingConfigs.create("internal"). Он читает путь и пароли из ~/.gradle/gradle.properties или из переменных окружения каждой машины. В репозитории ключа быть не должно. Сам файл хранится в менеджере паролей или в зашифрованном виде (sops/age) (Sign your app). Сейчас «По душе» подписывает release всегда отладочным ключом. «По любви» подписывает его релизным, если задан RELEASE_STORE_FILE, иначе тоже отладочным. Ключи магазинных сборок com.podushe.app и com.polyubvi.app для Google Play и RuStore должны быть отдельными.
Сборки с ключом Windows можно было бы обновить без удаления через ротацию ключа по схеме v3 (apksigner rotate и --rotation-min-sdk-version 28). Но для горстки телефонов проще разовая переустановка (apksigner). SHA-256 сертификата каждого варианта закрепляется в каталоге и в хабе. У самого хаба — свой постоянный релизный ключ.
Варианты уже различаются applicationIdSuffix. Если два варианта объявят одинаковый authority у ContentProvider или одинаковое своё разрешение, установка второго упадёт с INSTALL_FAILED_CONFLICTING_PROVIDER или DUPLICATE_PERMISSION. Поэтому authority надо строить из ${applicationId} (PackageManager.java). Разные подписи вариантов на рабочем столе делаются через resValue("string", "app_name", …). Но это видимое изменение, и решает его владелец.
Формат каталога объединяет модели F-Droid index-v2 и Zealot (PackageV2.kt; Zealot schema.rb).
| Уровень | Поля | Зачем |
|---|---|---|
| Файл | schemaVersion, generatedAt, sequence |
Версия формата; растущий sequence не даёт подсунуть старый каталог |
| Приложение | id (polyubvi/podushe), name |
Две карточки |
| Вариант | packageName, label, environment, backend, signerSha256, coexists |
Строка на карточке, закреплённый ключ, список для <queries> |
| Сборка | buildId, versionCode, versionName, git.commit, git.branch, builtAt (UTC), builtOn |
Идентичность, сверка с установленным |
| Сборка | minSdk, targetSdk, abis, files[] {abi, url, size, sha256} |
Проверки до скачивания, выбор файла |
| Сборка | notes, whatToTest, tickets[] {key, title, url} |
«Что проверить», ссылки на задачи |
| Сборка | flags {latest, recommended, withdrawn, withdrawnReason, mandatory}, minSupportedBuild, rebuildOf, expiresAt |
Отзыв сборки, обязательное обновление, повтор старой сборки, срок хранения |
| Сборка | sentryRelease, legacyHash |
Связь с GlitchTip; опознание старых сборок с номером 1 |
Каталог подписывается отдельным файлом catalog.json.minisig (minisign, Ed25519). Открытый ключ зашит в хаб. В доверенный комментарий подписи пишется sequence, и хаб отвергает каталог, у которого sequence меньше последнего увиденного (minisign). Ed25519 в штатной криптографии Android есть только с API 33, а у «По любви» minSdk 28. Поэтому хабу нужен BouncyCastle или Tink (Tink; jsch PR #1160). HTTPS защищает канал, но не защищает от подмены файлов на самом сервере. От этого защищают подписанный каталог, SHA-256 файлов и закреплённый сертификат.
Файлы лежат по неизменяемым путям /<app>/<variant>/<buildId>/<app>-<variant>-<versionName>-<abi>.apk. Имя никогда не переиспользуется для других байтов. ETag и Range nginx отдаёт для статики по умолчанию. MIME-тип APK надо прописать: в стандартном mime.types его нет, а с application/octet-stream некоторые браузеры не предлагают установку (nginx; заметка команды). На дев-сервере /media/ отдаётся с max-age в 7 дней. Поэтому постоянное имя вроде «последняя сборка» там даст устаревший файл (заметка команды; MDN).
location /builds/ {
types { application/vnd.android.package-archive apk; application/json json; }
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Disposition "attachment";
sendfile on; tcp_nopush on; # Range и ETag включены по умолчанию; gzip для apk не нужен
}
location = /builds/catalog.json { add_header Cache-Control "no-cache"; }
Хранятся последние 10–20 сборок на вариант, плюс все с флагом recommended и упомянутые в открытых задачах. Остальное удаляется через 60–90 дней. В каталоге при этом остаётся запись с withdrawn или expiresAt, чтобы хаб предупредил тех, у кого такая сборка стоит. При файлах по 30–80 МБ это несколько гигабайт на приложение.
Публикация — один скрипт на машине, которая управляет сборками. Например, tools/macbuild можно расширить до tools/publish-build <app> <variant>. Скрипт работает на управляющем сервере, где есть .git и выход в Telegram.
| Шаг | Что делает | Чем |
|---|---|---|
| 1. Метаданные | Берёт BUILD_TIME (UTC), GIT_SHA, GIT_BRANCH на машине с .git |
date, git |
| 2. Сборка | tools/macsync и tools/macbuild :app:assemble<Variant> с -PBUILD_TIME -PAPP_VERSION -PGIT_SHA -PGIT_BRANCH |
Gradle на Mac mini |
| 3. Проверка | apksigner verify --print-certs против закреплённого SHA-256, sha256sum, aapt2 dump badging (versionCode, versionName, minSdk, ABI) |
build-tools (apksigner) |
| 4. Заметки | git log --no-merges --format='%s' <prev>..<commit>; ключи DUSHA-\d+ и LYUBVI-\d+ превращаются в заголовки задач из REST трекера; whatToTest разработчик может поправить |
git, трекер (fastlane) |
| 5. Выкладка | rsync на российский сервер по неизменяемому пути |
rsync |
| 6. Каталог | Запись во временный файл, затем rename; minisign -Sm с sequence в доверенном комментарии |
minisign |
| 7. Объявление | Сообщение бота: приложение, вариант, versionName, 3–5 строк заметок, URL-кнопки на /b/<buildId>, QR-код (qrencode); в задачи трекера — комментарий «доступно в сборке N» |
Bot API (Telegram), трекер |
| 8. GlitchTip | Создать релиз с коммитом | GlitchTip CLI |
Размер уменьшают ABI-сплиты: splits { abi { isEnable = true; reset(); include("arm64-v8a", "x86_64"); isUniversalApk = true } }. При раздаче мимо магазина все файлы одной сборки могут иметь один versionCode (Build multiple APKs). Хаб берёт первый элемент Build.SUPPORTED_ABIS, для которого есть файл, а если такого нет — универсальный (NDK ABIs). armeabi-v7a нужен, только если у кого-то 32-битный телефон. Порядок ABI в BlueStacks неизвестен, это надо проверить; универсальный файл страхует. useLegacyPackaging = true сжимает .so и уменьшает загрузку, но увеличивает место после установки (16 KB page sizes). Дельта-обновления не окупают сложность при 50 МБ/с с российского сервера.
Для ссылок нужны App Links на собственном HTTPS-домене сервера сборок, с assetlinks.json и autoVerify. Пути такие: /app/<app>, /app/<app>/<env> и /b/<buildId>. С Android 12 неподтверждённая веб-ссылка открывается в браузере. Свои схемы вида hub:// мессенджеры часто не делают кликабельными, и у таких ссылок нет запасного веб-пути (App Links; Android 12). Страницы рендерятся сервером с тегами Open Graph. Тогда Telegram показывает превью: приложение, номер, сервер, «Что проверить». Для внешней ссылки Telegram ищет не-браузерное приложение через queryIntentActivities и открывает его, если находит (Browser.java). Увидит ли он хаб при правилах видимости Android 11+, зависит от манифеста Telegram; это надо проверить на устройстве. На запасной странице нужна заметная кнопка «Открыть в приложении для тестировщиков» — ссылка intent:// с запасным URL. Проверка App Links требует сервисов Google. Поэтому в BlueStacks без них ссылки могут не открываться в хабе.
Отзывы хаб отправляет POST-запросом (multipart) на небольшой эндпоинт на российском сервере. Эндпоинт создаёт задачу в трекере: проект по приложению, метка from-hub, buildId в описании, вложения через уже работающий REST, как в trk_attach.py. Короткое сообщение он дублирует в чат. В отзыв уходят текст по шаблону и скриншоты, выбранные через Photo Picker без разрешений (Photo picker). К ним добавляются данные сборки (пакет, versionName, versionCode, build_id, lastUpdateTime) и данные телефона (производитель, модель, версия Android, ABI, язык, свободное место, тип сети). Последними идут имя тестировщика, buildId и sentryRelease. Без сети отзывы ждут в очереди и отправляются повторно. Скриншот чужого приложения хаб сделать не может: колбэк обнаружения скриншота в Android 14 не отдаёт картинку (Screenshot detection).
Obtainium как временная мера. Для каждого варианта нужен постоянный адрес «последняя сборка»: редирект 302 или symlink на свежий файл, с no-cache. Obtainium отслеживает такой адрес как Direct APK Link по ETag или по хешу первых ~1 КБ файла (direct_apk_link.dart; apps_provider.dart). Настройку раздаёт одна ссылка obtainium://apps/<json> (Obtainium deep links).
| Среда | Что происходит | Что делает хаб |
|---|---|---|
| Samsung, One UI 6.1.1+ | «Автоблокировка» включена по умолчанию (по документации Knox — с One UI 6.0) и пускает только Google Play и Galaxy Store; в One UI 8.5 тестируют авто-включение через 30 минут (Knox; Android Authority) | Карточка со шагами; статус при блокировке неизвестен — логировать |
| Xiaomi, MIUI/HyperOS | «Оптимизация MIUI» ломает сессии; на HyperOS установка «возвращает на главный экран», иногда кнопку нужно нажать дважды (Obtainium #1321; #3005) | Устаревший путь через системный установщик |
| OPPO/realme, ColorOS | «Обнаружен риск» и проверка паролем учётной записи (XDA) | Предупреждение в инструкции |
| Huawei/Honor | «Чистый режим» пускает только приложения, проверенные AppGallery (Huawei) | Инструкция по выключению |
| Android 16, Advanced Protection | «Установка неизвестных приложений» недоступна (Android Authority) | Объяснить, что хаб не работает, пока режим включён |
| Play Защита | «App scan recommended», «Send app for security check»; автоблокировка при RECEIVE_SMS, READ_SMS, слушателе уведомлений или спецвозможностях — у наших приложений их нет (Play Protect) |
Предупредить заранее; при ложной тревоге — форма апелляции |
| Restricted settings и ECM (Android 13, 15) | Ограничивают опасные разрешения у приложений, поставленных из файлов (Android Authority) | Наших разрешений не касается |
| BlueStacks 5 | Экземпляры Pie 64-bit (API 28), Android 11 (API 30), Android 13 beta (API 33); предустановленные профили сертифицированы, Play Защита активна (BlueStacks; BlueStacks) | «По душе» (minSdk 31) — только на Android 13; на Android 11 — перезапуск при выдаче разрешения |
Проверка разработчиков Google. С июня 2026 года на телефоны с Android 8+ через сервисы Google Play ставится компонент Android Developer Verifier. С 30.09.2026 правило действует в Бразилии, Индонезии, Сингапуре и Таиланде. С 2027 года оно станет глобальным для сертифицированных устройств с Android 7+ (developer verification; The Hacker News). В Android 16 QPR2 и 17 PackageInstaller сообщает причину отказа в EXTRA_DEVELOPER_VERIFICATION_FAILURE_REASON: 0 — неизвестно, 1 — нет сети, 2 — разработчик заблокирован. Установщики с target ≤ 36 сначала получают STATUS_PENDING_USER_ACTION. Установщики с target > 36 сразу получают STATUS_FAILURE_ABORTED, если пользователь не может обойти проверку (PackageInstaller.java).
Регистрация охватывает личность разработчика, имена пакетов и SHA-256 сертификатов. Полный аккаунт стоит $25 и требует документ и карту на имя разработчика; предоплаченные карты не принимаются. Аккаунт ограниченного распространения даёт до 20 устройств, которые подключаются через QR-код или ссылку (она живёт 7 дней). Он работает только с новыми именами пакетов и требует платёжный профиль Google (ADC Help; Limited distribution). Для тестовых вариантов это означало бы новые applicationId, например с суффиксом .test. Принимает ли Google отладочные сертификаты (CN=Android Debug), не документировано. Установка через ADB не проверяется. Registered App Stores в Android 17 рассчитаны на проверенные магазины и частному хабу не подходят (Android Authority).
До выпуска хаба на устройствах нужно проверить шесть вещей, которые источники не закрывают. Первая — какие статусы приходят, когда установку блокируют Samsung, MIUI и Play Защита. Вторая — работают ли тихие обновления и самообновление на HyperOS, One UI и в BlueStacks на Android 13. Третья — какой порядок SUPPORTED_ABIS в BlueStacks. Четвёртая — читается ли sourceDir чужого пакета на HyperOS и One UI. Пятая — открывает ли Telegram ссылки App Links в хабе. Шестая — доходит ли статус сессии до нового процесса после самообновления.