Кратко:
- Вынос в отдельный модуль, разбор форка 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)
- android/my/offline/mobile-impl/.../service/AppDownloadsTracker.kt уходят 4 события жц загрузки;
vsid = ""во всех четырёх,requestId = ""в событии ошибки — при том чтоAnalyticsErrorMapperуже инжектирован в этот же класс и используется для других полей, а методgetRequestIdв интерфейсе существует. - Evgen
Download.*(24 функции,EvgenAnalytics.kt:5894–6498) не вызывается с Android нигде — это iOS-часть общей схемы. События паузы и возобновления не отправляются ни в одном из вариантов, хотя пауза в продукте есть. - AnalyticsErrorMapperImpl схлопывает все ошибки в 4 значения (
AppError/NetworkError/ParserError/BackendError). Нехватка места, DRM, лимиты правообладателя, гео — всё этоapp_error. - Экран «Загрузки» не имеет 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 года последовательно рекомендует для пользовательских загрузок медиа не
dataSyncforeground 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-ресиверу стартоватьdataSyncFGS — классическая схема «докачать после перезагрузки» через 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.Три механизма утечки слотов лимита:
- Записи, застрявшие в
Removing.remove()сначала помечает записьRemoving, затемrunCatching { downloadManager.remove(id).get() }— результат и исключение проглатываются без отката статуса (OfflineContentManagerImpl.kt:434–472). Элемент исчезает из списка и из подсчёта размера, но строка и файлы остаются и продолжают занимать слотgetTotalCount(). - Записи, упавшие до получения манифеста. Фильтр видимости
isPreparingне смотрит наerrorStatus, поэтому такая загрузка исчезает из «Моих загрузок» вместе с текстом ошибки, но живёт в БД и учитывается в лимите до следующего холодного старта. - Проверка лимита выполняется после вставки.
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 и осиротевшие файлы не учитываются вовсе, поэтому цифра систематически расходится с тем, что показывает система в «Настройки → Хранилище».
Что можно сделать
- Пользователь удаляет загрузку в приложении. Клиент сейчас не отправляет ничего, хотя
CANCELLEDв контракте есть. Чинится на клиенте - Приложение удалено, данные стёрты, устройство потеряно. Клиента больше нет — отправить сигнал физически некому и нечем. Решается только на сервере: TTL/GC загрузок и лицензий по возрасту. Именно этот сценарий описан в KPANDROID-24106 (записи 2023 года с устройства, где приложения давно нет).
- Продуктовое: Пользователь хочет освободить слоты сам при отвязке устройства
Ожидаемая польза
- Пользователю: до нажатия видно «≈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, пропорциональная размеру библиотеки.
Батарея
- В
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 лицензии не освобождает вообще и эталоном быть не может. - Старт приложения не должен блокироваться на DRM
updateLicenseStatusesпри каждом старте последовательно вызывает блокирующийdrmLicenseManager.getLicenseProperties(...).get()для каждой лицензии каждой загрузки — без параллелизма, без таймаута, без проверки отмены. При несколько десятках серий это секунды-десятки секунд на IO-потоке и риск исчерпания одновременных MediaDrm-сессий на слабых устройствах. - Это особенно важно именно потому, что автопродления нет: раз лицензия не восстановится сама, пользователь обязан видеть корректный срок заранее.
- Ручное продление должно давать результат
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 ГБ · Очистить», индикатор занятых слотов лимита с разбивкой по устройствам и кнопкой освобождения. Зависит от проекта про место и слоты.