Загрузки

Кратко:

  • Вынос в отдельный модуль, разбор форка DownloadService.java
  • Приложение не знает, сколько места займёт загрузка и сколько его есть, никогда не убирает за собой и не сообщает серверу об удалении
  • Приложение скачивает все аудиодорожки и все субтитры всегда, при выборе «Максимальное» кладёт на устройство не максимальное качество, а изменить это через Remote Config невозможно — конфиг не парсится.
  • Значительная часть действий в загрузках не даёт пользователю никакого видимого результата — ни успеха, ни ошибки.
  • Плеер решает «играть офлайн или онлайн» по факту наличия строки в базе, а не по готовности файла
  • Пока идёт загрузка, приложение раз в секунду переспрашивает базу, декодирует до 20 постеров в умедомлениях и пересобирает весь список на главном потоке
  • Фоновые загрузки восстанавливаются только в ручную. и наоборот, FS может висеть с неактуальным уведомл-ем(тут еще Requirements не совпадают с Media3), Google рекомендует для загрузок использовать dataSync сервисы вместо FS
  • Срок жизниВесь TTL считается через System.currentTimeMillis, хотя в проекте уже есть ServerTimeProvider на SystemClock.elapsedRealtime — он применяется для catchup-лицензий и timeshift. Смена часового пояса или ручной перевод времени ломает отображение срока. Отдельно: expireAt заморожен — пересчитывается только если значение было нулевым, поэтому счётчик «осталось N дней» устаревает.
  • В OfflineContentManagerImpl.remove() строка БД с keyId удаляется раньше, чем вызывается releaseLicense , а ошибки проглатываются: .onErrorComplete() стоит до .doOnError, поэтому падение не логируется вообще. Отозвать лицензию повторно нечем — keySetId уже потерян.
  • Не хватает телеметрии, vsid; Evgen Download.* есть, но не вызывается; Нехватка места, DRM, лимиты правообладателя, гео - всё это мапится в app_error
  • Тесты - их практически нет; нет androidTest

Диагностика (наблюдаемость)

