ATL-2026-013engineering_noteexperimental

Upgrade a dependency or stay on the old version: what is the team actually deciding?

Upgrading flutter_secure_storage on Android: why an intermediate release does not guarantee migration, what the app must check, and when deferral is justified.

Published
September 30, 2026
Verified
September 28, 2026

Upgrade a dependency or stay on the old version: what is the team actually deciding?

The dependency has been updated, a few calls adjusted, and the app builds. Sign-in works on the test device, the session is saved, and restarting the app does not require another password. It looks ready to ship.

But the app was freshly installed on that device. A user has had it for a year, with data written by a very different version of the library. The new build can create its own records. Can it read what the old one left behind? A clean install had nothing old to read.

After an experience like that, the temptation to leave everything alone is understandable. It was working. Yet while the dependency stays put, fixes arrive, platform requirements change, and a familiar one-version upgrade gradually becomes an investigation into several years of someone else's changes. Keeping an older dependency can be reasonable. It helps to know until when, and what makes it suitable in the meantime.

The upgrade question soon extends beyond pubspec.yaml. Which fix does the app actually need? What must change besides API calls? And what happens to the data of someone who did not install every intermediate build alongside the development team?

flutter_secure_storage makes this distinction tangible. The package uses platform facilities to store sensitive values: a token for server requests, for example, or a key to local data. In both cases, the code reads a string by name. Yet a failed read may mean signing in again in one app, while in another it leaves everything encrypted with that key inaccessible.

The intermediate version shipped. Did the migration happen?

The package changelog contains a warning: v11 no longer supports the legacy encryption algorithms or the old EncryptedSharedPreferences backend. Data written using those mechanisms must first be migrated through v10, while it can still read them.

From the repository's perspective, the plan looks straightforward: upgrade the package to v10, release the app, then move to v11. The order is correct. Whether that order was followed on the user's phone is another question.

Suppose the app with v10 shipped a month ago. Some users installed and opened it. Others disabled updates or simply had no reason to use the app that month. Now a build with v11 ships, and someone installs it directly over the old one. The team released an intermediate version, but its code never ran on this device.

Even having that intermediate version installed is no guarantee. Something still has to trigger the migration.

To check precisely this point, a small Android test app was prepared for ArkTelos Lab using three dependency versions: 9.2.4, 10.3.2 and 11.2.0. The old build saved a synthetic string instead of a real token; the next build was installed over it. The application ID and signing certificate stayed the same. Data was not cleared between the steps of an upgrade path: doing so would remove the very thing being tested.

The experiment source and preserved results are available for repeating these transitions. README and RESULTS describe the setup and limitations.

The old build explicitly selected its storage configuration:

const storage = FlutterSecureStorage(
  aOptions: AndroidOptions(
    resetOnError: false,
    keyCipherAlgorithm: KeyCipherAlgorithm.RSA_ECB_PKCS1Padding,
    storageCipherAlgorithm: StorageCipherAlgorithm.AES_CBC_PKCS7Padding,
  ),
);

The algorithms are explicit so the experiment can be repeated with the same legacy configuration. This is not a recommendation for a new app: v11 has removed support for exactly this configuration.

The intermediate build using 10.3.2 enabled migration on an algorithm change and backups during migration:

const storage = FlutterSecureStorage(
  aOptions: AndroidOptions(
    resetOnError: false,
    migrateOnAlgorithmChange: true,
    migrateWithBackup: true,
  ),
);

In all three builds, resetOnError: false disabled automatic resets on error. Otherwise, an investigation into compatibility might quietly become an investigation into how the app creates empty storage after a failed read.

The following paths were tested on Android 15, API 35:

Upgrade path What happened in 11.2.0
Clean install of 11.2.0 A new record could be saved and read
9.2.4 → 10.3.2 with storage access → 11.2.0 The previous value was read unchanged
9.2.4 → directly to 11.2.0 The previous record was identified as unreadable
10.3.2 installed, but the app not launched The previous record was identified as unreadable
The 10.3.2 build's screen opened, but storage was not accessed The previous record was identified as unreadable

The last row is easily missed behind “but the app did launch”. The screen did appear. In this test app, however, storage access triggered migration, and that access never happened. A record of the intermediate app being launched therefore does not establish that the data was migrated either.

In the three unsuccessful transitions, the check returned legacyDataUnreadable with the reason missingAlgorithmMarkers: the record lacked metadata expected by the new implementation. It also reported willDiscard=false. Calling this data loss would be inaccurate: the check said this version could not read the data, not that the data had been destroyed. After that response, the application did not attempt a regular read.

Which version should the app check?

An application-owned storageVersion seems a natural next step: read the number, migrate if needed, then write the new number. That is a reasonable direction. The problem is that “version” currently refers to three different things.

