Обновить зависимость или остаться на старой версии: что на самом деле решает команда?
Обновление flutter_secure_storage на Android: почему промежуточный релиз не гарантирует миграцию, что проверяет приложение и когда отсрочка обновления оправданна.
- Опубликовано
- 30 сентября 2026 г.
- Проверено
- 28 сентября 2026 г.
Обновить зависимость или остаться на старой версии: что на самом деле решает команда?
Зависимость обновили, несколько вызовов поправили, приложение собралось. На тестовом устройстве вход работает, сессия сохраняется, после перезапуска повторно вводить пароль не нужно. Казалось бы, можно выпускать.
Но на этом устройстве приложение установили заново. У пользователя оно стоит уже год, а внутри остались данные, записанные совсем другой версией библиотеки. Новая сборка умеет создавать свои записи. Прочитает ли она то, что осталось от старой? При чистой установке читать было нечего.
После такой истории соблазн ничего не трогать вполне понятен. Работало же. Только за время этого «пока» выходят новые исправления, меняются требования к платформе, а знакомый переход на одну версию вперёд постепенно превращается в разбор нескольких лет чужих изменений. Оставить старую зависимость бывает разумно. Но хорошо бы понимать, до какого момента и за счёт чего она остаётся подходящим решением.
Поэтому вопрос об обновлении довольно быстро выходит за пределы pubspec.yaml. Какое исправление действительно нужно приложению? Что придётся менять помимо вызовов API? И что произойдёт с данными человека, который не устанавливал каждую промежуточную сборку вместе с командой разработки?
На flutter_secure_storage это различие хорошо видно. Пакет хранит чувствительные значения средствами платформы: например, токен для обращения к серверу или ключ к локальным данным. В обоих случаях код читает строку по имени. Только после неудачного чтения в одном приложении может быть достаточно заново войти в аккаунт, а в другом окажется недоступно всё, что зашифровано этим ключом.
Промежуточную версию выпустили. А миграция состоялась?
В истории изменений пакета есть предупреждение: v11 больше не поддерживает прежние алгоритмы шифрования и старый механизм EncryptedSharedPreferences. Данные, записанные с их помощью, сначала нужно перенести через v10, пока она ещё умеет их читать.
На уровне репозитория план выглядит просто: сначала обновить пакет до десятой версии, выпустить приложение, затем перейти на одиннадцатую. Порядок соблюдён. Осталось разобраться, соблюдён ли он на телефоне пользователя.
Предположим, приложение с v10 вышло месяц назад. Часть пользователей его установила и открыла. Кто-то отключил обновления, кому-то приложение за этот месяц просто не понадобилось. Теперь выходит сборка с v11, и человек получает её сразу поверх старой. Команда промежуточный релиз выпустила, но на этом устройстве его код ни разу не исполнялся.
Впрочем, даже установленная промежуточная версия ещё ничего не гарантирует. Миграцию ведь должно что-то запустить.
Чтобы проверить именно этот момент, для ArkTelos Lab подготовлен небольшой Android-стенд с тремя версиями зависимости: 9.2.4, 10.3.2 и 11.2.0. Старая сборка сохраняла искусственную строку вместо настоящего токена, следующая устанавливалась поверх неё. Идентификатор приложения и подпись оставались прежними. Между шагами одного перехода данные не очищались — иначе вместе с ними исчез бы и сам предмет проверки.
Исходники и сохранённые результаты эксперимента доступны для повторения этих переходов. Условия запуска и ограничения приведены в README и RESULTS.
В старой сборке способ хранения был задан явно:
const storage = FlutterSecureStorage(
aOptions: AndroidOptions(
resetOnError: false,
keyCipherAlgorithm: KeyCipherAlgorithm.RSA_ECB_PKCS1Padding,
storageCipherAlgorithm: StorageCipherAlgorithm.AES_CBC_PKCS7Padding,
),
);
Эти алгоритмы указаны явно, чтобы опыт можно было повторить с тем же старым способом хранения. Для нового приложения такой выбор не предлагается: как раз его поддержку из v11 уже убрали.
В промежуточной сборке с 10.3.2 включены миграция при смене алгоритма и резервное копирование на время переноса:
const storage = FlutterSecureStorage(
aOptions: AndroidOptions(
resetOnError: false,
migrateOnAlgorithmChange: true,
migrateWithBackup: true,
),
);
resetOnError: false во всех трёх вариантах отключал автоматический сброс при ошибке. Иначе вместо исследования совместимости можно незаметно исследовать, как приложение создаёт пустое хранилище после неудачного чтения.
На Android 15, API 35, проверены следующие пути:
| Путь обновления | Что произошло в 11.2.0 |
|---|---|
| Чистая установка 11.2.0 | Новая запись сохранялась и читалась |
| 9.2.4 → 10.3.2 с обращением к хранилищу → 11.2.0 | Прежнее значение прочиталось без изменения |
| 9.2.4 → сразу 11.2.0 | Прежняя запись определена как нечитаемая |
| 10.3.2 установлена, но приложение не запускалось | Прежняя запись определена как нечитаемая |
| Экран сборки с 10.3.2 открыт, но обращения к хранилищу не было | Прежняя запись определена как нечитаемая |
Последняя строка легко теряется за привычным «приложение же запускалось». Экран действительно появился. Но в этом стенде миграцию запускало обращение к хранилищу, а его не было. Поэтому отметка о запуске промежуточной версии тоже не подтверждает, что данные перенесены.
В трёх неудачных переходах проверка возвращала legacyDataUnreadable с причиной missingAlgorithmMarkers: запись осталась без служебных отметок, которые ожидает новая реализация. При этом willDiscard=false. Называть такой результат потерей данных было бы неточно: проверка сообщила, что эта версия не может их прочитать, а не что они уничтожены. После такого ответа код приложения обычное чтение не запускал.
Какую версию должно проверять приложение
Здесь напрашивается собственный storageVersion: прочитать номер, при необходимости выполнить миграцию, записать новый. Направление разумное. Проблема в том, что за словом «версия» пока скрываются три разных вещи.
Версия пакета известна из сборки и зафиксированных зависимостей. Она отвечает на вопрос, какой код сейчас исполняется. Что этот код найдёт на устройстве, из номера пакета не следует.
Способ хранения — это расположение данных, алгоритмы, ключи и служебные отметки, с которыми работает библиотека. Можно записать в настройки число 11, но поддержка старого алгоритма от этого в новый пакет не вернётся.
Версия прикладного формата описывает содержимое записи. Например, раньше сессия была одной строкой, а теперь это структура с номером схемы и полем credential. Вот этот переход принадлежит приложению: библиотека не знает, зачем строку понадобилось превращать в структуру и что в ней обязательно должно сохраниться.
Но и собственный номер схемы можно записать слишком рано:
await preferences.setInt('storageVersion', 2);
await migrateSession();
Это намеренно ошибочный пример, не код стенда. Первая строка успела выполниться, вторая — нет. При следующем запуске приложение увидит новую версию, хотя перенос не завершился. Переставить строки полезно, но останется обратная ситуация: данные уже перенесены, а номер ещё старый. Можно ли выполнить ту же миграцию повторно?
От ответа зависит реализация. Номер версии помогает выбрать действие; сам по себе он не доказывает, что это действие закончилось успешно.
Сначала доступность, потом собственный формат
Проверка должна произойти до того, как фоновая задача полезет обновлять токен или экран попробует восстановить сессию. Иначе приложение успеет обратиться к проблемным данным раньше своего же механизма защиты.
В v11 есть checkUpgradeStatus(). Его назначение объяснено сопровождающим пакета: обнаружить проблемы после перехода до обычных обращений к хранилищу. Сам метод не переносит старые данные и не восстанавливает ключи.
Приложению нужен ответ, с которым можно работать дальше: разрешено ли читать данные, уже обнаружена проблема или проверка пока ничего не выяснила. В стенде эти варианты собраны в enum StorageReadiness; ответ пакета преобразуется в него следующим образом:
final status = await storage.checkUpgradeStatus();
if (status.reason == SecureStorageUpgradeReason.unsupportedPlatform) {
return StorageReadiness.unsupported;
}
return switch (status.state) {
SecureStorageUpgradeState.ok => StorageReadiness.readable,
SecureStorageUpgradeState.legacyDataUnreadable =>
StorageReadiness.unreadable,
SecureStorageUpgradeState.legacyDataDiscarded =>
StorageReadiness.discarded,
SecureStorageUpgradeState.unknown => StorageReadiness.unknown,
};
У первой ветки есть неочевидная причина. В использованном интерфейсе пакета неподдерживаемая диагностика может вернуть state=ok вместе с unsupportedPlatform. Если оставить только проверку ok, получится разрешение на работу там, где состояние хранилища вообще не проверяли.
И само имя readable здесь означает лишь допуск к следующему шагу. Оно не обещает, что каждая запись успешно расшифруется и устроит приложение по содержимому. Это ещё предстоит проверить чтением.
При unknown ещё предстоит выяснить причину. Возможно, для чтения потребуется аутентификация с участием пользователя. Если диагностика на платформе не поддерживается, понадобится другой способ проверки. Ни один из этих ответов не даёт основания очищать хранилище. Стенд останавливает дальнейший доступ; экраны аутентификации и полноценное восстановление в нём не реализованы.
Номер схемы рядом с данными
Для одной небольшой записи необязательно начинать с универсального движка миграций. В примере старое значение лежит под ключом trial.session.v1, а новое — под trial.session.v2. Внутри новой записи хранятся одновременно номер схемы и значение:
{
"schema": 2,
"credential": "not-a-real-token:arktelos-storage-trial"
}
JSON здесь — содержимое записи, которое затем передаётся в secure storage. Это не предложение положить токен открытым текстом в обычные настройки. Отдельный ключ позволяет не перезаписывать единственный исходник до проверки переноса.
Сначала код ищет новую запись. Если она есть, проверяет схему и обязательное поле. Неизвестная будущая схема или повреждённый JSON не трактуются как отсутствие сессии. Иначе старая версия приложения после отката могла бы молча заменить непонятные ей новые данные.
Если новой записи нет, код читает известный старый ключ. Но и отсутствие старого значения не стоит сразу трактовать как новую установку. Пустое хранилище после первого запуска и отсутствие ожидаемой записи у давнего пользователя могут потребовать разных действий.
Сам перенос сводится к короткому фрагменту. Здесь legacy — уже прочитанная непустая строка, currentKey — новый ключ, а read и write — переданные функции обращения к secure storage.
_decode проверяет, что schema — целое число 2, а credential — непустая строка, и возвращает её. StartupResult сообщает, можно ли продолжать запуск; recoveryRequired запрещает зависимым операциям работать до решения проблемы.
final encoded = jsonEncode({'schema': 2, 'credential': legacy});
await write(currentKey, encoded);
final persisted = await read(currentKey);
if (persisted == null || _decode(persisted) != legacy) {
return const StartupResult(
StartupAction.recoveryRequired,
'verification_failed',
);
}
Исключение чтения или записи в окружающем коде также приводит к остановке, а не к удалению данных.
После write код читает записанное обратно и сравнивает с исходником. Без этого он подтвердил бы только завершение вызова, но не совпадение перенесённого значения.
На этом можно было бы закончить пример, если бы приложение никогда не запускалось повторно. При разборе логики обнаружился ещё один случай: в новую запись попало другое значение, сравнение его отклонило, но сама запись осталась. После перезапуска её JSON разбирается, номер схемы подходит, поле на месте. Код, который проверяет только формат, больше не заметит неудачу предыдущего переноса.
Поэтому, пока исходник сохраняется, стенд сравнивает оба значения и при расхождении останавливается — в том числе после перезапуска. Этот случай проверен вместе с ошибками записи, повреждённым форматом и повторными вызовами запуска: все 16 проверок прикладной логики прошли. На Android также проверены успешный перенос формата и чтение после перезапуска процесса.
Теперь возникает закономерное возражение: а если после миграции токен обновился? Старое и новое значения разойдутся уже по нормальной причине, а защита всё равно остановит приложение. Поэтому прежде, чем разрешать обычное обновление сессии, нужно завершить миграцию и определить, когда удаляется прежняя копия. Бессрочно держать второй секрет «на всякий случай» — не способ избежать этого решения.
Стенд до этой части не доходит: в нём проверены перенос одной записи и обнаружение конфликта. Копировать его целиком в рабочее управление сессиями без правила завершения миграции нельзя. И запись схемы вместе со значением не даёт гарантии на случай отключения питания — таких испытаний не было.
Когда одной записи уже мало
Если связаны несколько ключей, успешного переноса первого недостаточно. Например, новый формат сессии уже записан, а связанные параметры ещё остались старыми. Приложению нужен компонент, который знает последовательность переходов, проверяет результат каждого шага и не допускает обычную работу с наполовину изменённым набором. Такой компонент обычно называют координатором миграций.
Для него можно вести журнал: какой переход начат, какие данные подготовлены, какой набор сейчас активен. Тогда после прерывания есть возможность продолжить работу с известной точки. Но запись «начато» не равна «готово», а сам журнал не превращает несколько операций secure storage в единую транзакцию.
Сложность здесь появляется не из-за ещё одного класса. Каждый шаг придётся уметь повторить или продолжить, не повредив уже перенесённое, а параллельные обращения — не пустить в середину работы. Понадобятся и правила отката. Такой координатор в опыте не реализован: для одной строки это лишний механизм, но при переносе связанных данных от этих вопросов уже не отмахнуться.
Есть и менее очевидный вопрос: где лежит сам журнал? Если внутри защищённого хранилища, сначала придётся научиться его читать. Если снаружи — отметку могли восстановить отдельно от данных, она могла устареть или измениться. Несекретную подсказку о состоянии хранить там можно. Удалять по ней старую копию, не проверив данные, — уже нельзя.
И здесь возвращается тот же пользователь, который пропустил несколько релизов. Если приложение умеет переходить только с предыдущего формата, журнал ему не поможет. Нужна либо цепочка преобразований от сохранившейся версии, либо явный ответ, что такой переход больше не поддерживается, с допустимым для этих данных восстановлением.
Если прочитать старые данные уже нечем
Собственная схема начинает помогать после того, как данные удалось получить. Если новая библиотека лишилась старого механизма чтения, дополнительная проверка номера проблему не исправит.
Один вариант — на время сохранить совместимое чтение в той сборке, которую реально получит пользователь. Это может означать более долгую жизнь промежуточной зависимости или отдельную поддерживаемую реализацию. Но не простое подключение v9 и v11 одного Dart-пакета одновременно: обычное разрешение зависимостей выбирает одну версию пакета.
Такой старый код останется частью продукта: его придётся сопровождать, проверять доступ к прежним ключам, оценивать безопасность. И заранее решать, при каких условиях его наконец можно удалить. Для приложения с незаменимыми локальными данными эта работа может быть оправданна. В другом случае стоимость отдельной реализации окажется выше пользы от немедленного обновления.
Если же старый токен можно заменить, иногда разумнее предложить повторный вход и получить новый. Только это должен быть предусмотренный сценарий: с понятным объяснением пользователю, без бесконечной попытки восстановить ту же нечитаемую сессию.
Если же в хранилище был единственный ключ к локальным данным, новый вход на сервер сам по себе ничего не расшифрует. Здесь нельзя объявить сброс приемлемым только потому, что после него приложение снова запускается.
Пакет видит строки и ключи. Ценность этих строк знает приложение.
Что в итоге решать команде
После всех этих условий легко снова вернуться к «лучше не трогать». Но тогда что делать с исправлениями, ради которых обновление вообще рассматривалось? В v11.2.0 исправлены, например, биометрические сценарии и область действия deleteAll. Если приложение сталкивается с такой ошибкой, отсрочка оставляет её у пользователей. А вместе с обновлением меняются и требования: Android-реализации v11.2.0 нужен как минимум API 24. Одного подходящего тестового телефона для решения здесь недостаточно.
Для рассмотренного перехода «промежуточную версию выпускали» — слабый аргумент. Команде нужен ответ для устройства, на котором миграция не исполнялась: чем прочитать старые данные, можно ли их восстановить другим способом и что произойдёт, если нельзя ни то ни другое. Пока такого ответа нет, выпуск несовместимой сборки означает перенос этой проблемы на пользователя.
Поэтапный выпуск помогает ограничить масштаб проблемы, но не заменяет её решение. Даже если большинство активных пользователей уже мигрировало, спустя месяц может вернуться человек со старой установкой. Его отсутствие в недавней статистике не означает отсутствия его данных.
Отложить обновление до проверки пропущенных релизов — понятное решение. Оставить совместимое чтение до подготовки восстановления — тоже. У обоих есть работа, после которой решение можно пересмотреть. У «не трогать, пока работает» такой точки обычно нет: приложение остаётся на старой версии просто потому, что осталось на ней в прошлый раз.
Опыт ArkTelos Lab закрывает только часть этих вопросов. Он выполнен на одном Android-образе, без биометрии, EncryptedSharedPreferences, облачного восстановления и отключения питания. Искусственные ошибки в Dart проверяют прикладную логику, а не поведение устройства при реальном сбое записи. Переносить результат на все варианты flutter_secure_storage оснований нет.
Но одну подмену этот небольшой пример показывает вполне отчётливо: история релизов команды не равна истории миграций на устройстве. Правильная последовательность версий в репозитории не избавляет приложение от необходимости разобраться с тем, что действительно хранится у пользователя.
Обновление можно выпускать, когда команда умеет объяснить не только пользу новой зависимости, но и путь к ней со старых данных. А если пока не умеет — это и есть конкретная работа, ради которой стоит отложить релиз. Не ради того, чтобы ещё какое-то время видеть знакомый номер в pubspec.yaml.
Другие инженерные разборы — в ArkTelos Lab. Об экосистеме и её решениях — на официальном сайте ArkTelos.
Telegram: ArkTelos Lab RU | ArkTelos Lab EN | ArkTelos RU | ArkTelos EN.
Границы результата
- Один Android-образ API 35 arm64; без биометрии, Tink/EncryptedSharedPreferences, облачного восстановления, отключения питания, downgrade и многопроцессности. Завершение миграции и удаление старой копии перед обычным обновлением сессии не реализованы.
CODE / DATA / AGENTS
Связанные эксперименты
storage-upgrade-trialПроверить реальные пути обновления Android secure storage и перенос одной записи приложения.
Один Android-образ API 35 arm64; без биометрии, Tink/EncryptedSharedPreferences, облачного восстановления, отключения питания, downgrade и многопроцессности. Завершение миграции и удаление старой копии перед обычным обновлением сессии не реализованы.
Открыть паспорт эксперимента →Версия в Telegram