Примеры тикетов:

  • KPANDROID-23122](https://st.yandex-team.ru/KPANDROID-23122)
  • KPANDROID-23123 | licenseStatus - Expired после завершения загрузки
  • KPANDROID-23126 | errorProvisionRequestException, 401 Invalid Credentials

В логах из KPANDROID-23126: vsid: null. Это и есть причина, по которой тикеты «на анализе» пять месяцев - сессию загрузки не с чем связать.

Что в коде (AI)
  1. android/my/offline/mobile-impl/.../service/AppDownloadsTracker.kt уходят 4 события жц загрузки; vsid = "" во всех четырёх, requestId = "" в событии ошибки — при том что AnalyticsErrorMapper уже инжектирован в этот же класс и используется для других полей, а метод getRequestId в интерфейсе существует.
  2. Evgen Download.* (24 функции, EvgenAnalytics.kt:5894–6498) не вызывается с Android нигде — это iOS-часть общей схемы. События паузы и возобновления не отправляются ни в одном из вариантов, хотя пауза в продукте есть.
  3. AnalyticsErrorMapperImpl схлопывает все ошибки в 4 значения (AppError/NetworkError/ParserError/BackendError). Нехватка места, DRM, лимиты правообладателя, гео — всё это app_error.
  4. Экран «Загрузки» не имеет SLO-трекинга вообще; success rate считается неточно (Started шлётся повторно после рестарта сервиса).

Что дает Media3 (AI)
  • Download.FAILURE_REASON_* содержит только NONE и UNKNOWN — причину отказа движок не хранит, единственный источник правды это finalException в DownloadManager.Listener.onDownloadChanged(), его надо перехватывать и классифицировать самим.
  • Прогресс не даёт колбэков — официальная рекомендация Google опрашивать getCurrentDownloads().
  • Зато даром доступна битовая маска невыполненных Requirements через onRequirementsStateChanged — она позволяет честно отделить «заблокировано средой» (нет сети, лимитный Wi-Fi, мало места) от «сломались мы». Сейчас эта информация теряется полностью.

Ожидаемая польза

  • Диагностика инцидента вместо гадания. Три висящих тикета становятся разрешимыми; разбор обращения «не качается» перестаёт требовать ручного поиска в персональном sherlog-файле пользователя.
  • База для всех остальных проектов. Появляется baseline: success rate загрузок, время до готовности, распределение причин отказа, доля времени «заблокировано средой».
  • Продуктовые решения на данных. Сейчас нельзя ответить даже на вопрос «сколько загрузок вообще доходит до просмотра».
  • Диагностика инцидента вместо гадания.** Три висящих тикета «[Анализ]» становятся разрешимыми; разбор обращения «не качается» перестаёт требовать ручного поиска в персональном sherlog-файле пользователя.
  • База для всех остальных проектов. Появляется baseline: success rate загрузок, время до готовности, распределение причин отказа, доля времени «заблокировано средой».
  • Продуктовые решения на данных. Сейчас нельзя ответить даже на вопрос «сколько загрузок вообще доходит до просмотра».

Подтверждённые цифры (AI)

  • В домене загрузок: 125 файлов / 13 170 строк в src/main против 12 файлов / 4 098 строк / 159 @Test в src/test.
  • androidTest в домене — ноль каталогов (единственный androidTest во всём Android-репозитории лежит в player/television/screen/tv-impl). UI/e2e-тестов по загрузкам нет: products/android/apps/kinopoisk-mobile/ui-tests содержит только README.md и .gradle.
  • 12 модулей из 19 не имеют ни одного теста, включая my/offline/mobile-data (Room + 25 TypeConverter'ов, JSON-парсинг DRM-лицензий) и player/creator/shared/offline (маппинг офлайн-контента, включая DRM keyId, скипы, дорожки).
  • В домене загрузок: 125 файлов / 13 170 строк в src/main против 12 файлов / 4 098 строк / 159 @Test в src/test.
  • androidTest в домене — ноль каталогов (единственный androidTest во всём Android-репозитории лежит в player/television/screen/tv-impl). UI/e2e-тестов по загрузкам нет: products/android/apps/kinopoisk-mobile/ui-tests содержит только README.md и .gradle.
  • 12 модулей из 19 не имеют ни одного теста, включая my/offline/mobile-data (Room + 25 TypeConverter'ов, JSON-парсинг DRM-лицензий) и player/creator/shared/offline (маппинг офлайн-контента, включая DRM keyId, скипы, дорожки).

Что именно не покрыто (AI)
  • OfflineContentManagerImpl — 977 строк, ноль тестов. В нём сосредоточены download(), renewLicenses(), remove(), лимиты, гонки и обработка более двадцати типов ошибок.
  • 18 миграций Room при exportSchema = false. Схемы не экспортируются, значит MigrationTestHelper использовать не с чем — миграции нельзя проверить автотестом в принципе. Среди них деструктивные: MIGRATION_7_8 пересоздаёт таблицу через временную с DROP TABLE, MIGRATION_10_11 делает DROP TABLE SubProfile. База собирается с fallbackToDestructiveMigrationOnDowngrade(dropAllTables = true). Цена ошибки в следующей миграции — потеря всей скачанной библиотеки у пользователей при обновлении, и на CI это не воспроизводится
  • Форк DownloadService.java (1177 строк) — ноль тестов, при том что именно в нём находится вся логика жизненного цикла: isIdle, onTimeout, START_STICKY, attach/detach, отложенный рестарт.
  • Скриншот-тест выбора качества мёртв. В my/downloads-quality-screen/mobile-impl/build.gradle.kts не подключён плагин app.cash.paparazzi, а 4 PNG-эталона в репозитории названы по старому FQN (ru.kinopoisk.presentation.screen.film.downloads.quality_ChooseDownloadQualityFragmentViewPaparazziTest), не совпадающему ни с пакетом, ни с именем существующего класса. Экран выглядит покрытым, но регрессии вёрстки не ловятся вообще.
  • Compose-вариант ячейки не покрыт. Тест PlayableVideoVHPaparazziTest подставляет configProvider, который на любой флаг возвращает false, поэтому все 52 golden-снимка сняты с legacy XML-версии. Раскатка флага downloads_new_playable подменит визуал всего списка загрузок непротестированной реализацией.

Тестируемость

Причина 1: форк Media3 DownloadService

Последствия, подтверждённые кодом (AI):

  • Любое обновление Media3 требует ручного диффа форка против апстрима без страховки тестами.
  • Именно в этом файле придётся менять Scheduler, isIdle, onTimeout — то есть весь проект «надёжное фоновое выполнение» упирается в непокрытый тестами форк.
  • В самой библиотеке плеера (ExoDownloadManager) стоит TODO «Necessary rid of this DownloadManager, DownloadService and ExoWritableDownloadIndex» — то есть двойной слой абстракции признан временным и на стороне SDK.
  • Любое обновление Media3 требует ручного диффа форка против апстрима без страховки тестами.
  • Именно в этом файле придётся менять Scheduler, isIdle, onTimeout — то есть весь проект «надёжное фоновое выполнение» упирается в непокрытый тестами форк.
  • В самой библиотеке плеера (ExoDownloadManager) стоит TODO «Necessary rid of this DownloadManager, DownloadService and ExoWritableDownloadIndex» — то есть двойной слой абстракции признан временным и на стороне SDK.

Причина 2: офлайн живёт внутри монолита

Контракт домена жёстко приколочен к движку: смена или крупное обновление Media3 может потребовать правки core-интерфейсов и пересборки всех потребителей.


Рефакторинг

После убийства процесса системой или перезагрузки телефона скачивание молча замирает, и каждый элемент нужно возобновлять руками. При этом foreground-сервис, наоборот, не умеет останавливаться и висит с уведомлением, когда ничего не качается.

Подтверждённые факты (AI-assisted)

Восстановления нет ни одного механизма:

  • OfflineContentDownloadService.getScheduler() возвращает null (.../service/OfflineContentDownloadService.kt:97) — значит updateScheduler() в форкнутом DownloadService.java:1157 полностью выключен, а ветка ACTION_SET_REQUIREMENTS мертва.
  • WorkManager в тракте загрузок не используется вообще (в приложении он есть, но только для сплэша, TV-рекомендаций и push).
  • RECEIVE_BOOT_COMPLETED в мобильных манифестах отсутствует.
  • OfflineActionInitialize.initialize() при старте безусловно переводит все Queued/Downloading → Paused, а ExoWritableDownloadIndex мапит Paused в STATE_STOPPED со stopReason=1000, поэтому resumeDownloads() их не поднимет.
  • Ветка авто-перезапуска (tryingRestart + onActivityResumed, DownloadService.java:1117–1155) мертва после detachService(): тот снимает ActivityLifecycleCallbacks, а условие перезапуска истинно как раз тогда, когда сервис уже отсоединён.

Сервис не останавливается:

isIdle() = !offlineContentManager.isDownloaded(), а isDownloaded() проверяет наличие статусов Paused/Queued/Downloading. Одна поставленная на паузу загрузка держит FGS вечно — а такие записи создаются автоматически после каждого рестарта приложения (см. выше). В DownloadService.stop(isIdle) ветки else просто нет.

Requirements настроены неверно:при выключенном тумблере «только Wi-Fi» ставится Requirements(0) (DownloadRequirementManagerImpl.kt:56) — нет даже требования наличия сети, хотя дефолт Media3 это Requirements(NETWORK). Итог: пропажа сети даёт не ожидание, а FAILED после исчерпания ретраев. DEVICE_STORAGE_NOT_LOW не используется в проекте нигде.

Критерий Wi-Fi расходится: UI считает по hasTransport(TRANSPORT_WIFI), движку уходит NETWORK_UNMETERED. На Wi-Fi, помеченном системой как лимитный, интерфейс пишет «Загружается», а движок стоит намертво.

Android 15: onTimeout (6 ч/24 ч для dataSync) реализован формально — снимает уведомления и делает stopSelf(). Состояние в БД не сохраняется, аналитика не отправляется, пользователю ничего не сообщается.

Живой тикет: KPANDROID-25688 — «После перезапуска приложение просит обновить некоторые загрузки», воспроизводится за ~5 перезапусков.

Что рекомендует индустрия (AI)
  • Google с 2024 года последовательно рекомендует для пользовательских загрузок медиа не dataSync foreground service, а user-initiated data transfer job — JobInfo.Builder.setUserInitiated(true) (API 34+). Это зафиксировано в трёх официальных местах: гайде «Alternatives to data sync foreground services», странице выбора API и в Play-политике деклараций FGS-типов.
  • Публичный измеренный кейс: Google Maps после миграции офлайн-загрузки карт с FGS на UIDT отчиталась о более чем 10% улучшения download failure rate.
  • Android 15 добавил лимит 6 ч/24 ч на все dataSync-сервисы приложения вместе и запретил BOOT_COMPLETED-ресиверу стартовать dataSync FGS — классическая схема «докачать после перезагрузки» через FGS больше не работает.
  • Android 16 ужесточил квоты JobScheduler, в том числе для джобов, работающих одновременно с foreground service.

Ожидаемая польза

  • Пользователю: поставил сезон на ночь — утром он скачан. Не нужно открывать приложение и тыкать «Возобновить» на каждой серии. Уведомление исчезает, когда работа закончена.
  • Технически: снимается главная причина незавершённых загрузок; приложение перестаёт бессмысленно жечь суточный бюджет dataSync и удерживать процесс в памяти.
  • Ориентир эффекта: >10% улучшения failure rate (Google Maps, аналогичная миграция).
  • Технически: снимается главная причина незавершённых загрузок; приложение перестаёт бессмысленно жечь суточный бюджет dataSync и удерживать процесс в памяти.
  • Ориентир эффекта: >10% улучшения failure rate (Google Maps, аналогичная миграция).


Место на устройстве

Приложение не знает, сколько места займёт загрузка и сколько его есть, никогда не убирает за собой и не сообщает серверу об удалении — поэтому пользователи упираются в «превышено число загрузок» при половине лимита и в «удалил всё, а память занята».

Подтверждённые факты (AI)

Место не проверяется вообще. Предполётные проверки в download() — только выбор качества, Wi-Fi и число загрузок (OfflineContentManagerImpl.kt:176–179, 765–769). StorageManager.getAllocatableBytes / allocateBytes в проекте отсутствуют полностью, Requirements.DEVICE_STORAGE_NOT_LOW не используется нигде. Единственная защита — внутри библиотеки: при записи чанка проверяется cacheDir.freeSpace < 100 МБ, и ошибка приходит уже постфактум как ChunksOutOfSpace. Скачанные до этого момента гигабайты остаются на диске.

Размер неизвестен до конца загрузки. OfflinePlayback.size создаётся как -1 и заполняется только из Download.contentLength по факту. Пока size <= 0, UI экстраполирует размер из процента. На экране выбора качества показывается только слово («Максимальное»), поле description в модели существует, но всегда null.Три механизма утечки слотов лимита:

  1. Записи, застрявшие в Removing. remove() сначала помечает запись Removing, затем runCatching { downloadManager.remove(id).get() } — результат и исключение проглатываются без отката статуса (OfflineContentManagerImpl.kt:434–472). Элемент исчезает из списка и из подсчёта размера, но строка и файлы остаются и продолжают занимать слот getTotalCount().
  2. Записи, упавшие до получения манифеста. Фильтр видимости isPreparing не смотрит на errorStatus, поэтому такая загрузка исчезает из «Моих загрузок» вместе с текстом ошибки, но живёт в БД и учитывается в лимите до следующего холодного старта.
  3. Проверка лимита выполняется после вставки. checkedAddToDownloading вставляет строку в Room до checkLimitPerDevice(), и при выбросе DownloadTotalCountExceededException строка не удаляется, а получает errorStatus. Каждая неудачная попытка скачать сверх лимита оставляет мусорную строку, которая продолжает считаться. Счётчик растёт необратимо. Плюс off-by-one: строгое > позволяет создать 501-ю запись при лимите 500.

Клиент не сообщает серверу об удалении. Контракт поддерживает Cancelled, но маппер OfflinePlayback.toContentStatus() возвращает только Downloading и Downloaded.Клиент (OfflineContentSyncManagerImpl.toSyncDownloadStatuses) собирает список из существующих строк Room. Это не снимок с семантикой «чего нет — то удалено»: у сервера нет оснований трактовать отсутствие downloadId как освобождение слота. Освобождает ли он слот сам и по какому признаку — из клиента неверифицируемо и подлежит выяснению у бэкенда, а не додумыванию.

Сборки мусора нетНи один компонент не обходит каталог offline/contents и не сверяет его с Room и DownloadCache (listFiles/walk/deleteRecursively в main-исходниках отсутствуют). Единственная уборка при старте — удаление «preparing»-записей по данным БД, а не по файловой системе. Любой разрыв связки «строка Room ↔ файлы» (провалившееся удаление, kill процесса между операциями, деструктивная миграция при fallbackToDestructiveMigrationOnDowngrade) оставляет гигабайты навсегда: экран «Загрузки» пуст, «Удалить всё» ничего не удаляет, освободить место из приложения невозможно в принципе.Размер в настройках считается по БД, а не по дискуdownloadSize()складывает downloadedSize из записей, причём Room применяет значение монотонно (CASE WHEN :downloadedSize > … THEN …). Записи в Removing и осиротевшие файлы не учитываются вовсе, поэтому цифра систематически расходится с тем, что показывает система в «Настройки → Хранилище».

Что можно сделать

  1. Пользователь удаляет загрузку в приложении. Клиент сейчас не отправляет ничего, хотя CANCELLED в контракте есть. Чинится на клиенте
  2. Приложение удалено, данные стёрты, устройство потеряно. Клиента больше нет — отправить сигнал физически некому и нечем. Решается только на сервере: TTL/GC загрузок и лицензий по возрасту. Именно этот сценарий описан в KPANDROID-24106 (записи 2023 года с устройства, где приложения давно нет).
  3. Продуктовое: Пользователь хочет освободить слоты сам при отвязке устройства

Ожидаемая польза

  • Пользователю: до нажатия видно «≈4,1 ГБ», и загрузка не падает на 80% из-за места; возвращаются гигабайты, которые сейчас нельзя освободить из приложения вообще; исчезает тупик «лимит исчерпан давно удалёнными загрузками».
  • Поддержке и бэкенду: закрывается класс обращений вида KPANDROID-24106; расхождение «слотов на сервере минус фактических загрузок»
  • Технически: появляется единственный источник правды о занятом месте и предсказуемое поведение при нехватке.
  • Пользователю:** до нажатия видно «≈4,1 ГБ», и загрузка не падает на 80% из-за места; возвращаются гигабайты, которые сейчас нельзя освободить из приложения вообще; исчезает тупик «лимит исчерпан давно удалёнными загрузками».
  • Поддержке и бэкенду: закрывается класс обращений вида KPANDROID-24106; расхождение «слотов на сервере минус фактических загрузок» перестаёт накапливаться — в той части, которая зависит от клиента.
  • Технически: появляется единственный источник правды о занятом месте и предсказуемое поведение при нехватке.


Трафик

Приложение скачивает все аудиодорожки и все субтитры всегда, при выборе «Максимальное» кладёт на устройство не максимальное качество, а изменить это через Remote Config невозможно — конфиг не парсится.

Подтверждённые факты (AI)

Скачивается всё подряд. DownloadQualityManagerImpl.chooseQuality выбирает ровно один видеотрек, а всё остальное добавляет целиком без фильтрации: trackVariants.filter { it.trackType != TrackType.Video }.plus(selectedTrack) . Выбора языка при скачивании нет ни в UI, ни в домене: audioLanguage/subtitleLanguage хранятся только как предпочтения для последующего воспроизведения. Субтитры весят мало, но каждая лишняя звуковая дорожка — это десятки-сотни мегабайт на фильм.«Максимальное» даёт минимум из диапазона. selectTrack определён как QualityRangeType.Max -> minByOrNull { it.format.width } , а профиль High имеет rangeType = Max и диапазон 1280..3839 для 16:9. То есть при наличии дорожек 1080p и 720p пользователь, выбравший «Максимальное», получает 720p. Верхняя граница 3839 дополнительно отсекает 4K.Remote Config мёртв. Корневая модель DownloadsQualityRemoteConfigList объявлена без @Serializable, при том что все вложенные типы в том же файле аннотированы, а декодирование идёт рефлексивным kotlinx.serialization. Любая попытка выкатить новые диапазоны или качества молча падает в SerializationException, перехватывается runCatching и подменяется захардкоженным DEFAULT. Практический вывод: починить пороги качества без релиза приложения сейчас нельзя — включая те самые 3839 и Max -> min.Качество скачанного нигде не хранится. В Room-сущности нет ни qualityId, ни width, ни bitrate — только trackKeys с числовыми индексами. Пользователь не видит, что именно лежит на телефоне, и не может пересобрать файл в другом качестве, не удалив его. Поддержка не может проверить жалобу «скачал в максимальном, а картинка мыло».Возобновление может скачать всё. При resume треки заново вычитываются из манифеста и фильтруются по сохранённым числовым индексам; если состав на бэкенде изменился, результат уходит в downloadManager.start без проверки на пустоту — то есть скачивается весь манифест целиком: все качества, все аудио, все субтитры. Молча.Сезон = N последовательных обменов. getDownloadFlow создаёт отдельный GraphQL-запрос на каждый contentId, плюс отдельный манифест и отдельный обмен с DRM-прокси. Batch-контракт в проекте существует (StreamsCommonRequest.Batch), но для загрузок не используется. Скачивание сезона из 20 серий — это 20 «тяжёлых» запросов, 20 манифестов и 20 DRM-обменов по очереди.Выбор качества в настройках матчится по локализованной строке (qualities.find { it.name == model.text }), а не по id — любое совпадение или изменение перевода ломает выбор.

Ожидаемая польза

  • Пользователю: фильм занимает заметно меньше и качается быстрее; «Максимальное» наконец означает максимальное; видно, что именно лежит на телефоне.
  • Компании: прямая экономия исходящего трафика CDN на каждой загрузке.
  • Технически: возвращается управляемость — пороги качества можно менять из Remote Config без релиза.
  • Пользователю: фильм занимает заметно меньше и качается быстрее — в поездку влезает больше; «Максимальное» наконец означает максимальное; видно, что именно лежит на телефоне.
  • Компании: прямая экономия исходящего трафика CDN на каждой загрузке.
  • Технически: возвращается управляемость — пороги качества можно менять из Remote Config без релиза.


Трафик

Значительная часть действий в загрузках не даёт пользователю никакого видимого результата — ни успеха, ни ошибки. Отсюда самый массовый класс жалоб: «кнопка не работает» и «висит вечно».

Подтверждённые факты (AI)

Ошибка старта загрузки не показывается. Обработчик в DownloadInteractionDelegateImpl.download() реагирует ровно на три типа исключений: не выбрано качество, превышен лимит устройств, мобильный интернет. Всё остальное — только запись в лог. Нажал «Скачать» при отсутствии сети, отказе прав или ошибке манифеста — визуально не происходит ничего.«Повторить» гасит ошибку и вешает вечное «В очереди». resume() сразу выставляет Queued, а onResumeDownloadClick не обрабатывает ошибки вообще. Если причина не устранена (нет места, нет сети), элемент остаётся в «В очереди» навсегда, и понять, что произошло, уже невозможно.Ошибка на экране «Загрузки» превращается в пустой экран. onErrorReturn в DownloadsViewModel.loadDownloads() не только подставляет пустой список, но и завершает Observable: после первой ошибки апстрима список больше никогда не обновится, а пользователь видит «Здесь будет храниться загруженное кино» — неотличимо от «загрузок нет». Отдельного error-state и кнопки «Повторить» на экране не существует.Статус лжёт. UI считает Wi-Fi по TRANSPORT_WIFI, движок — по NETWORK_UNMETERED: на лимитном Wi-Fi интерфейс пишет «Загружается», а закачка стоит. Статус «В очереди» вообще показывается только когда активных загрузок больше двух (hasDownloadingContent() = count > 2) — при постановке сезона первые две серии выглядят так, будто ничего не началось.Удаление опасно и молчаливо. Массовое удаление выполняется без подтверждения и без undo — один тап по кнопке безвозвратно удаляет десятки гигабайт. При ошибке удаления UI выглядит как успешный (выделение снимается), а элементы остаются. Завершённую загрузку нельзя удалить поштучно с экрана «Загрузки» — только через режим мультивыбора или карточку фильма.Уведомления могут не появиться вовсе. POST_NOTIFICATIONS запрашивается только на табах и только при needRequestNotificationPermissionOnStartScreen (в Allplay/Yango SDK этот флаг false). В сценарии загрузки разрешение не проверяется и не запрашивается никогда. На Android 13+ при отказе пользователь не видит ни прогресса, ни кнопок «Пауза/Отмена», ни сообщения о завершении — при этом фоновый сервис работает.Доступность сломана — есть живые тикеты: KPANDROID-22115 (открыт, заводился как Critical) — в режиме редактирования тайтлы не имеют роли флажка и состояния; KPANDROID-22114 (открыт) — кнопка предупреждения об обновлении загрузки без подписи. Оба заведены по итогам аудита доступности ALLY-282. Дополнительно: Compose-вариант ячейки (за флагом downloads_new_playable) теряет и богатое contentDescription legacy-холдера, и визуальное затемнение недоступного контента.Чего нет вовсе: ETA и скорости загрузки, сортировки, поиска, pull-to-refresh, «выбрать всё», предупреждения об истечении срока заранее.

Ожидаемая польза

  • Пользователю: исчезает состояние «нажал и ничего не понял». Каждое действие даёт результат, каждая остановка — причину и кнопку, которая её снимает. Случайное удаление обратимо.
  • Поддержке: падает поток обращений «кнопка не работает» / «загрузка висит» — это сегодня самый частый пользовательский сценарий отказа.
  • Продукту: закрываются два открытых A11Y-тикета и снимается риск повторного провала аудита доступности.
  • Пользователю: исчезает состояние «нажал и ничего не понял». Каждое действие даёт результат, каждая остановка — причину и кнопку, которая её снимает. Случайное удаление обратимо.
  • Поддержке: падает поток обращений «кнопка не работает» / «загрузка висит» — это сегодня самый частый пользовательский сценарий отказа.
  • Продукту: закрываются два открытых A11Y-тикета и снимается риск повторного провала аудита доступности.


UX

Подтверждённые факты

Роутинг в офлайн слишком широкий. KinopoiskPlayerCompositeVideoDataRepository.getVideoData() выбирает офлайн-источник при offlineContentManager.hasContent(contentId) — это SELECT COUNT(*) FROM OfflineContent WHERE contentId = :id. Верификация уточнила: отсеиваются только два состояния — «готовится» (offlinePlayback == null) и «удаляется» (Removing). Всё остальное уходит в офлайн: недокачанное, приостановленное, с ошибкой, с истёкшей лицензией. При этом конфигурация источника ставит canReadFromUpstream = false, то есть догрузиться из сети уже нельзя.Сценарий пользователя: начал качать фильм, скачалось 30%, поставил на паузу; позже нажал «Смотреть» при полном интернете — получает обрывок вместо стрима. При протухшей лицензии — ошибку DRM вместо нормального онлайн-просмотра по действующей подписке.Крэш на старых загрузках. KinopoiskPlayerTimingsManager делает checkNotNull(videoData.videoToken) внутри withContext(NonCancellable); в legacy-реализации этот же вызов был обёрнут в runCatching{}.onFailure{ log }, в новой обёртка потеряна. Для загрузок, сделанных до миграции Room 11→12, videoToken пустой (колонка добавлена как TEXT NOT NULL DEFAULT ''), а скоуп создан без CoroutineExceptionHandler — IllegalStateException улетает в глобальный обработчик. Падение срабатывает по таймеру отправки таймингов (каждые 20 с) и на каждом seek, то есть воспроизводимо.Офлайн не отключает сетевые фичи (регресс относительно legacy). В legacy для PlayMode.Offline подставлялся DisabledStrmManagerConfig, не создавался StrmValidator, а watchNext и continue-on-tv были ограничены онлайном. В новом плеере офлайн-ветки нет: при просмотре скачанного идут STRM-запросы, preload следующей серии, watchNext, deep-dive и continue-on-tv. В чистом офлайне это поток сетевых ошибок и ретраев, в роуминге — трафик и деньги пользователя.Ключ офлайн-кеша хрупкий. Офлайн-провайдер использует CacheKeyFactory.DEFAULT (ключ = полный URI), тогда как онлайн-кеш нормализует URL (срезает host/signature/timestamp). При этом manifestUrl загрузки собирается с добавлением vsid, а маппер умеет дописывать min_res_height. Любое изменение ссылки между скачиванием и воспроизведением делает контент непроигрываемым при canReadFromUpstream = false — байты на диске есть, а ключи не совпадают.Тайминги офлайна уезжают не тому профилю. OfflineTimingsSyncManagerImpl берёт profileId из первой записи и отправляет под ним весь список, после чего удаляет все строки — при том что таблица имеет составной PK (contentId, profileId, subProfileId) и штатно хранит записи разных аккаунтов.Порог «просмотрено» не работает в KinopoiskPlayerWatchProgressResolver: position / duration — целочисленное деление Long, поэтому сравнение с 0.95f вырождается в «position < duration». Досмотренный на 99% контент считается недосмотренным.

Ожидаемая польза

  • Пользователю: нажал на скачанное — оно играет; если офлайн объективно невозможен, начинается онлайн-просмотр.
  • Технически: снимается воспроизводимый крэш; прекращается лишний сетевой трафик при офлайн-просмотре (прямая экономия батареи).
  • Пользователю: нажал на скачанное — оно играет; если офлайн объективно невозможен, начинается онлайн-просмотр с объяснением, а не обрыв и не крэш.
  • Технически: снимается воспроизводимый крэш; прекращается лишний сетевой трафик при офлайн-просмотре (прямая экономия батареи и денег пользователя в роуминге).


Переход офлайн/онлайн

Возможная связь с KPANDROID-24219 — «Зависает приложение во время загрузки фильмов», воспроизводимость «Стабильно».

Подтверждённые факты (AI)

Цикл уведомлений раз в секунду. ForegroundNotificationUpdater перепланирует себя каждые 1000 мс (DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL). Верификация уточнила важное: тяжёлая работа лежит не в самой summary-нотификации, а в per-item ветке showNotifications(...) — это запрос к Room, сортировка, take(20) и синхронная загрузка битмапов через Picasso для каждого элемента. При постановке сезона это до 20 декодирований JPEG в секунду на протяжении часов. Реальный перегрев и расход батареи, а система всё равно троттлит обновления уведомлений.Список пересобирается на главном потоке. В DownloadsViewModel.loadDownloads() оператор map стоит после observeOn(main), поэтому полный перемаппинг моделей и DownloadStateResolverImpl.resolve для каждого элемента идут на UI-потоке. Источник — Room-Observable, инвалидируемый на каждой записи прогресса (порядка одного события в секунду на активную загрузку). Ни debounce, ни conflate, ни distinctUntilChanged в цепочке нет. Сверху RecyclerAdapter.updateModels синхронно считает DiffUtil.calculateDiff тоже на main.DiffUtil работает вхолостую. VideoViewHolderModel.isTheSameAs сравнивает только episodeId, а на экране «Загрузки» верхнеуровневые элементы — фильмы и сериалы, у которых episodeId == null. Все элементы считаются «одним и тем же», поэтому вместо move/insert/remove выдаются сплошные change-события и перебиндивается весь хвост списка.Синк переписывает всю таблицу. updateAvailabilityStatuses в транзакции читает все не-сериальные строки, проставляет каждой updatedAt = now и вызывает @Update(onConflict = REPLACE) — то есть UPDATE OR REPLACE для всех строк, даже если ничего не изменилось. Это гарантированно инвалидирует Flow и запускает полный перемаппинг списка.Движок читает всю таблицу на каждую операцию. ExoWritableDownloadIndex.getDownloads(vararg states) выполняет downloadStorage.getAll().get() — то есть SELECT * FROM OfflineContent с десериализацией JSON-конвертеров (DRM-лицензии, trackKeys) — и фильтрует по состояниям в памяти. При сотнях загрузок это происходит на каждое изменение состояния.Room настроен неоптимально: JournalMode.TRUNCATE вместо WAL (запись блокирует чтение на фоне постоянных апдейтов прогресса), индексы объявлены только на manifestUrl, kpId, parentContentId — тогда как горячие запросы фильтруют по downloadChunkStatus, errorStatus и contentType.Блокирующие вызовы в горячих путях. OfflineDownloadStorage.get/getAll/getByManifest возвращают Future поверх блокирующих Room-запросов, и этот storage передаётся прямо в TrackFilterProvider, чей filter(uri, params) — синхронный API, вызываемый при парсинге манифеста в потоке загрузки. Плюс идиома await(dispatcher) в FutureUtils создаёт ложное ощущение фона: runInterruptible выполняется в контексте вызывающей корутины, а переданный диспетчер используется только для вспомогательного job'а отмены.Google рекомендует опрашивать getCurrentDownloads() для прогресса (колбэков Media3 не даёт) — но частота опроса и стоимость обработки полностью на нас. Обновление уведомления по значимому изменению (шаг прогресса ≥ 1%) вместо таймера — стандартная практика.Порядок ускорения от параллелизма сегментов подтверждён замерами Media3, но выигрыш есть только на низких битрейтах — на 1920×856 прироста нет вовсе. Значит, оптимизация параллелизма должна быть адаптивной, а не «поставим 6 везде».

Ожидаемая польза

  • Пользователю: телефон меньше греется и дольше живёт при скачивании сезона; экран «Загрузки» перестаёт лагать и дёргаться; уведомление перестаёт мигать раз в секунду.
  • Технически: снимается постоянная фоновая нагрузка на CPU, диск и GC
  • Пользователю: телефон меньше греется и дольше живёт при скачивании сезона; экран «Загрузки» перестаёт лагать и дёргаться; уведомление перестаёт мигать раз в секунду.
  • Технически: снимается постоянная фоновая нагрузка на CPU, диск и GC, пропорциональная размеру библиотеки.


Батарея

  1. В OfflineContentManagerImpl.remove() строка БД с keyId удаляется раньше, чем вызывается releaseLicense , а ошибки проглатываются: .onErrorComplete() стоит до .doOnError, поэтому падение не логируется вообще. Отозвать лицензию повторно нечем — keySetId уже потерян. Последствие прямое: неотозванные офлайн-лицензии накапливаются в Widevine keystore и сгорают лимиты устройства. Это соседняя боль с KPANDROID-24106 («TOTAL_DOWNLOAD_COUNT_EXCEEDED при 261 активной загрузке», 590 загрузок на трёх устройствах). Типовой сценарий утечки — «почистить память в самолёте»: сети нет, отзыв падает, ошибка не видна никому. Правильный путь зафиксирован в документации Media3: KEY_TYPE_RELEASE со scope = keySetId, при котором сервер криптографически подтверждает удаление и корректирует счётчик офлайн-лицензий устройства. MediaDrm.removeOfflineLicense() — аварийный вариант без уведомления сервера. Демо-приложение Media3 лицензии не освобождает вообще и эталоном быть не может.
  2. Старт приложения не должен блокироваться на DRMupdateLicenseStatuses при каждом старте последовательно вызывает блокирующий drmLicenseManager.getLicenseProperties(...).get() для каждой лицензии каждой загрузки — без параллелизма, без таймаута, без проверки отмены. При несколько десятках серий это секунды-десятки секунд на IO-потоке и риск исчерпания одновременных MediaDrm-сессий на слабых устройствах.
  3. Это особенно важно именно потому, что автопродления нет: раз лицензия не восстановится сама, пользователь обязан видеть корректный срок заранее.
  4. Ручное продление должно давать результатrenewLicenses завершается .onErrorComplete(), вызывающий onRenewLicenseClick делает .subscribe() без обработчиков — успех и провал для UI неотличимы. При ошибке теряется availabilityStatus, поэтому вместо «Оплатить» пользователь видит бесполезное «Обновить/Удалить». Тикеты: KPANDROID-23123 (licenseStatus - Expired), KPANDROID-23122 (ERROR_DRM_GENERIC_PLUGIN), KPANDROID-23126 (provisioning 401), KPANDROID-25688 («после перезапуска просит обновить некоторые загрузки»).

Ожидаемая польза

  • Прекращается утечка лицензий, из-за которой у пользователя сгорают лимиты устройства и новые загрузки упираются в ошибку.
  • Старт приложения перестаёт блокироваться на DRM-операциях при большой библиотеке.
  • Срок жизни загрузки показывается честно — способ предупредить заранее.
  • Ручное продление даёт понятный результат с корректным действием («Оплатить» вместо «Обновить», когда кончилась подписка).
  • Прекращается утечка лицензий, из-за которой у пользователя сгорают лимиты устройства и новые загрузки упираются в ошибку.
  • Старт приложения перестаёт блокироваться на DRM-операциях при большой библиотеке.
  • Срок жизни загрузки показывается честно — критично именно в условиях запрета автопродления: единственная защита пользователя от «протухло в дороге» это предупредить заранее.
  • Ручное продление даёт понятный результат с корректным действием («Оплатить» вместо «Обновить», когда кончилась подписка).

Ожидаемая польза

Netflix Smart Downloads — cостоит из двух независимых механизмов:

  • Download Next Episode — «когда вы досмотрели скачанный эпизод, мы удаляем его с устройства и автоматически скачиваем следующий»; последний эпизод сезона не удаляется; работает только по Wi-Fi.
  • Downloads for You — фоновое скачивание рекомендаций в пределах пользовательского бюджета памяти (при запуске в 2021 это были 1/3/5 ГБ, сейчас — слайдер на каждый профиль).
  • Download Next Episode — «когда вы досмотрели скачанный эпизод, мы удаляем его с устройства и автоматически скачиваем следующий»; последний эпизод сезона не удаляется; работает только по Wi-Fi.
  • Downloads for You — фоновое скачивание рекомендаций в пределах пользовательского бюджета памяти (при запуске в 2021 это были 1/3/5 ГБ, сейчас — слайдер на каждый профиль).

YouTube Premium Smart Downloads** — более агрессивный вариант (новые видео докачиваются каждые 7 дней, старые заменяются). Именно эта фича собрала наибольший негатив: вокруг неё выросла индустрия инструкций «как отключить», потому что она молча занимает гигабайты.

Фактически занятый объём по диску (а не по БД), строка «Не привязано к загрузкам — N ГБ · Очистить», индикатор занятых слотов лимита с разбивкой по устройствам и кнопкой освобождения. Зависит от проекта про место и слоты.