The package version is known from the build and its locked dependencies. It tells the team which code is running. It does not tell the team what that code will find on the device.

The storage mechanism covers data locations, algorithms, keys and metadata used by the library. Writing 11 into preferences does not restore support for an old algorithm in the new package.

The application data format version describes the contents of a record. A session might previously have been a single string and now be a structure containing a schema number and a credential field. This transition belongs to the application: the library does not know why the string needs to become a structure or what must survive that change.

Even an application-owned schema number can be written too early:

await preferences.setInt('storageVersion', 2);
await migrateSession();

This is an intentionally incorrect example, not code from the test app. The first line completes; the second does not. At the next launch, the app sees the new version even though migration never finished. Reversing the lines helps, but leaves the opposite situation: the data has been migrated while the number is still old. Can the same migration run again?

The implementation depends on that answer. A version number helps select an action; it does not, by itself, prove that the action succeeded.

Check storage access before the application format

The check must happen before a background task tries to refresh a token or a screen tries to restore a session. Otherwise, the app reaches the problematic data before its own protection does.

Version 11 provides checkUpgradeStatus(). Its purpose is explained by the package maintainer: detect upgrade problems before regular storage access. The method itself does not migrate old data or recover keys.

The application needs an actionable result: reading is allowed, a problem has already been found, or the check has not established what is happening. The test app groups these outcomes in the StorageReadiness enum and maps the package response as follows:

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,
};

The first branch has a less obvious reason. In the package interface used here, unsupported diagnostics can return state=ok together with unsupportedPlatform. Checking only ok would permit access where the storage state had not actually been checked.

The name readable itself means only permission to proceed to the next step. It does not promise that every record will decrypt successfully or contain values the application accepts. Actual reads still have to establish that.

With unknown, the cause still needs investigation. Reading might require authentication involving the user. If diagnostics are unsupported on a platform, another check is needed. Neither response is grounds for clearing storage. The test app stops further access; authentication screens and a complete recovery flow are not implemented.

Keep the schema number with the data

One small record does not require starting with a universal migration engine. In the example, the old value lives under trial.session.v1, and the new one under trial.session.v2. The new record contains both its schema number and its value:

{
  "schema": 2,
  "credential": "not-a-real-token:arktelos-storage-trial"
}

Here, JSON is the record content passed to secure storage. This is not a suggestion to put a plaintext token in ordinary preferences. A separate key avoids overwriting the only source copy before the transfer has been verified.

The code first looks for the new record. If it exists, it checks the schema and required field. An unknown future schema or malformed JSON is not treated as an absent session. Otherwise, after a downgrade, an older app could silently replace newer data it does not understand.

If there is no new record, the code reads the known legacy key. But the absence of that value should not immediately be interpreted as a fresh install either. Empty storage on a first launch and a missing expected record for a long-standing user may require different actions.

The transfer itself comes down to a short fragment. Here, legacy is a non-empty string already read from storage, currentKey is the new key, and read and write are supplied functions for accessing secure storage.

_decode checks that schema is the integer 2 and credential is a non-empty string, then returns that string. StartupResult reports whether startup may continue; recoveryRequired prevents dependent operations from proceeding until the problem is resolved.

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',
  );
}

A read or write exception in the surrounding code also stops the process rather than deleting data.

After write, the code reads the value back and compares it with the source. Without this step, it would confirm only that the call completed, not that the transferred value matched.

The example could end here if the app never started again. Reviewing the logic exposed another case: a different value was written to the new record, comparison rejected it, but the record remained. After a restart, its JSON parses, the schema number is correct, and the field is present. Code that checks only the format will no longer detect the failed transfer.

While the source is retained, the test app therefore compares both values and stops if they differ, including after a restart. This case was checked alongside write failures, invalid formats and repeated startup calls: all 16 application-logic checks passed. Successful format migration and reading after a process restart were also tested on Android.

There is an obvious objection now: what if the token changes after migration? The old and new values will differ for a legitimate reason, yet the protection will still stop the app. Before ordinary session updates are allowed, migration must therefore be completed, with a rule for when the previous copy is removed. Keeping a second secret indefinitely “just in case” does not avoid that decision.

The test app does not implement this part: it checks the transfer of one record and detection of a conflict. Its code cannot be copied wholesale into production session management without a migration-completion rule. Nor does storing the schema with the value guarantee safety during a power failure: those tests were not performed.

When one record is no longer enough

When several keys are related, successfully transferring the first is insufficient. The new session format might be written while related settings remain in the old format. The application needs a component that knows the sequence of transitions, verifies each step, and prevents ordinary operation on a half-migrated set. Such a component is usually called a migration coordinator.

It can keep a journal recording which transition started, which data has been prepared, and which set is currently active. After an interruption, work can then resume from a known point. But “started” is not “finished”, and a journal does not turn several secure-storage operations into one transaction.

