Infrastructure SDKs
storage_sdk Free
- Tier
- Freeversion 3.1.4
- Command
archipelago generate storage_sdk- Generates
infrastructure/storage_sdk_apiinfrastructure/storage_sdk_shared_preferences
- Generates first
- test_prereqwhen needed and missing, so there is nothing to install first
Local key-value storage abstraction (
StorageProvider/AppStorage) with three independent axes — namespace, secure, and scope — backed bySharedPreferencesAsyncandFlutterSecureStorage, registered once, globally, via@InjectableInit.microPackage().
Variables
| Variable | Type | Default | Description |
|---|---|---|---|
| androidPreferencesBackend | enum (dataStore, sharedPreferences) | dataStore | Android backend for non-secure storage |
| iosKeychainAccessibility | enum (passcode, unlocked, unlocked_this_device, first_unlock, first_unlock_this_device) | first_unlock | iOS/macOS Keychain accessibility level for secure storage |
| iosUseSecureEnclave | boolean | true | Use Secure Enclave-backed keys for secure storage on iOS/macOS |
Why this brick exists
Several feature bricks (biometric_auth_sdk, feedback_sdk, rating_review_sdk, ai_chat_sdk, auth_sdk) need local persistence, some of it secret (auth tokens) and some of it not. Each registering its own storage instance in the app's global GetIt container crashes at boot the moment two of them are installed together — GetIt throws on duplicate type registration. storage_sdk centralizes registration behind one type (StorageProvider), registered exactly once, so the collision is structurally impossible instead of order-dependent.
This brick also unifies what used to be two separate SDKs — storage_sdk (namespaced, non-secure only) and secure_storage_sdk (secure, flat only, retired) — behind one contract, so a feature choosing secure: true doesn't need a second SDK with a completely different, non-namespaced, non-scoped API just because it flipped one axis.
The three axes
StorageProvider.forNamespace takes three independent axes:
- namespace (
String) — isolates one feature's keys from every other's. Isolation is key-prefix based ("$namespace::$key") within one shared backend instance per axis, not by separate OS-level files — Android's per-file option only works outside the modern DataStore backend, and iOS's per-suite option requires a manually provisioned App Group entitlement no generator can automate. - secure (
bool, defaultfalse) —trueroutes throughFlutterSecureStorage(Keychain on iOS, with Secure Enclave-backed keys by default; Keystore-backed encryption on Android);falseroutes throughSharedPreferencesAsync(DataStore backend by default). Never changes based onscope. - scope (
StorageScope, defaultapplication) —StorageScope.userregisters the namespace to be swept byclearAllUserScoped(), called once by a central logout-clear coordinator (never by individual features).StorageScope.applicationnamespaces are never auto-cleared. Pure bookkeeping — never changes which backend handles the namespace.
// Non-secret, survives logout (e.g. onboarding-seen flag).
final settings = storageProvider.forNamespace('settings');
// Secret, cleared automatically on logout (e.g. auth tokens).
final tokens = storageProvider.forNamespace(
'auth',
secure: true,
scope: StorageScope.user,
);Per-namespace secure-storage hardening
secure: true alone gives every namespace the same app-wide protection level (iosKeychainAccessibility + iosUseSecureEnclave, configured once at generation time). That's the right default for short-lived bearer tokens, but it's not enough for a namespace holding long-lived encryption-key material — e.g. a SQLCipher/local-database encryption key — which needs to be device-bound and excluded from backups: restoring an encrypted device backup onto a different device should never hand that device a working key.
Pass hardening (a SecureStorageHardening, from storage_sdk_api) to request stronger or different protection for one namespace, without changing anything about the app-wide default or any other secure: true namespace:
final databaseKey = storageProvider.forNamespace(
'database-dek',
secure: true,
scope: StorageScope.application,
hardening: const SecureStorageHardening(
accessibility: SecureAccessibility.firstUnlockThisDevice,
androidStorageNamespace: 'database_dek',
),
);- accessibility (
SecureAccessibility) — vendor-neutral mirror of the iOS/macOS Keychain accessibility axis (passcode,unlocked,unlockedThisDevice,firstUnlock,firstUnlockThisDevice), mapped ontoflutter_secure_storage'sKeychainAccessibilityfor bothiOptionsandmOptions.storage_sdk_apinever depends onflutter_secure_storage, so this enum has its own (camelCase) member names rather than reusing the vendor's. - androidStorageNamespace (
String?) — a distinct Android Keystore storage namespace, isolating this entry's wrapping key/alias from every other secure namespace that doesn't request one.nulluses the app-wide default (nostorageNamespace, i.e.flutter_secure_storage's own default).
Every namespace requesting the same hardening value (by ==, not identity) shares one underlying FlutterSecureStorage instance — hardening: null (the default) always resolves to the same app-wide instance every unhardened secure: true namespace already used, so nothing changes for callers that don't opt in. hardening is only meaningful when secure: true; passing it with secure: false, or requesting the same namespace with two different non-null hardening values across calls, fails an assertion instead of silently doing the wrong thing.
Logout-clear coordination
clearAllUserScoped() sweeps every scope: user namespace across every installed feature, regardless of secure. It's wired up by a small coordinating Initializer that lives in the generated app layer — it resolves both AuthSDK and StorageProvider from the service locator and listens for AuthLogoutEvent. Neither auth_impl nor storage_sdk_shared_preferences ever imports the other; avoiding that direct dependency between two independent SDKs is deliberate. See auth_sdk's _AuthStorageClearInitializer for the reference implementation.
Usage
Interactive
archipelago generate storage_sdkNon-interactive (CI)
archipelago generate storage_sdk --config my_config.jsonConfig Template
{
"androidPreferencesBackend": "dataStore",
"iosKeychainAccessibility": "first_unlock",
"iosUseSecureEnclave": true
}Most consumers never need to generate this brick manually — any feature brick with a hard dependency on storage_sdk self-generates it if it isn't already installed.
Generated Structure
infrastructure/
├── storage_sdk_api/
│ ├── lib/
│ │ ├── storage_sdk_api.dart
│ │ └── src/
│ │ ├── app_storage.dart # AppStorage abstract interface
│ │ ├── secure_storage_hardening.dart # SecureStorageHardening + SecureAccessibility
│ │ └── storage_provider.dart # StorageProvider + StorageScope
│ ├── pubspec.yaml
│ └── README.md
└── storage_sdk_shared_preferences/
├── lib/
│ ├── storage_sdk_shared_preferences.dart
│ ├── testing.dart # test-only entry point
│ └── src/
│ ├── shared_preferences_app_storage.dart # non-secure AppStorage
│ ├── secure_app_storage.dart # secure AppStorage
│ ├── shared_preferences_storage_provider.dart
│ ├── testing/ # StorageSdkSharedPreferencesTestPrerequisite
│ └── di/
│ ├── injector.dart
│ ├── injector.module.dart
│ └── register_module.dart
├── test/
├── pubspec.yaml
└── README.mdAPI
enum StorageScope { user, application }
enum SecureAccessibility {
passcode,
unlocked,
unlockedThisDevice,
firstUnlock,
firstUnlockThisDevice,
}
class SecureStorageHardening {
const SecureStorageHardening({
required this.accessibility,
this.androidStorageNamespace,
});
final SecureAccessibility accessibility;
final String? androidStorageNamespace;
}
abstract interface class StorageProvider {
AppStorage forNamespace(
String namespace, {
bool secure = false,
StorageScope scope = StorageScope.application,
SecureStorageHardening? hardening,
});
Future<void> clearAllUserScoped();
}
abstract interface class AppStorage {
Future<String?> getString(String key);
Future<void> setString(String key, String value);
Future<bool?> getBool(String key);
Future<void> setBool(String key, bool value);
Future<int?> getInt(String key);
Future<void> setInt(String key, int value);
Future<double?> getDouble(String key);
Future<void> setDouble(String key, double value);
Future<List<String>?> getStringList(String key);
Future<void> setStringList(String key, List<String> value);
Future<void> remove(String key);
Future<void> clear();
Future<Set<String>> getKeys();
}Key Features
- Zero manual wiring —
StorageSdkSharedPreferencesPackageModuleregistersStorageProvideras a global@lazySingleton, discovered automatically bymonorepo_toolkit generate host-registrythe same way every other infrastructure SDK is - Standalone-generatable — any consumer brick self-generates
storage_sdkif it isn't already installed, soarchipelago generate auth_sdk(or any other consumer) works into a bare workspace with no separate manual prerequisite step - Secure Enclave by default —
iosUseSecureEnclavedefaults totrue; gracefully falls back to standard Keychain storage on devices/simulators without a Secure Enclave - Per-namespace hardening —
hardening: SecureStorageHardening(...)lets onesecure: truenamespace opt into stronger, device-bound, non-backed-up protection (and its own Android Keystore namespace) for long-lived key material, without touching the app-wide default - Vendor-agnostic contract —
storage_sdk_apihas zero external dependencies (onlymeta), so feature packages can depend on it without pulling inshared_preferences/flutter_secure_storagetransitively
Testing
SharedPreferencesStorageProvider builds SharedPreferencesAsync in its initializer list. That constructor throws when no platform is registered, which is the normal state under plain flutter test. ai_chat_sdk's global module also reads a non-secure namespace during initializeInjector(), so the composition root fails without a fake.
The package ships the fake as a TestPrerequisite (see test_prereq) and declares it in its own pubspec.yaml:
archipelago:
test_prerequisites:
- class: StorageSdkSharedPreferencesTestPrerequisite
reason: >-
SharedPreferencesStorageProvider's initializer list constructs
SharedPreferencesAsync, which throws with no platform registered;
AiChatImplGlobalModule.init() resolves StorageProvider and reads it
during initializeInjector().The composition-root harness generator reads this declaration. Until that harness ships, install it yourself:
import 'package:storage_sdk_shared_preferences/testing.dart';
setUpAll(() async {
await StorageSdkSharedPreferencesTestPrerequisite().install();
await initializeInjector();
});install() sets SharedPreferencesAsyncPlatform.instance to an empty in-memory store, once per process. A second call does nothing, so data that earlier tests wrote stays. It does not fake FlutterSecureStorage: a test that uses secure: true namespaces still needs its own fake. Import testing.dart only from test code.
Not the same scope as a local database
storage_sdk is preference/key-value storage only — namespace isolation, optional encryption, optional logout-scoping. It is explicitly not a replacement for a full local database (Hive, Isar, sqflite/Drift, etc.); those have different lifecycle and query requirements and are out of scope for this brick.
Dependencies
storage_sdk_shared_preferencesdepends onshared_preferences: ^2.5.5,shared_preferences_android(for theandroidPreferencesBackendknob),flutter_secure_storage: ^10.3.1,meta,shared_preferences_platform_interface(for the test prerequisite's in-memory store), andtest_prereq.pre_genself-generatestest_prereqifutilities/test_prereqis missingstorage_sdk_apihas no external dependencies beyondmeta
Migrating from secure_storage_sdk
secure_storage_sdk is retired — new generations never offer it. If your project already generated it, that code keeps compiling and working unchanged (it's an independent copy with no live dependency back on this brick). To consolidate onto the unified SDK by hand, replace SecureStorage calls with storageProvider.forNamespace(namespace, secure: true, scope: StorageScope.user) and remove the secure_storage_sdk_api/ secure_storage_sdk_impl dependencies.
Changelog
Run archipelago brick info storage_sdk to see the latest published version. Enterprise customers can read the full CHANGELOG.md in the brick source repository.