diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6c93c3c..b45973d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,47 @@ jobs: - name: Юнит-тесты run: ./gradlew testDebugUnitTest + # Заметки к релизу пишутся тем же PR, которым вносится изменение. Галочка в + # шаблоне PR это уже просит — и её ставили, не заполняя раздел: 0.2.0 и 0.2.1 + # вышли с заметками, собранными из git-лога. Проверяет машина. + changelog: + name: Заметки к релизу + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + + - name: Раздел «Не выпущено» заполнен + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + set -euo pipefail + + # Ровно те же два условия, при которых release-on-merge.yml не + # выпускает релиз: нечего выпускать — нечего и описывать. + if echo "$LABELS" | grep -qF 'без-релиза'; then + echo "Метка «без-релиза» — заметки не нужны" + exit 0 + fi + + FILES="$(gh pr view "$PR" --json files --jq '.files[].path')" + if [ -z "$(printf '%s\n' "$FILES" | grep -vE '(^docs/|\.md$)' || true)" ]; then + echo "PR тронул только документацию — заметки не нужны" + exit 0 + fi + + # Логика «что считается записью» одна на проверку и на выпуск — + # она живёт в скрипте, здесь только повод его дёрнуть. + if ! python3 scripts/bump-version.py --check-unreleased; then + echo "::error file=CHANGELOG.md::раздел «Не выпущено» пуст — допишите заметку к релизу" + exit 1 + fi + backend: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b0cb30d..ef18c914 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,6 +143,27 @@ jobs: "dist/neiro-$VERSION.apk" "dist/mapping-$VERSION.txt.gz" dist/SHA256SUMS.txt fi + # Телефоны узнают о релизе в ту же минуту: neiro-push рассылает + # data-push app_update, приложение проверяет GitHub и показывает + # уведомление. Без этого шага новость ждала бы суточной проверки. + # + # continue-on-error: недоступный Pi — не повод отмечать релиз упавшим. + # APK уже опубликован, и телефоны найдут его сами, просто позже. + - name: Сказать телефонам о релизе + continue-on-error: true + env: + BASE_URL: ${{ secrets.NEIRO_PUSH_API_BASE_URL }} + RELEASE_NOTIFY_KEY: ${{ secrets.NEIRO_PUSH_RELEASE_NOTIFY_KEY }} + run: | + if [ -z "$BASE_URL" ] || [ -z "$RELEASE_NOTIFY_KEY" ]; then + echo "::notice::секретов для neiro-push нет, пуш о релизе пропускаю" + exit 0 + fi + curl -fsS --max-time 60 -X POST "${BASE_URL%/}/v1/release/notify" \ + -H "Authorization: Bearer $RELEASE_NOTIFY_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"version_name\":\"$VERSION\"}" + # Ключи не должны пережить сборку даже на одноразовой машине. - name: Убрать секреты if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e8638b3..ecbb5cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,61 @@ ## [Не выпущено] -Пока пусто. Сюда дописывается изменение, заметное пользователю, — тем же PR, -которым оно вносится, а не по памяти в день выпуска. Заголовки внутри: -«Добавлено», «Изменено», «Исправлено», «Убрано». +### Добавлено + +- **Уведомление о новой версии приходит сразу** — раньше приложение узнавало о + вышедшем обновлении в течение суток: проверяло раз в день и при открытии. + Теперь сообщение о новой версии приходит в течение минуты после выпуска, + даже если приложение закрыто. Отключается там же, где и было, — «О + программе» → «Проверять автоматически». + +## [0.2.1] — 2026-08-20 + +### Исправлено + +- **Стоимость занятия за текущий месяц** — цена бралась из последнего закрытого + расчёта, а он закрывается в конце месяца. После повышения ставки приложение + считало заработок по старой цене до конца месяца. Теперь ставка берётся из + начисления за день, то есть видна сразу. +- **Склонения в подписях и в дате календаря** — «2 занятия», «5 занятий», + «21 августа» вместо форм, которые не сходились с числом. + +### Изменено + +- **Кнопка «Сегодня» вернулась к прежнему виду** — после перевода на стеклянный + вид она стала заметно больше и перетягивала внимание с календаря. +- **Стеклянный вид включён по умолчанию** — на чистой установке приложение + сразу выглядит так, как задумано. Выключенный вручную тумблер остаётся + выключенным. + +## [0.2.0] — 2026-08-20 + +### Добавлено + +- **Стеклянный вид** — диалоги стали полупрозрачными, календарь за ними + размывается. Тумблер в «Настройки → Внешний вид → Стеклянный вид»; выключите + его, и всё рисуется как раньше, непрозрачным. +- **Свайп уведомлений в обе стороны** — уведомление в ленте убирается движением + и влево, и вправо, а не только в одну сторону. + +### Изменено + +- **Новый вид календаря** — чёрный фон и новые плитки дня; шапка и нижняя + плашка дня стали прозрачными и больше не перекрывают список занятий. +- **Уведомления о занятиях по умолчанию** — на чистой установке напоминание + перед занятием и сводки на сегодня и на завтра выключены, а напоминание об + архиве включено. Уже настроенного вами это не касается. +- **Списки в диалогах не обрезаются** — длинный список прокручивается целиком, + а его края растворяются, показывая, что дальше есть ещё. + +### Исправлено + +- **Счётчик уведомлений в шапке** — двузначное число обрезалось кружком. +- **Строки «Налог за месяц» и «Стоимость одного занятия»** — они появлялись в + диалоге «Финансы» сами, даже если их не включали: стоило один раз тронуть + любой тумблер в настройках прибыли, и старое значение уезжало в память + навсегда. Обе строки служебные, для проверки расчётов, — теперь они + выключены. Включённые заново после обновления остаются включёнными. ## [0.1.7] — 2026-08-15 diff --git a/CLAUDE.md b/CLAUDE.md index 7c3d4368..a42e8c68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,26 @@ - `Refactor CalendarHeader for improved usability` - любое сообщение длиннее одной короткой фразы +## Тронул код — дописал CHANGELOG + +Любая правка, которую видно пользователю, идёт вместе с записью в разделе +«Не выпущено» файла `CHANGELOG.md` — **тем же изменением**, а не потом. + +Записи пишутся для того, кто пользуется приложением: «что изменилось у меня на +экране», а не «какой класс переехал». Заголовки внутри раздела — «Добавлено», +«Изменено», «Исправлено», «Убрано». + +Проверяется машиной: `python3 scripts/bump-version.py --check-unreleased`, и +тем же на каждом PR с кодом (job `changelog` в `ci.yml`). Пустой раздел роняет +CI. + +Почему: слияние в main выпускает релиз сразу, а `bump-version.py` молча +пропускает пустой раздел — версия уходит со списком коммитов вместо описания, +и дописать его уже некуда. Так вышли 0.2.0 и 0.2.1. + +Выпускать нечего (правка тестов, чистый рефакторинг, работа только с +документацией) — метка «без-релиза» на PR, тогда ни релиза, ни проверки. + ## Без Gradle-сборки Никогда не запускать `./gradlew` или любые Gradle-команды (`assembleDebug`, `build`, `test` и т.п.). diff --git a/README.md b/README.md index 956cbdcb..76774509 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,11 @@ публикует релиз с описанием изменений. Разряд версии задаётся меткой PR — [как это устроено](docs/updater/RELEASE.md#как-выпустить-версию). -Приложение раз в сутки спрашивает GitHub, не вышло ли что-то новее. Нашло — -показывает уведомление; по согласию скачивает APK, сверяет SHA256 **и** подпись -со своей, и ставит поверх. Настройки, календарь и архив остаются на месте. +О вышедшей версии приложение узнаёт сразу: сервер `neiro-push` рассылает push +в ту же минуту, когда релиз опубликован. Push не дошёл — приложение и само +спрашивает GitHub раз в сутки и при открытии. Нашло — показывает уведомление; +по согласию скачивает APK, сверяет SHA256 **и** подпись со своей, и ставит +поверх. Настройки, календарь и архив остаются на месте. На Android 12+ обновление приложения самого себя проходит без системного диалога — согласия, которое пользователь уже дал, достаточно. @@ -142,6 +144,7 @@ FCM-токены устройств. Сервер — Raspberry Pi владел | Архив | [docs/archive/](docs/archive/README.md) | Закрытые аудиты, старая переработка и оба погашенных поколения push-сервиса — история, не руководство | | Скриншоты | [docs/screenshots.md](docs/screenshots.md) | Что снимать, требования README и RuStore, скрипт разбора кадров | | Боковая панель | [docs/profile-drawer.md](docs/profile-drawer.md) | Drawer профиля, жесты, файлы в коде | +| Оформление | [docs/appearance.md](docs/appearance.md) | Тема, палитры, семантические цвета и стеклянный вид: что где живёт и почему | | Аудит | [docs/audit/METHODIKA.md](docs/audit/METHODIKA.md) | Как проводить аудит: границы, чек-листы по областям, формат пакета, история прошлых аудитов | | Последний аудит | [docs/archive/audit-14.08.26/](docs/archive/audit-14.08.26/REPORT.md) | Пакет от 14.08.2026: 18 находок, все закрыты; отчёт о прогоне, архитектура на дату | | Что осталось доделать | [docs/next/](docs/next/README.md) | Доводка после перехода на `neiro-push`: шесть этапов с планом работ | diff --git a/app/src/main/java/ru/greemlab/neiro/push/NeiroFirebaseMessagingService.kt b/app/src/main/java/ru/greemlab/neiro/push/NeiroFirebaseMessagingService.kt index b31c7d26..69e233a8 100644 --- a/app/src/main/java/ru/greemlab/neiro/push/NeiroFirebaseMessagingService.kt +++ b/app/src/main/java/ru/greemlab/neiro/push/NeiroFirebaseMessagingService.kt @@ -7,12 +7,17 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.coroutines.runBlocking import ru.greemlab.neiro.data.network.YClientsRepository +import ru.greemlab.neiro.update.UpdateCheckCoordinator /** * FCM: сервер шлёт события занятий (`session_events`) прямо в payload — правим * календарь и показываем уведомление без похода в YClients. Если пуш не влез в * лимит, сервер шлёт нудж (`sync_events`) — забираем через WorkManager, сеть в * FCM-сервисе не место (app.md §6.3). + * + * Третий вид — `app_update`: сервер получил от GitHub Actions новость о новом + * релизе и разослал её на телефоны, чтобы обновление не ждало суточной + * проверки. */ class NeiroFirebaseMessagingService : FirebaseMessagingService() { @@ -20,6 +25,12 @@ class NeiroFirebaseMessagingService : FirebaseMessagingService() { when (message.data["action"]) { "session_events" -> handleSessionEvents(message) "sync_events" -> PushEventsSyncCoordinator.enqueue(applicationContext) + // «Вышел релиз» — сеть отсюда не трогаем по тем же причинам, что и + // при нудже: проверку ставит WorkManager (app.md §6.3). + "app_update" -> UpdateCheckCoordinator.onUpdatePush( + applicationContext, + message.data["version_name"], + ) } } diff --git a/app/src/main/java/ru/greemlab/neiro/theme/GlassStyle.kt b/app/src/main/java/ru/greemlab/neiro/theme/GlassStyle.kt index 8cd6654b..b52e6101 100644 --- a/app/src/main/java/ru/greemlab/neiro/theme/GlassStyle.kt +++ b/app/src/main/java/ru/greemlab/neiro/theme/GlassStyle.kt @@ -32,9 +32,13 @@ import androidx.compose.ui.window.DialogWindowProvider /** * Стеклянный вид поверхностей — тумблер в «Настройки → Внешний вид». * - * Выключен по умолчанию: при `false` всё рисуется ровно как раньше, обычным - * [Surface] с непрозрачным цветом схемы. Настройка живёт в - * `AppearancePreferences`, сюда её кладёт `NeiroApp`. + * Включён по умолчанию (`AppearancePreferences.DEFAULT_GLASS`) — приложение с + * ним и задумано. При `false` всё рисуется ровно как раньше, обычным + * [Surface] с непрозрачным цветом схемы; настройку сюда кладёт `NeiroApp`. + * + * Значение по умолчанию самого `CompositionLocal` — `false`: превью и тесты, + * которые про стекло ничего не знают, должны получать простую поверхность, а + * не лезть за настройкой в `Context`. */ val LocalGlassEnabled = staticCompositionLocalOf { false } diff --git a/app/src/main/java/ru/greemlab/neiro/theme/SemanticColors.kt b/app/src/main/java/ru/greemlab/neiro/theme/SemanticColors.kt index c4388a8e..3c661f52 100644 --- a/app/src/main/java/ru/greemlab/neiro/theme/SemanticColors.kt +++ b/app/src/main/java/ru/greemlab/neiro/theme/SemanticColors.kt @@ -98,7 +98,7 @@ object NeiroSurfaceAlpha { const val PANEL = 0.41f /** Карточка внутри панели: плашка дня. */ - const val CARD = 0.26f + const val CARD = 0f /** Плитка внутри панели или карточки: счётчики, суммы. */ const val TILE = 0.40f diff --git a/app/src/main/java/ru/greemlab/neiro/theme/ThemeMode.kt b/app/src/main/java/ru/greemlab/neiro/theme/ThemeMode.kt index abbe2b7e..0440e47c 100644 --- a/app/src/main/java/ru/greemlab/neiro/theme/ThemeMode.kt +++ b/app/src/main/java/ru/greemlab/neiro/theme/ThemeMode.kt @@ -7,10 +7,14 @@ import ru.greemlab.neiro.data.THEME_SYSTEM /** * Что выбрано в настройках: светлая, тёмная или «как в системе». * - * Хранится строкой — значение [storageId] уезжает в DataStore, экспорт архива - * и синхронизацию, поэтому переименовывать его нельзя: старый бэкап должен - * читаться новой версией приложения. Внутри кода со строками работать не надо, - * есть [fromStorageId]. + * Хранится строкой — значение [storageId] лежит в DataStore приложения + * (`CalendarDataStore`, ключ `app_theme`), поэтому переименовывать его нельзя: + * записанное старой версией должна прочитать новая. Внутри кода со строками + * работать не надо, есть [fromStorageId]. + * + * Устройство тема не покидает: в файл экспорта архива она не входит, в + * синхронизацию с YClients тоже, а облачный бэкап Android выключен целиком + * (`backup_rules.xml`). * * Это «какой яркости» тема, а не «какого цвета» — цвет живёт отдельно, в * [NeiroPalette], и одна палитра всегда умеет обе яркости. diff --git a/app/src/main/java/ru/greemlab/neiro/ui/settings/AppearancePreferences.kt b/app/src/main/java/ru/greemlab/neiro/ui/settings/AppearancePreferences.kt index 3e945f83..fc35bd58 100644 --- a/app/src/main/java/ru/greemlab/neiro/ui/settings/AppearancePreferences.kt +++ b/app/src/main/java/ru/greemlab/neiro/ui/settings/AppearancePreferences.kt @@ -8,8 +8,9 @@ import kotlinx.coroutines.flow.asStateFlow /** * Оформление приложения поверх выбранной темы (светлая/тёмная/системная). * - * Живёт в отдельных prefs, а не в DataStore рядом с темой: тема уезжает в - * синхронизацию и экспорт, а вид поверхностей — местная настройка телефона. + * Живёт в отдельных prefs, а не в DataStore рядом с темой: тот держит данные + * календаря, и класть туда тумблер оформления значит будить перерисовку всего + * снимка состояния ради одного `Boolean`. * * Стеклянный вид включён по умолчанию: приложение с ним и задумано, и первый * запуск должен показать его сразу. Дефолт срабатывает только пока ключа в diff --git a/app/src/main/java/ru/greemlab/neiro/update/ReleaseVersion.kt b/app/src/main/java/ru/greemlab/neiro/update/ReleaseVersion.kt index de4f017c..62428abd 100644 --- a/app/src/main/java/ru/greemlab/neiro/update/ReleaseVersion.kt +++ b/app/src/main/java/ru/greemlab/neiro/update/ReleaseVersion.kt @@ -42,6 +42,14 @@ data class ReleaseVersion( ) } + /** + * Версия без `v` — в таком виде она приходит в пуше `app_update` и + * лежит в `version.properties`. Разбор общий с тегом: две разные + * реализации однажды разошлись бы на «0.1.100». + */ + fun parseName(name: String): ReleaseVersion? = + parseTag("v" + name.trim().removePrefix("v")) + /** null — тег не нашей схемы; такой релиз молча игнорируем. */ fun parseTag(tag: String): ReleaseVersion? { val m = TAG_REGEX.matchEntire(tag.trim()) ?: return null diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckCoordinator.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckCoordinator.kt index 3632750e..869388bc 100644 --- a/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckCoordinator.kt +++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckCoordinator.kt @@ -7,9 +7,13 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +import androidx.work.workDataOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -18,7 +22,7 @@ import ru.greemlab.neiro.BuildConfig import java.util.concurrent.TimeUnit /** - * Когда приложение спрашивает GitHub о новой версии. Два триггера, один вход. + * Когда приложение спрашивает GitHub о новой версии. Три триггера, один вход. * * 1. [UpdateCheckWorker] раз в сутки — `PeriodicWorkRequest`, а не * самопланирующийся `OneTimeWorkRequest`, как у `PushKeepAliveCoordinator`. @@ -30,6 +34,11 @@ import java.util.concurrent.TimeUnit * образцу `AutoSyncCoordinator`. Воркер может задержаться на день в Doze; * открытие приложения — самый естественный момент спросить. Суточный порог * считает сам [UpdateChecker], здесь порога нет. + * 3. Пуш `app_update` от neiro-push — [onUpdatePush]. Первые два триггера + * отвечают на вопрос «не вышло ли чего», третий приходит в тот момент, + * когда релиз действительно вышел: `release.yml` после публикации дёргает + * сервер, сервер рассылает пуш. Без него телефон в кармане узнавал бы о + * версии только на следующие сутки. * * Скачивание отсюда не запускается никогда — только по нажатию пользователя * (этап 6). Пятнадцать мегабайт по мобильному интернету без спроса не прощают. @@ -38,6 +47,9 @@ object UpdateCheckCoordinator { const val WORK_NAME = "update_check" + /** Разовая проверка по пушу — отдельно от суточной, чтобы не сбивать её расписание. */ + const val PUSH_WORK_NAME = "update_check_push" + private const val TAG = "UpdateCheck" private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -118,6 +130,46 @@ object UpdateCheckCoordinator { return status } + /** + * Пуш «вышел релиз». Вызывается из `NeiroFirebaseMessagingService`, то + * есть с фонового потока FCM и в любом состоянии приложения. + * + * В сеть отсюда не ходим: у обработчика пуша считанные секунды, а проверка + * тянет за собой GitHub, скачивание заметок и уведомление. Ставим разовую + * работу — тот же [UpdateCheckWorker], но с `force`, потому что суточный + * троттлинг здесь бессмысленен: релиз уже опубликован. + * + * @param versionName версия из пуша (`0.2.2`) или null, если сервер её не + * прислал. Нужна только чтобы не дёргать GitHub из-за новости о версии, + * которая на телефоне уже стоит — так бывает у того, кто обновился первым. + */ + fun onUpdatePush(context: Context, versionName: String?) { + val appContext = context.applicationContext + + val blocked = UpdateChannelGate.blockReason(appContext) + if (blocked != null) { + Log.i(TAG, "Пуш о релизе пришёл, но самообновление выключено: $blocked") + return + } + + // Выключенная автопроверка — это «не ходи в GitHub сам», и пуш её не + // отменяет: у пользователя остаётся кнопка на экране «О программе». + if (!UpdatePreferences.get(appContext).isAutoCheckEnabled) { + Log.i(TAG, "Пуш о релизе пришёл, но автопроверка выключена") + return + } + + // Версия не разобралась — проверяем: пуш пришёл, значит релиз был, а + // разошедшийся формат версии не повод пропустить обновление. + val pushed = versionName?.let { ReleaseVersion.parseName(it) } + if (pushed != null && !pushed.isNewerThan(BuildConfig.VERSION_CODE)) { + Log.i(TAG, "Пуш о версии ${pushed.versionName}: она не новее установленной") + return + } + + enqueuePushCheck(appContext) + } + /** Настройка «проверять автоматически» на экране «О программе» (этап 8). */ fun onAutoCheckToggled(context: Context, enabled: Boolean) { val appContext = context.applicationContext @@ -147,6 +199,29 @@ object UpdateCheckCoordinator { Log.i(TAG, "Убрал скачанный APK версии $pending — она уже не новее установленной") } + private fun enqueuePushCheck(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = OneTimeWorkRequestBuilder() + // Разовый запрос к GitHub и уведомление — квоты expedited на такое + // тратить незачем, но и ждать общего окна WorkManager не хочется. + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setInputData(workDataOf(UpdateCheckWorker.KEY_FORCE to true)) + .setConstraints(constraints) + .build() + + // REPLACE, а не APPEND: два пуша подряд (перевыпуск того же релиза) — + // одна новость, и ходить к GitHub дважды незачем. Батарею здесь, в + // отличие от суточной работы, не сторожим: проверка разовая. + WorkManager.getInstance(context.applicationContext).enqueueUniqueWork( + PUSH_WORK_NAME, + ExistingWorkPolicy.REPLACE, + request, + ) + } + private fun schedulePeriodic(context: Context) { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) @@ -173,6 +248,11 @@ object UpdateCheckCoordinator { } fun cancel(context: Context) { - WorkManager.getInstance(context.applicationContext).cancelUniqueWork(WORK_NAME) + WorkManager.getInstance(context.applicationContext).apply { + cancelUniqueWork(WORK_NAME) + // Снимаем и разовую: выключили автопроверку — значит и работа, + // поставленная пушем минуту назад, в GitHub уже не идёт. + cancelUniqueWork(PUSH_WORK_NAME) + } } } diff --git a/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckWorker.kt b/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckWorker.kt index 469bd24c..c90a1f5f 100644 --- a/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckWorker.kt +++ b/app/src/main/java/ru/greemlab/neiro/update/UpdateCheckWorker.kt @@ -10,9 +10,11 @@ import kotlinx.coroutines.CancellationException * Суточная проверка обновлений. Работа уникальная (`update_check`), в чужие * цепочки — push и уведомления о занятиях — не вмешивается. * + * Тем же воркером идёт и разовая проверка по пушу `app_update` — там работа + * ставится под своим именем (`update_check_push`) и с `force = true`. + * * Сам ничего не решает: спрашивает [UpdateCheckCoordinator.checkNow] и - * переводит ответ в `Result`. Показ уведомления о найденной версии появится на - * этапе 5. + * переводит ответ в `Result`. */ class UpdateCheckWorker( appContext: Context, @@ -20,7 +22,11 @@ class UpdateCheckWorker( ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { - val status = runCatching { UpdateCheckCoordinator.checkNow(applicationContext) } + // Суточная работа приходит без входных данных — для неё force = false, + // и суточный троттлинг остаётся на месте. true ставит только пуш о + // вышедшем релизе: ждать там нечего, версия уже опубликована. + val force = inputData.getBoolean(KEY_FORCE, false) + val status = runCatching { UpdateCheckCoordinator.checkNow(applicationContext, force) } // Отмену не глушим: иначе Result.retry() воскресил бы работу, // остановленную системой. .onFailure { if (it is CancellationException) throw it } @@ -39,7 +45,10 @@ class UpdateCheckWorker( } } - private companion object { - const val TAG = "UpdateCheckWorker" + companion object { + /** Ключ входных данных: игнорировать суточный троттлинг. */ + const val KEY_FORCE = "force" + + private const val TAG = "UpdateCheckWorker" } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae2b1ae1..2e8f6264 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -185,7 +185,7 @@ Последняя проверка: %1$s Ещё ни разу не проверяли Проверять автоматически - Раз в сутки, когда есть сеть + Сразу после выхода версии и раз в сутки Установлена последняя версия Доступна %1$s Что изменилось: diff --git a/app/src/test/java/ru/greemlab/neiro/update/ReleaseVersionTest.kt b/app/src/test/java/ru/greemlab/neiro/update/ReleaseVersionTest.kt index 24be08a1..7ae4d42e 100644 --- a/app/src/test/java/ru/greemlab/neiro/update/ReleaseVersionTest.kt +++ b/app/src/test/java/ru/greemlab/neiro/update/ReleaseVersionTest.kt @@ -36,6 +36,22 @@ class ReleaseVersionTest { assertEquals(100, ReleaseVersion.parseTag(" v0.1.0\n")?.versionCode) } + @Test + fun `версия из пуша идёт без префикса v`() { + // Сервер шлёт то, что лежит в version.properties: «0.2.2», не «v0.2.2». + assertEquals(202, ReleaseVersion.parseName("0.2.2")?.versionCode) + assertEquals(202, ReleaseVersion.parseName(" 0.2.2\n")?.versionCode) + // Лишний «v» не ломает разбор: цена ошибки — пропущенное обновление. + assertEquals(202, ReleaseVersion.parseName("v0.2.2")?.versionCode) + } + + @Test + fun `мусор в версии из пуша не разбирается`() { + assertNull(ReleaseVersion.parseName("0.2.2-rc1")) + assertNull(ReleaseVersion.parseName("latest")) + assertNull(ReleaseVersion.parseName("")) + } + @Test fun `тег без префикса v не наш`() { assertNull(ReleaseVersion.parseTag("0.2.0")) diff --git a/docs/appearance.md b/docs/appearance.md new file mode 100644 index 00000000..0807ca52 --- /dev/null +++ b/docs/appearance.md @@ -0,0 +1,128 @@ +# Оформление: тема, палитра, стекло + +Как приложение решает, каким цветом рисовать. Появилось в `0.2.0` вместе с +«новым видом»; до этого цвета частью лежали в схеме Material, частью были +вписаны прямо в места использования. + +Три независимые вещи, которые легко перепутать: + +| Что | Отвечает за | Где живёт | Кто выбирает | +|---|---|---|---| +| **Тема** (`ThemeMode`) | яркость: светлая, тёмная, как в системе | DataStore приложения (`app_theme`) | пользователь, «Настройки → Внешний вид» | +| **Палитра** (`NeiroPalette`) | какого цвета оформление | `LocalNeiroPalette` | пока никто: выбора в интерфейсе нет | +| **Стекло** (`GlassStyle`) | полупрозрачные поверхности | локальные prefs телефона | пользователь, «Настройки → Внешний вид» | + +Тема и палитра перпендикулярны: **каждая палитра обязана уметь обе яркости**. +Выбрать оформление и потерять тёмную тему пользователь не должен. + +## Тема + +`theme/ThemeMode.kt` — три значения. `storageId` уезжает в DataStore +(`CalendarDataStore`, ключ `app_theme`), поэтому переименованию не подлежит: +значение, записанное старой версией, должна читать новая. + +Внутри кода со строками работать не надо — есть `fromStorageId` и `isDark`; +неизвестное значение читается как `SYSTEM`. Актуальную яркость считает +`MainActivity`, оттуда она уходит в `NeiroTheme`. + +**Устройство тему не покидает.** В файл экспорта архива (`exportAllData`) +уходят только записи архива, уведомления архива и история ЗП; синхронизация +работает с расписанием YClients, а не с настройками; облачный бэкап Android +выключен целиком (`backup_rules.xml`). Переехали на новый телефон — тему надо +выбрать заново. + +## Палитра + +`theme/NeiroPalette.kt` — одно оформление целиком: Material-схема плюс +семантические цвета для обеих яркостей. + +Откуда берётся Material-схема, описывает `NeiroColorSource`: + +- `Fixed` — пара схем, нарисованная вручную (`NeiroLightColorScheme`, + `NeiroDarkColorScheme` в том же файле, сами цвета — в `Color.kt`); +- `Wallpaper` — Material You, цвета читаются у системы в момент отрисовки + (Android 12+), с `fallback` для всего остального. + +Реестр — `NeiroPalettes.all`: сейчас в нём «Neiro» (фирменная) и «Обои +системы». `NeiroPalettes.Default` — фирменная. + +**Выбора в интерфейсе пока нет.** `NeiroTheme` принимает палитру параметром со +значением по умолчанию, и `MainActivity` его не передаёт. База под экран +выбора готова: добавить палитру — значит описать цвета в `Color.kt`, собрать +`NeiroSemanticColors` для светлой и тёмной яркости и внести объект в +`NeiroPalettes.all`; экран настроек возьмёт список оттуда и про конкретные +палитры знать не будет. + +### Семантические цвета + +`theme/SemanticColors.kt` — весь цвет приложения, которого нет в +Material-схеме: статусы занятий, суммы, акценты уведомлений, линия текущего +времени, подпись выходного дня. + +Правило одно: **понадобился оттенок — его место здесь**, а не `Color(0xFF…)` +по месту использования. Цвет, вписанный в точку использования, не переключится +вместе с палитрой и не отреагирует на смену темы. К `0.2.0` таких мест в +интерфейсе не осталось. + +Пастель, читаемая на тёмном фоне, на светлом превращается в нечитаемый текст, +поэтому у каждого цвета есть пара (`*Light`/`*Dark` в `Color.kt`). Набор под +текущую яркость отдаёт палитра, раздаёт `NeiroTheme` — то есть цвета следуют +теме, выбранной **в приложении**, а не системной (чем грешил +`isSystemInDarkTheme()` в точках вызова до `0.2.0`). + +Семантика намеренно не подстраивается под обои даже в палитре «Обои системы»: +цвет статуса занятия несёт смысл, а не оформление. Иначе смена палитры молча +переназначала бы значения цветов в расписании. + +## Стеклянный вид + +`theme/GlassStyle.kt` — полупрозрачные панели с размытием за окном. + +- Тумблер: «Настройки → Внешний вид → Стеклянный вид». +- Хранение: `ui/settings/AppearancePreferences.kt` — отдельные prefs, не + DataStore рядом с темой. +- **Включён по умолчанию** (с `0.2.1`; в `0.2.0` был выключен). Дефолт + срабатывает, только пока ключа в prefs нет: выключил вручную — остаётся + выключенным. +- Выключенное стекло рисует ровно то же, что рисовалось до `0.2.0`: обычный + `Surface` непрозрачным цветом схемы. Это и есть запасной путь, если стекло + где-то подведёт. + +Настройку кладёт `MainActivity` в `LocalGlassEnabled`. Значение по умолчанию +самого `CompositionLocal` — `false`: превью и тесты, которые про стекло ничего +не знают, должны получать простую поверхность, а не лезть за настройкой в +`Context`. + +### Почему панели такие плотные + +Числа лежат в `GlassStyle` (`DARK_TOP_ALPHA` и соседние) и подобраны не «как +красивее». Сквозь честно прозрачную панель просвечивал пёстрый календарь, фон +под цифрами получался неоднородным, и суммы приходилось вычитывать. Читаемость +важнее прозрачности — при правке этих чисел проверять надо диалог статистики +над насыщенным месяцем, а не пустой экран. + +Отдельно: без размытия за окном (`NO_BLUR_EXTRA_ALPHA`) панель приходится +держать плотнее — на Android ниже 12 размывать нечем. + +### `LocalGlassPanelAbove` + +Экран под стеклянной панелью не исчезает — он просвечивает. Яркое пятно +(фирменная плашка, акцентная кнопка) в этот момент читается сквозь стекло +отдельным объектом и тянет взгляд с того, ради чего диалог открыли. Кто так +себя ведёт, на это время уходит в нейтральный тон, читая `LocalGlassPanelAbove`. + +Он намеренно **не** `static`: значение меняется на каждом открытии диалога, и +перерисовать нужно только тех, кто его читает, а не весь экран. + +## Куда смотреть в коде + +| Файл | Что там | +|---|---| +| `theme/ThemeMode.kt` | Яркость и её хранение | +| `theme/NeiroPalette.kt` | Палитры, реестр, источник Material-схемы | +| `theme/SemanticColors.kt` | Цвета вне Material-схемы | +| `theme/Color.kt` | Сами значения цветов, парами на светлую и тёмную | +| `theme/Theme.kt` | `NeiroTheme`: собирает схему, семантику и палитру | +| `theme/GlassStyle.kt` | Стекло: параметры, `GlassPanel`, границы и блики | +| `ui/settings/AppearancePreferences.kt` | Тумблер стекла и его хранение | +| `MainActivity.kt` | Точка, где тема и стекло попадают в дерево | diff --git a/docs/neiro-push/API.md b/docs/neiro-push/API.md index 9415355e..729f0c4c 100644 --- a/docs/neiro-push/API.md +++ b/docs/neiro-push/API.md @@ -30,6 +30,13 @@ NEIRO_PUSH_API_BASE_URL=https://push.neiro.greemlab.ru | `API_KEY` (он же ключ приложения, в APK) | `Authorization: Bearer ` | Только `POST /v1/auth/login` | | `device_token` (выдаётся при входе) | `Authorization: Bearer ` | Всё остальное, в пределах одного аккаунта | | `ADMIN_API_KEY` | `Authorization: Bearer ` | `/health`, `/v1/admin/*`, дашборд | +| `RELEASE_NOTIFY_KEY` | `Authorization: Bearer ` | Только `POST /v1/release/notify` | + +`RELEASE_NOTIFY_KEY` отдельный от админского намеренно: он лежит в GitHub +Secrets, то есть вне Pi. Админским ключом можно удалять устройства и ходить в +прокси YClients — класть его в чужую систему ради одного запроса незачем. +Пустой `RELEASE_NOTIFY_KEY` означает «эндпоинт выключен» (`503`), а не +«пускать любого». `device_token` — 32 случайных байта в base64url. На сервере хранится только `sha256`; восстановить его из БД нельзя, потерялся на телефоне — нужен новый @@ -154,6 +161,63 @@ FCM, когда Firebase его перевыпустил. Заменяет ча --- +## Релиз приложения + +### `POST /v1/release/notify` + +`Bearer `, тело `{"version_name": "0.2.2"}`. + +Дёргается шагом «Сказать телефонам о релизе» в `.github/workflows/release.yml` +сразу после публикации релиза на GitHub. Сервер рассылает всем живым +устройствам с непустым `fcm_token` data-push `app_update`, телефон по нему идёт +к GitHub за APK и заметками и показывает уведомление «вышла новая версия». + +Без этого эндпоинта приложение узнавало о релизе только на суточной проверке +(`UpdateCheckCoordinator`) — в худшем случае через сутки после публикации. + +```json +{ + "version_name": "0.2.2", + "devices": 3, + "sent": 2, + "failed": 1 +} +``` + +`version_name` — строго `X.Y.Z`, ровно то, что лежит в `version.properties` и в +теге без `v`; иначе `422`. Ссылка на APK, размер и заметки в пуш не кладутся: +источник правды о релизе — GitHub, а не сервер. + +| Код | Когда | +|---|---| +| `401` | нет ключа, ключ чужой (админский тоже не подходит) | +| `422` | `version_name` не вида `X.Y.Z` | +| `503` | `RELEASE_NOTIFY_KEY` не задан или FCM не настроен | + +Ошибка отправки на одно устройство не роняет рассылку на остальные — она +попадает в `failed`. Мёртвый токен FCM гасится (`fcm_token = ''`), сама строка +устройства остаётся: удаление означало бы выход из аккаунта из-за проблемы с +доставкой. + +--- + +## Пуши на устройства + +Не эндпоинты, но часть контракта: что телефон получает от FCM и как это +разбирает `NeiroFirebaseMessagingService`. + +| `action` | Payload | Что делает приложение | +|---|---|---| +| `session_events` | `events` (JSON), `last_event_id` | Правит календарь и показывает уведомление о занятии | +| `sync_events` | `last_event_id` | Нудж: события не влезли в 3 КБ, приложение забирает их через `GET /v1/events` | +| `app_update` | `version_name` | Форс-проверка GitHub и уведомление о новой версии | + +Все три — `data`-сообщения с `priority: HIGH`: без notification-части в Doze +сообщение иначе ждёт выхода из режима, а обе новости нужны тогда, когда они +случились. + +--- + ## Прокси YClients Общее для всех: @@ -236,7 +300,7 @@ FCM, когда Firebase его перевыпустил. Заменяет ча | `GET /v1/devices/{device_id}/events` | `GET /v1/events` | | `POST /v1/devices/{device_id}/events/ack` | `POST /v1/events/ack` | | `DELETE /v1/devices/{device_id}` | `POST /v1/auth/logout` | -| — | `GET /v1/session`, `POST /v1/devices/fcm`, шесть `/v1/yclients/*` | +| — | `GET /v1/session`, `POST /v1/devices/fcm`, шесть `/v1/yclients/*`, `POST /v1/release/notify` | Админские эндпоинты, `/health` и дашборд переносятся как есть, плюс две кнопки: отзыв устройства и сброс аккаунта. diff --git a/docs/neiro-push/DEPLOY.md b/docs/neiro-push/DEPLOY.md index 236d1df4..312efd8f 100644 --- a/docs/neiro-push/DEPLOY.md +++ b/docs/neiro-push/DEPLOY.md @@ -115,6 +115,7 @@ neiro-push/ ```bash API_KEY=… # ключ приложения: только POST /v1/auth/login ADMIN_API_KEY=… # ПАРОЛЬ ОТ ДАШБОРДА, плюс /health и /v1/admin/* +RELEASE_NOTIFY_KEY=… # GitHub Actions: только POST /v1/release/notify TOKEN_ENCRYPTION_KEY=… # Fernet: шифрование user_token в БД YCLIENTS_PARTNER_TOKEN=… # единственное место, где он теперь живёт YCLIENTS_COMPANY_ID=… # филиал по умолчанию при входе @@ -151,6 +152,40 @@ ssh roster-b3 'grep ^API_KEY= ~/neiro-push/.env' # в приложени ssh roster-b3 'grep ^ADMIN_API_KEY= ~/neiro-push/.env' # только админ: health, test-push ``` +### Пуш о новом релизе + +`POST /v1/release/notify` (см. [API.md](API.md#post-v1releasenotify)) закрыт +своим ключом, и пока его нет в `.env`, эндпоинт отвечает `503` — релиз просто +выходит молча, как раньше. + +Включается в двух местах, значение одно: + +```bash +KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))') + +# 1. Pi: строка в .env и перезапуск +ssh roster-b3 "printf 'RELEASE_NOTIFY_KEY=%s\n' '$KEY' >> ~/neiro-push/.env \ + && cd ~/neiro-push && docker compose up -d" + +# 2. GitHub: секрет, которым release.yml дёргает сервер +gh secret set NEIRO_PUSH_RELEASE_NOTIFY_KEY --body "$KEY" +``` + +Проверка без выпуска релиза — тем же запросом, что делает workflow. Версия +должна быть не новее установленной на телефонах, иначе придёт настоящее +уведомление: + +```bash +curl -sS -X POST https://push.neiro.greemlab.ru/v1/release/notify \ + -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ + -d '{"version_name":"0.0.1"}' +# {"version_name":"0.0.1","devices":1,"sent":1,"failed":0} +``` + +`devices` — сколько телефонов с живым токеном FCM знает сервер, `sent` — +скольким пуш ушёл. `sent: 0` при `devices > 0` значит мёртвые токены: они +гасятся, телефон пришлёт новый сам через `POST /v1/devices/fcm`. + ### Карта секретов Где что лежит и что делать, если потерялось. Правило простое: **боевое значение @@ -161,6 +196,7 @@ ssh roster-b3 'grep ^ADMIN_API_KEY= ~/neiro-push/.env' # только адми | `YCLIENTS_PARTNER_TOKEN` | `~/neiro-push/.env` на Pi | закомментирован в `local.properties`, снимки `env-*.txt` | взять новый в `developer.yclients.com`, положить в `.env`, перезапустить | | `API_KEY` (ключ приложения) | `~/neiro-push/.env` | `neiro-push/.env`, `local.properties` → `NEIRO_PUSH_API_KEY` | сгенерировать заново — но APK придётся пересобрать и раскатать | | `ADMIN_API_KEY` (дашборд) | `~/neiro-push/.env` | `neiro-push/.env` | сгенерировать заново; приложение его не знает и не заметит | +| `RELEASE_NOTIFY_KEY` | `~/neiro-push/.env` | GitHub Secrets → `NEIRO_PUSH_RELEASE_NOTIFY_KEY` | сгенерировать заново в обоих местах; до этого релиз выйдет молча | | `TOKEN_ENCRYPTION_KEY` | `~/neiro-push/.env` | снимки `env-*.txt` в `neiro-push/backups/` | **ничем**: `user_token_enc` не расшифровать, всем нужен повторный вход | | `user_token` YClients | БД сервера, шифрован Fernet | снимки БД | повторный вход логином и паролем | | `device_token` | только телефон; в БД лишь `sha256` | — | повторный вход | diff --git a/docs/next/TASKS.md b/docs/next/TASKS.md index 125dc1f4..89f73a14 100644 --- a/docs/next/TASKS.md +++ b/docs/next/TASKS.md @@ -37,7 +37,7 @@ | [3](#этап-3--скриншоты) | Скриншоты для README и RuStore | Владелец + агент | ⬜ | | [4](#этап-4--крайние-случаи-обновления) | Крайние случаи обновления | Владелец | ⬜ | | [5](#этап-5--убрать-мёртвые-сервисы) | Убрать `server/` и `neiro-push-events/` | Агент | ✅ 13.08.2026 | -| [6](#этап-6--сжать-mapping-в-релизе) | Сжать `mapping.txt` в релизе | Агент | ✅ 13.08.2026, 51,44 → 3,66 МиБ | +| [6](#этап-6--сжать-mappingtxt-в-релизе) | Сжать `mapping.txt` в релизе | Агент | ✅ 13.08.2026, 51,44 → 3,66 МиБ | **Ближайшее действие:** этап [3](#этап-3--скриншоты) — снять восемь кадров на тестовых данных; раскладывает и вставляет их агент. Дальше остаётся только diff --git a/docs/updater/ARCHITECTURE.md b/docs/updater/ARCHITECTURE.md index 17134186..ccae4945 100644 --- a/docs/updater/ARCHITECTURE.md +++ b/docs/updater/ARCHITECTURE.md @@ -25,7 +25,8 @@ │ Приложение │ │ │ │ UpdateCheckWorker ──► UpdateChecker ──► UpdateStatus.Available │ -│ (раз в сутки) │ │ │ +│ (сутки / старт / │ │ │ +│ пуш app_update) │ │ │ │ │ ▼ │ │ │ UpdateNotifier │ │ │ системное уведомление │ @@ -370,7 +371,7 @@ Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, "package:${context.packageNam ## Когда проверяем -Два триггера, оба через один координатор: +Три триггера, все через один координатор: ```kotlin object UpdateCheckCoordinator { @@ -393,6 +394,50 @@ object UpdateCheckCoordinator { по образцу `AutoSyncCoordinator`. Воркер может задержаться на день, если телефон лежал в Doze; открытие приложения — самый естественный момент спросить. +3. **Пуш `app_update`** — `UpdateCheckCoordinator.onUpdatePush`. Первые два + триггера отвечают на вопрос «не вышло ли чего», третий приходит в тот + момент, когда релиз действительно вышел. + +### Пуш о релизе + +``` +release.yml ──► POST /v1/release/notify ──► neiro-push ──► FCM data-push + (после {"version_name": рассылка action=app_update + публикации) "0.2.2"} по устройствам version_name=0.2.2 + │ + ▼ + NeiroFirebaseMessagingService + │ + UpdateCheckCoordinator.onUpdatePush + │ + OneTimeWorkRequest(force=true) + work name: update_check_push + │ + UpdateCheckWorker ──► обычная проверка +``` + +Почему не проще: + +- **Сеть в обработчике пуша не трогаем.** У `onMessageReceived` считанные + секунды, а проверка тянет за собой GitHub и уведомление. Работу ставит + WorkManager — тот же порядок, что у нуджа `sync_events`. +- **`force = true`.** Суточный троттлинг здесь бессмысленен: релиз уже + опубликован. Исчерпанный лимит GitHub при этом остаётся в силе — там ждать + всё равно нечего. +- **Работа отдельная (`update_check_push`), политика `REPLACE`.** Суточное + расписание не сбивается, а два пуша подряд (перевыпуск того же тега) — одна + новость, и ходить к GitHub дважды незачем. +- **Три причины промолчать до всякой сети:** сборка не может обновляться + (`UpdateChannelGate`), автопроверка выключена в настройках, версия из пуша + не новее установленной. Последнее — про того, кто обновился первым: пуш + придёт и ему тоже. +- **Заметки и ссылку на APK через пуш не передаём.** Источник правды о релизе + один — GitHub; данные в пуше нужны только чтобы решить, идти туда или нет. + +Ссылка на серверную часть: [API.md](../neiro-push/API.md#post-v1releasenotify), +ключ и его включение — [DEPLOY.md](../neiro-push/DEPLOY.md#пуш-о-новом-релизе). +Не настроен ключ или недоступен Pi — шаг в workflow не роняет релиз, телефоны +узнают о версии на суточной проверке, как раньше. Скачивание автоматически **не** запускается никогда — только по нажатию пользователя. Пятнадцать мегабайт по мобильному интернету без спроса — не то @@ -424,6 +469,7 @@ object UpdateCheckCoordinator { | `app/build.gradle.kts` | Чтение `version.properties`, `versionCode` по формуле, `buildConfigField` `UPDATE_ENABLED` и `UPDATE_REPO` | | `app/src/main/AndroidManifest.xml` | `REQUEST_INSTALL_PACKAGES`, `UPDATE_PACKAGES_WITHOUT_USER_ACTION`, регистрация `UpdateInstallReceiver` (`exported="false"`) | | `NeiroApplication.kt` | Одна строка: `UpdateCheckCoordinator.initialize(this)` в существующем `appScope.launch` | +| `push/NeiroFirebaseMessagingService.kt` | Ветка `"app_update"` в разборе `action`: вызов `UpdateCheckCoordinator.onUpdatePush` | | `ui/screens/CalendarScreen.kt` | `CalendarOverlay.About` рядом с прочими оверлеями и его ветка отрисовки — тем же способом, что `ProfitSettings` | | `ui/settings/AppSettingsScreen.kt` | Секция «О программе» с `SettingsNavigationRow`: версия в подзаголовке, точка при доступном обновлении | | `app/src/main/res/values/strings.xml` | Строки экрана и уведомления | diff --git a/docs/updater/README.md b/docs/updater/README.md index 9ebfe9a8..6f3d7f04 100644 --- a/docs/updater/README.md +++ b/docs/updater/README.md @@ -59,7 +59,7 @@ Mac, подключить кабель, `adb install`. Пока пользова | Версия | Начинаем заново с `0.1.0`. Источник — `version.properties`, тег обязан совпадать, CI проверяет ([RELEASE](RELEASE.md#версии)) | | `versionCode` | Считается из версии: `major * 10000 + minor * 100 + patch`. `0.1.0 → 100` | | Сборка релиза | GitHub Actions по тегу, ключи и `google-services.json` — в секретах репозитория ([RELEASE](RELEASE.md#секреты)) | -| Проверка обновлений | Раз в сутки, `PeriodicWorkRequest` + проверка при запуске, если давно не спрашивали | +| Проверка обновлений | Push `app_update` в момент выпуска + раз в сутки `PeriodicWorkRequest` + проверка при запуске, если давно не спрашивали | | Установка | `PackageInstaller` Session API. На Android 12+ — без системного диалога (самообновление), ниже и при отказе — обычное подтверждение | | Проверка целостности | SHA256 из релиза **и** сверка подписи APK с собственной — до установки, не после | | Новые зависимости | Не нужны: OkHttp, Retrofit, Gson, WorkManager уже в проекте | diff --git a/docs/updater/RELEASE.md b/docs/updater/RELEASE.md index 7a7f9d2b..905fc4c0 100644 --- a/docs/updater/RELEASE.md +++ b/docs/updater/RELEASE.md @@ -253,12 +253,36 @@ jobs: "dist/neiro-$VERSION.apk" "dist/mapping-$VERSION.txt.gz" dist/SHA256SUMS.txt fi + # Телефоны узнают о релизе в ту же минуту, а не на суточной проверке. + # Недоступный Pi релиз не роняет: APK уже опубликован. + - name: Сказать телефонам о релизе + continue-on-error: true + env: + BASE_URL: ${{ secrets.NEIRO_PUSH_API_BASE_URL }} + RELEASE_NOTIFY_KEY: ${{ secrets.NEIRO_PUSH_RELEASE_NOTIFY_KEY }} + run: | + if [ -z "$BASE_URL" ] || [ -z "$RELEASE_NOTIFY_KEY" ]; then + echo "::notice::секретов для neiro-push нет, пуш о релизе пропускаю" + exit 0 + fi + curl -fsS --max-time 60 -X POST "${BASE_URL%/}/v1/release/notify" \ + -H "Authorization: Bearer $RELEASE_NOTIFY_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"version_name\":\"$VERSION\"}" + # Ключи не должны пережить сборку даже на одноразовой машине. - name: Убрать секреты if: always() run: rm -f app/release.jks app/google-services.json local.properties ``` +Последний шаг — единственное место, где релиз разговаривает с чужой системой. +Что происходит дальше: сервер рассылает всем телефонам data-push `app_update`, +приложение проверяет GitHub и показывает уведомление о новой версии +([ARCHITECTURE.md § Пуш о релизе](ARCHITECTURE.md#пуш-о-релизе)). Секрета нет — +шаг пишет notice и выходит с нулём: обновление в этом случае доедет само, но +в пределах суток. + ## Секреты Заводятся один раз, в `Settings → Secrets and variables → Actions` или @@ -276,6 +300,9 @@ gh secret set RELEASE_KEY_ALIAS -R Greem4/neiro gh secret set RELEASE_KEY_PASSWORD -R Greem4/neiro gh secret set NEIRO_PUSH_API_BASE_URL -R Greem4/neiro gh secret set NEIRO_PUSH_API_KEY -R Greem4/neiro + +# ключ пуша о релизе — то же значение, что в RELEASE_NOTIFY_KEY на Pi +gh secret set NEIRO_PUSH_RELEASE_NOTIFY_KEY -R Greem4/neiro ``` | Секрет | Откуда взять | @@ -284,6 +311,7 @@ gh secret set NEIRO_PUSH_API_KEY -R Greem4/neiro | `RELEASE_STORE_PASSWORD`, `RELEASE_KEY_ALIAS`, `RELEASE_KEY_PASSWORD` | `local.properties` | | `GOOGLE_SERVICES_JSON_BASE64` | `app/google-services.json` | | `NEIRO_PUSH_API_BASE_URL`, `NEIRO_PUSH_API_KEY` | `local.properties`, ключ также `ssh roster-b3 'grep ^API_KEY= ~/neiro-push/.env'` | +| `NEIRO_PUSH_RELEASE_NOTIFY_KEY` | `ssh roster-b3 'grep ^RELEASE_NOTIFY_KEY= ~/neiro-push/.env'`; включение — [DEPLOY.md § Пуш о новом релизе](../neiro-push/DEPLOY.md#пуш-о-новом-релизе) | **Keystore незаменим.** Потеряется файл или пароль — обновить установленное приложение станет нечем: подпись не совпадёт, и всем придётся удалять Neiro и diff --git a/docs/updater/TASKS.md b/docs/updater/TASKS.md index 30078af2..560c0074 100644 --- a/docs/updater/TASKS.md +++ b/docs/updater/TASKS.md @@ -70,6 +70,7 @@ | [8](#этап-8--экран-о-программе) | Экран «О программе» — **та самая кнопка** | ✅ готово | | [9](#этап-9--после-установки) | Отметка после установки | ✅ готово | | [О8](#этап-о8--заметки-релиза-из-changelog) | Заметки релиза из `CHANGELOG` | ✅ готово — описания `0.1.3`–`0.1.5` пришли из файла | +| [12](#этап-12--push-о-вышедшем-релизе) | Push о вышедшем релизе | ✅ готово 21.08.2026 — эндпоинт на Pi, шаг в `release.yml`, ветка в FCM-сервисе | | [10](#этап-10--скриншоты) | Скриншоты | 🟡 инструмент и требования готовы, кадров нет | | [11](#этап-11--проверка-на-устройстве) | Проверка на устройстве | 🟡 главный путь пройден, крайние случаи — нет | @@ -656,6 +657,47 @@ release-сборке в логах (`adb logcat -s UpdateCheck`) при перв --- +## Этап 12 — push о вышедшем релизе + +Сделано 21.08.2026. До него телефон узнавал о версии в пределах суток: этапа 4 +(раз в сутки) и проверки при открытии для этого хватало, но обновление, +выпущенное утром, доезжало вечером. + +- [x] `POST /v1/release/notify` в `neiro-push` под своим ключом + (`RELEASE_NOTIFY_KEY`), рассылка `app_update` всем живым устройствам +- [x] Шаг «Сказать телефонам о релизе» в `release.yml`, `continue-on-error` +- [x] Ветка `"app_update"` в `NeiroFirebaseMessagingService` +- [x] `UpdateCheckCoordinator.onUpdatePush` — разовая работа + `update_check_push` с `force = true` +- [x] `ReleaseVersion.parseName` — версия из пуша идёт без префикса `v` +- [x] Тесты: шесть на эндпоинт (`neiro-push/tests/test_main.py`), два на + разбор версии (`ReleaseVersionTest`) + +**Ключ отдельный от админского.** Он уезжает в GitHub Secrets, то есть вне Pi, +а админским можно удалять устройства и ходить в прокси YClients. Пустой +`RELEASE_NOTIFY_KEY` означает «эндпоинт выключен» (`503`), а не «пускать +любого». + +**Релиз от этого шага не зависит.** Недоступный Pi, забытый секрет, упавший +FCM — шаг пишет notice и выходит с нулём: APK уже опубликован, и телефоны +найдут его суточной проверкой, как раньше. + +Устройство отвечает на пуш не всегда, и это задумано: сборка из магазина или +debug (`UpdateChannelGate`), выключенная в настройках автопроверка, версия из +пуша не новее установленной — три причины промолчать до всякой сети. + +Разбор — [ARCHITECTURE.md § Пуш о релизе](ARCHITECTURE.md#пуш-о-релизе), +серверная часть — [API.md](../neiro-push/API.md#post-v1releasenotify), +включение ключа — [DEPLOY.md](../neiro-push/DEPLOY.md#пуш-о-новом-релизе). + +**Готово, когда:** `POST /v1/release/notify` с боевым ключом отвечает +`{"devices": N, "sent": N}`, без ключа — `401`, с версией не вида `X.Y.Z` — +`422`. Проверено на живом сервере 21.08.2026. + +**Коммит:** `Добавил пуш о вышедшем релизе` + +--- + ## Открытые вопросы Решить до того, как они станут срочными: diff --git a/neiro-push/README.md b/neiro-push/README.md index 66bf1445..99283a83 100644 --- a/neiro-push/README.md +++ b/neiro-push/README.md @@ -72,6 +72,7 @@ cd neiro-push && PYTHONPATH=. python -m pytest tests -q | GET | `/v1/yclients/salary/daily` | `device_token` | Начисления по дням | | GET | `/v1/yclients/salary/calculations` | `device_token` | Расчёты за период | | GET | `/v1/yclients/salary/calculations/{id}` | `device_token` | Детализация расчёта | +| POST | `/v1/release/notify` | `RELEASE_NOTIFY_KEY` | Рассылает пуш «вышла новая версия приложения»; дёргается из release.yml | | POST | `/v1/admin/devices/{id}/revoke` | `ADMIN_API_KEY` | Отзыв `device_token` устройства; строка остаётся в списке | | DELETE | `/v1/admin/devices/{id}` | `ADMIN_API_KEY` | Удаление устройства совсем; телефону придётся войти заново | | POST | `/v1/admin/accounts/{id}/reset` | `ADMIN_API_KEY` | Потребовать повторный вход паролем | diff --git a/neiro-push/app/config.py b/neiro-push/app/config.py index 67816baa..55b1e42c 100644 --- a/neiro-push/app/config.py +++ b/neiro-push/app/config.py @@ -8,6 +8,11 @@ class Settings(BaseSettings): api_key: str admin_api_key: str + # Ключ только для POST /v1/release/notify — он уезжает в GitHub Secrets, и + # админский ключ туда класть незачем: тем можно удалять устройства и ходить + # в прокси, а этому нужно ровно одно — сказать «вышел релиз». + # Пустой ключ означает «эндпоинт выключен», а не «пускать всех». + release_notify_key: str = "" database_path: str = "/data/neiro_push.db" poll_interval_seconds: int = 10 poll_night_interval_seconds: int = 3600 diff --git a/neiro-push/app/database.py b/neiro-push/app/database.py index 2a2729bd..9d6ac0f9 100644 --- a/neiro-push/app/database.py +++ b/neiro-push/app/database.py @@ -555,6 +555,25 @@ def list_devices_for_account(self, account_id: int) -> list[RegisteredDevice]: ).fetchall() return [_row_to_device(row) for row in rows] + def list_devices_with_fcm(self) -> list[RegisteredDevice]: + """Все живые телефоны с рабочим токеном — для рассылки на весь сервис. + + Отличие от `list_devices_for_account`: аккаунт не важен. Новость «вышла + новая версия приложения» одна на всех, а не своя у каждого сотрудника. + Устройства без `fcm_token` отсеиваются здесь же — слать им некуда. + """ + with self.connect() as conn: + rows = conn.execute( + """ + SELECT id, account_id, device_id, fcm_token, label, app_version, + last_ack_event_id + FROM devices + WHERE revoked_at IS NULL AND fcm_token != '' + ORDER BY id + """ + ).fetchall() + return [_row_to_device(row) for row in rows] + def update_account_poll_state( self, account_id: int, diff --git a/neiro-push/app/fcm.py b/neiro-push/app/fcm.py index 383c54e4..7729d0e8 100644 --- a/neiro-push/app/fcm.py +++ b/neiro-push/app/fcm.py @@ -68,6 +68,34 @@ async def send_events_push( } ) + result = await self._send(token=token, data=data) + if result.token_invalid: + return result + return FcmSendResult(nudged=nudged) + + async def send_app_update_push(self, *, token: str, version_name: str) -> FcmSendResult: + """Шлёт `app_update` — «на GitHub вышла версия version_name». + + Сама версия в payload нужна только чтобы телефон не ходил в сеть из-за + пуша о версии, которая у него уже стоит. Ссылку на APK и заметки он + берёт у GitHub сам: тащить их через пуш значило бы завести второй + источник правды о релизе. + """ + if not self.is_configured: + raise RuntimeError("FCM is not configured on the server") + + return await self._send( + token=token, + data={"action": "app_update", "version_name": version_name}, + ) + + async def _send(self, *, token: str, data: dict[str, str]) -> FcmSendResult: + """Отправка одного data-сообщения. Разбор payload — на вызывающем. + + priority HIGH: сообщение без notification-части в Doze иначе ждёт + выхода из режима, а обе новости — и событие занятия, и новый релиз — + нужны тогда, когда они случились. + """ access_token = self._access_token() url = f"https://fcm.googleapis.com/v1/projects/{self._project_id}/messages:send" body = { @@ -92,7 +120,7 @@ async def send_events_push( raise RuntimeError( f"FCM error {response.status_code}: {response.text[:300]}" ) - return FcmSendResult(nudged=nudged) + return FcmSendResult() def _is_invalid_token_error(self, response: httpx.Response) -> bool: """Токен мёртв — устройство можно удалять. diff --git a/neiro-push/app/main.py b/neiro-push/app/main.py index ecadfd86..06e82a47 100644 --- a/neiro-push/app/main.py +++ b/neiro-push/app/main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging from contextlib import asynccontextmanager from datetime import datetime, timezone @@ -27,7 +28,7 @@ from app.fcm import FcmSender from app.poller import MOSCOW, PollService from app.ratelimit import RateLimiter -from app.schemas import HealthResponse +from app.schemas import HealthResponse, ReleaseNotifyRequest, ReleaseNotifyResponse from app.security import SecretBox, constant_time_equals from app.yclients import YClientsClient @@ -73,6 +74,7 @@ async def lifespan(app: FastAPI): app.state.yclients = yclients_client app.state.limiter = RateLimiter() app.state.poll_service = poll_service + app.state.fcm = fcm_sender app.state.started_at = datetime.now(timezone.utc) poll_service.start() @@ -102,6 +104,10 @@ def get_poll_service(request: Request) -> PollService: return request.app.state.poll_service +def get_fcm(request: Request) -> FcmSender: + return request.app.state.fcm + + def verify_admin_api_key( authorization: str | None = Header(default=None), settings: Settings = Depends(get_settings), @@ -115,6 +121,29 @@ def verify_admin_api_key( raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin api key") +def verify_release_notify_key( + authorization: str | None = Header(default=None), + settings: Settings = Depends(get_settings), +) -> None: + """Ключ GitHub Actions. Отдельный от админского — см. `Settings`.""" + if not settings.release_notify_key: + # Ключа нет — эндпоинт закрыт совсем. Иначе пустая настройка означала + # бы «пускать любого, кто пришёл с пустым Bearer». + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="release notify key is not configured", + ) + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="missing bearer token" + ) + token = authorization.removeprefix("Bearer ").strip() + if not constant_time_equals(token, settings.release_notify_key): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid release notify key" + ) + + @app.get( "/health", response_model=HealthResponse, @@ -213,6 +242,82 @@ async def admin_reset_account( return {"account_id": account_id, "reauth_required": True} +@app.post( + "/v1/release/notify", + response_model=ReleaseNotifyResponse, + dependencies=[Depends(verify_release_notify_key)], +) +async def release_notify( + payload: ReleaseNotifyRequest, + db: Database = Depends(get_database), + fcm: FcmSender = Depends(get_fcm), +) -> ReleaseNotifyResponse: + """«На GitHub вышел релиз» — дёргается шагом в release.yml. + + Раньше телефон узнавал о новой версии в течение суток: раз в сутки воркер + плюс проверка при открытии приложения. Здесь новость приходит сразу — + сервер рассылает `app_update` всем живым устройствам, а телефон уже сам + идёт к GitHub за APK и заметками. + + Ошибка отправки на одно устройство не роняет рассылку на остальные: + релизу от неё ни холодно ни жарко, а в ответе видно, сколько не дошло. + """ + if not fcm.is_configured: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="FCM is not configured", + ) + + devices = db.list_devices_with_fcm() + if not devices: + logger.info("release %s: некому слать, устройств с токеном нет", payload.version_name) + return ReleaseNotifyResponse( + version_name=payload.version_name, devices=0, sent=0, failed=0 + ) + + results = await asyncio.gather( + *( + _push_app_update(fcm, db, device.device_id, device.fcm_token, payload.version_name) + for device in devices + ) + ) + sent = sum(1 for ok in results if ok) + logger.info( + "release %s: пуш ушёл на %s из %s устройств", + payload.version_name, + sent, + len(devices), + ) + return ReleaseNotifyResponse( + version_name=payload.version_name, + devices=len(devices), + sent=sent, + failed=len(devices) - sent, + ) + + +async def _push_app_update( + fcm: FcmSender, + db: Database, + device_id: str, + fcm_token: str, + version_name: str, +) -> bool: + try: + result = await fcm.send_app_update_push(token=fcm_token, version_name=version_name) + except Exception as exc: # noqa: BLE001 — сбой одного телефона не общий сбой + logger.warning("release push failed device=%s: %s", device_id, str(exc)[:300]) + return False + + if result.token_invalid: + # Тот же порядок, что у поллера: гасим только токен пуша, строку + # устройства не трогаем — иначе телефон выкинуло бы из аккаунта. + db.clear_device_fcm(device_id) + logger.warning("device %s: FCM token invalid, cleared", device_id) + return False + return True + + @app.get( "/v1/admin/dashboard.txt", response_class=PlainTextResponse, diff --git a/neiro-push/app/schemas.py b/neiro-push/app/schemas.py index 7ea895ed..bb6642a5 100644 --- a/neiro-push/app/schemas.py +++ b/neiro-push/app/schemas.py @@ -62,6 +62,20 @@ class LoginResponse(BaseModel): last_event_id: int +class ReleaseNotifyRequest(BaseModel): + # Строго `X.Y.Z` — ровно то, что лежит в version.properties и в теге без + # `v`. Телефон разбирает её тем же регулярным выражением (ReleaseVersion), + # и мусор здесь означал бы бессмысленный пуш на все устройства. + version_name: str = Field(pattern=r"^\d+\.\d+\.\d+$", max_length=40) + + +class ReleaseNotifyResponse(BaseModel): + version_name: str + devices: int + sent: int + failed: int + + class SessionResponse(BaseModel): account: AccountPayload reauth_required: bool diff --git a/neiro-push/tests/test_main.py b/neiro-push/tests/test_main.py index b28c13b1..00f3b902 100644 --- a/neiro-push/tests/test_main.py +++ b/neiro-push/tests/test_main.py @@ -29,6 +29,10 @@ def _event(record_id: int, date: str = TODAY) -> DerivedEvent: def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("API_KEY", "test-api-key") monkeypatch.setenv("ADMIN_API_KEY", "test-admin-key") + # Явно пустой: у разработчика рядом лежит neiro-push/.env с боевым + # значением, и Settings подхватил бы его — тест «без ключа эндпоинт + # закрыт» проходил бы в CI и падал локально. + monkeypatch.setenv("RELEASE_NOTIFY_KEY", "") monkeypatch.setenv("TOKEN_ENCRYPTION_KEY", "test-token-key") monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "events.db")) monkeypatch.setenv("FCM_CREDENTIALS_PATH", str(tmp_path / "missing-fcm.json")) @@ -447,3 +451,162 @@ def test_health_requires_admin_key(client: TestClient) -> None: body = response.json() assert body["accounts"] == 0 assert body["devices"] == 0 + + +class _FakeFcm: + """Подмена FcmSender: настоящий уходит в Google, а нам нужен список вызовов.""" + + def __init__(self, *, invalid_tokens: set[str] | None = None, fail: bool = False) -> None: + self.sent: list[tuple[str, str]] = [] + self.is_configured = True + self._invalid = invalid_tokens or set() + self._fail = fail + + async def send_app_update_push(self, *, token: str, version_name: str): + from app.fcm import FcmSendResult + + self.sent.append((token, version_name)) + if self._fail: + raise RuntimeError("FCM error 500") + return FcmSendResult(token_invalid=token in self._invalid) + + +@pytest.fixture +def release_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Тот же клиент, но с настроенным ключом релиза.""" + monkeypatch.setenv("API_KEY", "test-api-key") + monkeypatch.setenv("ADMIN_API_KEY", "test-admin-key") + monkeypatch.setenv("RELEASE_NOTIFY_KEY", "test-release-key") + monkeypatch.setenv("TOKEN_ENCRYPTION_KEY", "test-token-key") + monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "events.db")) + monkeypatch.setenv("FCM_CREDENTIALS_PATH", str(tmp_path / "missing-fcm.json")) + get_settings.cache_clear() + with TestClient(app) as test_client: + yield test_client + get_settings.cache_clear() + + +def test_release_notify_without_key_configured_is_closed(client: TestClient) -> None: + """Пустой RELEASE_NOTIFY_KEY означает «эндпоинта нет», а не «пускать всех».""" + response = client.post("/v1/release/notify", json={"version_name": "0.2.2"}) + assert response.status_code == 503 + + +def test_release_notify_rejects_wrong_key(release_client: TestClient) -> None: + response = release_client.post("/v1/release/notify", json={"version_name": "0.2.2"}) + assert response.status_code == 401 + + # Админский ключ сюда тоже не подходит: ключи разные не для красоты. + response = release_client.post( + "/v1/release/notify", + json={"version_name": "0.2.2"}, + headers={"Authorization": "Bearer test-admin-key"}, + ) + assert response.status_code == 401 + + +def test_release_notify_rejects_broken_version(release_client: TestClient) -> None: + response = release_client.post( + "/v1/release/notify", + json={"version_name": "v0.2.2-rc1"}, + headers={"Authorization": "Bearer test-release-key"}, + ) + assert response.status_code == 422 + + +def test_release_notify_sends_to_every_device(release_client: TestClient) -> None: + db = release_client.app.state.db + account_id = db.upsert_account(1, 10, "pt", "ut") + other_account_id = db.upsert_account(2, 20, "pt", "ut") + for account, device_id, fcm in ( + (account_id, "device-1", "token-1"), + (other_account_id, "device-2", "token-2"), + # Без токена слать некуда — в рассылку не попадает. + (account_id, "device-3", ""), + ): + db.upsert_device( + account_id=account, + device_id=device_id, + token_hash=hash_device_token(device_id), + fcm_token=fcm, + label=None, + app_version=None, + ) + + fake = _FakeFcm() + release_client.app.state.fcm = fake + + response = release_client.post( + "/v1/release/notify", + json={"version_name": "0.2.2"}, + headers={"Authorization": "Bearer test-release-key"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "version_name": "0.2.2", + "devices": 2, + "sent": 2, + "failed": 0, + } + # Новость одна на весь сервис: рассылка идёт по аккаунтам, а не внутри одного. + assert sorted(fake.sent) == [("token-1", "0.2.2"), ("token-2", "0.2.2")] + + +def test_release_notify_clears_dead_token(release_client: TestClient) -> None: + """Мёртвый токен гасим, но устройство оставляем — иначе это выход из аккаунта.""" + db = release_client.app.state.db + account_id = db.upsert_account(1, 10, "pt", "ut") + db.upsert_device( + account_id=account_id, + device_id="device-1", + token_hash=hash_device_token("device-1"), + fcm_token="dead-token", + label=None, + app_version=None, + ) + + release_client.app.state.fcm = _FakeFcm(invalid_tokens={"dead-token"}) + + response = release_client.post( + "/v1/release/notify", + json={"version_name": "0.2.2"}, + headers={"Authorization": "Bearer test-release-key"}, + ) + + assert response.status_code == 200 + assert response.json()["sent"] == 0 + assert response.json()["failed"] == 1 + device = db.get_device("device-1") + assert device is not None + assert device.fcm_token == "" + + +def test_release_notify_survives_fcm_error(release_client: TestClient) -> None: + """Упавшая отправка не роняет запрос: релиз уже опубликован.""" + db = release_client.app.state.db + account_id = db.upsert_account(1, 10, "pt", "ut") + db.upsert_device( + account_id=account_id, + device_id="device-1", + token_hash=hash_device_token("device-1"), + fcm_token="token-1", + label=None, + app_version=None, + ) + + release_client.app.state.fcm = _FakeFcm(fail=True) + + response = release_client.post( + "/v1/release/notify", + json={"version_name": "0.2.2"}, + headers={"Authorization": "Bearer test-release-key"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "version_name": "0.2.2", + "devices": 1, + "sent": 0, + "failed": 1, + } diff --git a/scripts/bump-version.py b/scripts/bump-version.py index 3650f48a..a4dc298d 100755 --- a/scripts/bump-version.py +++ b/scripts/bump-version.py @@ -12,6 +12,14 @@ python3 scripts/bump-version.py patch --dry-run Печатает новую версию в stdout последней строкой — её и читает workflow. + +Отдельно от подъёма версии: + + python3 scripts/bump-version.py --check-unreleased + +Проверяет, что в «Не выпущено» есть записи, и падает, если их нет. Дёргается +из CI на каждом PR с кодом — раздел пуст ровно до тех пор, пока о нём не +вспомнят, а вспоминают в день выпуска, когда уже поздно. """ from __future__ import annotations @@ -121,13 +129,60 @@ def move_unreleased(version: str, today: str) -> str: return f"перенёс «Не выпущено» в [{version}] — {today}" +def check_unreleased() -> None: + """Раздел «Не выпущено» пуст — выходим с ошибкой и объясняем, что делать. + + Та же [has_real_entries], что и у переноса: «есть записи» должно значить + одно и то же в проверке и в выпуске, иначе CI пропустит раздел, который + [move_unreleased] потом сочтёт пустым — и версия выйдет без заметок. + """ + if not CHANGELOG.exists(): + sys.exit("CHANGELOG.md нет") + + parts = split_unreleased(CHANGELOG.read_text(encoding="utf-8")) + if parts is None: + sys.exit(f"В CHANGELOG.md нет раздела «{UNRELEASED_HEADING}»") + + _, body, _ = parts + if not has_real_entries(body): + sys.exit( + "Раздел «Не выпущено» в CHANGELOG.md пуст.\n" + "\n" + "Слияние в main выпускает релиз сразу, и заметки к нему берутся\n" + "отсюда. Пустой раздел — значит версия выйдет со списком коммитов\n" + "вместо человеческого описания, и дописать его будет уже некуда.\n" + "\n" + "Что сделать: добавить в «Не выпущено» строку о том, что изменилось\n" + "для того, кто пользуется приложением, под заголовком «Добавлено»,\n" + "«Изменено», «Исправлено» или «Убрано».\n" + "\n" + "Если выпускать нечего (правка тестов, рефакторинг без следов на\n" + "экране) — метка «без-релиза» на PR: тогда ни релиза, ни этой\n" + "проверки." + ) + + print("«Не выпущено» заполнен") + + def main() -> None: parser = argparse.ArgumentParser(description="Поднять версию и закрыть раздел CHANGELOG") - parser.add_argument("kind", choices=["major", "minor", "patch", "keep"]) + parser.add_argument("kind", nargs="?", choices=["major", "minor", "patch", "keep"]) parser.add_argument("--dry-run", action="store_true", help="ничего не писать, только показать") parser.add_argument("--date", default=datetime.date.today().isoformat()) + parser.add_argument( + "--check-unreleased", + action="store_true", + help="только проверить, что «Не выпущено» не пуст (для CI)", + ) args = parser.parse_args() + if args.check_unreleased: + check_unreleased() + return + + if args.kind is None: + parser.error("нужен разряд версии: major, minor, patch или keep") + current = read_version() new = bump(current, args.kind) version = ".".join(str(part) for part in new)