The complexity does not come from adding another class. Every step must be repeatable or resumable without damaging data already migrated, and concurrent callers must be kept out while migration runs. Rollback rules are needed too. The experiment does not implement such a coordinator: it is unnecessary machinery for one string, but these questions cannot be ignored when related data is being moved.

There is a less obvious question too: where does the journal live? Inside secure storage, it first requires a working reader. Outside, its marker may have been restored separately from the data, become stale, or been changed. Keeping a non-secret state hint there can be useful. Deleting an old copy on that hint alone, without checking the data, is not acceptable.

The user who skipped several releases returns here as well. If the app can migrate only from the immediately preceding format, a journal will not help. It needs either a chain of transformations from the version actually present, or an explicit unsupported-transition outcome with a recovery path acceptable for those data.

When nothing can read the old data

An application-owned schema starts helping after the data has been obtained. If the new library has lost the old reading mechanism, another version check cannot fix it.

One option is to retain compatible reading temporarily in the build the user will actually receive. That may mean keeping the intermediate dependency longer or maintaining a separate implementation. It does not mean simply adding v9 and v11 of the same Dart package at once: ordinary dependency resolution selects one version of a package.

That old code remains part of the product. It must be maintained, access to existing keys tested, and security assessed. The team also needs to decide in advance under which conditions it can finally be removed. For an app with irreplaceable local data, the work may be justified. Elsewhere, the cost of a separate implementation may outweigh the benefit of upgrading immediately.

If the old token can be replaced, asking the user to sign in again and obtaining a new one may be more reasonable. This must be a deliberate flow, with a clear explanation rather than an endless attempt to restore the same unreadable session.

If storage held the only key to local data, however, signing in to the server will not decrypt those data. A reset cannot be called acceptable merely because the app starts again afterwards.

The package sees strings and keys. The application knows what those strings are worth.

What the team actually has to decide

After all these conditions, “better leave it alone” becomes tempting again. But what about the fixes that prompted the upgrade in the first place? Version 11.2.0 fixes, for example, biometric scenarios and the scope of deleteAll. If the app encounters one of those bugs, postponing the upgrade leaves it with users. Requirements change with the upgrade too: the Android implementation in v11.2.0 requires at least API 24. One suitable test phone is not enough to make this decision.

For the transition examined here, “we released an intermediate version” is a weak argument. The team needs an answer for a device where migration never ran: what can read the old data, whether those data can be recovered another way, and what happens if neither is possible. Without that answer, shipping an incompatible build passes the problem to the user.

A staged rollout helps limit the scale of a problem; it does not replace a solution. Even if most active users have migrated, someone may return a month later with an old installation. Their absence from recent statistics does not mean their data are absent.

Deferring an upgrade until skipped releases have been tested is a clear decision. Retaining compatible reading until recovery is ready is another. Both name work after which the decision can be reconsidered. “Do not touch it while it works” usually offers no such point: the app stays on an old version because it stayed there last time.

The ArkTelos Lab experiment answers only some of these questions. It ran on one Android system image, without biometrics, EncryptedSharedPreferences, cloud restore or power-failure testing. Simulated Dart failures check application logic, not device behaviour during a real write failure. There is no basis for extending the result to every flutter_secure_storage configuration.

But this small example exposes one mistaken assumption quite clearly: the team's release history is not the device's migration history. A correct sequence of versions in the repository does not remove the application's need to deal with what the user actually has stored.

An upgrade is ready to ship when the team can explain not only the benefit of the new dependency, but also how existing data will reach it. If it cannot yet do that, this is the concrete work worth postponing a release for. Not the chance to keep seeing a familiar number in pubspec.yaml a little longer.


More engineering articles are available at ArkTelos Lab. For the ecosystem and its tools, visit the official ArkTelos website.

Telegram: ArkTelos Lab RU | ArkTelos Lab EN | ArkTelos RU | ArkTelos EN.

Result boundaries

  • One Android API 35 arm64 image; no biometrics, Tink/EncryptedSharedPreferences, cloud restore, power failure, downgrade or multiprocess testing. Migration completion and old-copy cleanup before normal session updates are not implemented.

CODE / DATA / AGENTS

Related experiments

experimentstorage-upgrade-trial

Check Android secure-storage upgrade paths and migration of one application record.

One Android API 35 arm64 image; no biometrics, Tink/EncryptedSharedPreferences, cloud restore, power failure, downgrade or multiprocess testing. Migration completion and old-copy cleanup before normal session updates are not implemented.

Open the experiment record →

Telegram edition

Read in ArkTelos Lab

Read in ArkTelos Lab ↗

ARKTELOS CHANNELS

News and engineering material, without mixing languages.

The main channels cover ArkTelos development. ArkTelos Lab publishes architecture analysis, experiments, and reproducible research.