native_datastore 1.3.2
native_datastore: ^1.3.2 copied to clipboard
A modern Flutter plugin for persistent key-value storage. Uses Android Jetpack DataStore on Android and UserDefaults on iOS. A type-safe, async-first alternative to shared_preferences.
native_datastore #
A modern Flutter plugin for persistent key-value storage, powered by platform-native APIs.
| Platform | Backend |
|---|---|
| Android | Jetpack DataStore (Preferences) |
| iOS | UserDefaults |
Features #
- Jetpack DataStore on Android -- Google's officially recommended replacement for SharedPreferences. Async, non-blocking, coroutine-based I/O.
- 8 supported data types --
String,bool,int,double,List<String>,Uint8List,DateTime, andMap<String, dynamic>. - Drop-in replacement -- Same getter/setter pattern as
shared_preferences. Zero learning curve. - Type-safe platform communication -- Built with Pigeon. No hand-written method channels, no string-based lookups.
- Cross-platform -- One Dart API for Android and iOS.
- Lightweight -- No platform interface layer. A thin, clean bridge to native APIs.
Supported Types #
| Dart Type | Getter | Setter | Nullable |
|---|---|---|---|
String |
getString(key) |
setString(key, value) |
Yes |
bool |
getBool(key) |
setBool(key, value) |
Yes |
int |
getInt(key) |
setInt(key, value) |
Yes |
double |
getDouble(key) |
setDouble(key, value) |
Yes |
List<String> |
getStringList(key) |
setStringList(key, value) |
Yes |
Uint8List |
getBytes(key) |
setBytes(key, value) |
Yes |
DateTime |
getDateTime(key) |
setDateTime(key, value) |
Yes |
Map<String, dynamic> |
getMap(key) |
setMap(key, value) |
Yes |
All getters return
nullwhen the key does not exist.
Why not SharedPreferences? #
If you're using SharedPreferences (or Flutter's shared_preferences which wraps it), you're relying on a legacy Android API that Google has been actively deprecating.
| Concern | SharedPreferences | Jetpack DataStore |
|---|---|---|
| Thread safety | Not safe on UI thread; can cause ANRs | Fully async with Kotlin Coroutines |
| Error handling | Fails silently | Proper error signaling via Flow |
| Runtime exceptions | Parsing errors cause crashes | No runtime exceptions from parsing |
| Disk I/O | Blocking commit() / fire-and-forget apply() |
Consistent async API |
| Type safety | Returns defaults on type mismatch | Typed keys with compile-time safety |
| Consistency | No transactional guarantees | Atomic read-modify-write |
Google's recommendation: "Prefer DataStore over SharedPreferences." -- Android Developers Docs
Getting Started #
1. Install #
Add to your pubspec.yaml:
dependencies:
native_datastore: ^1.2.0
Then run:
flutter pub get
2. Import #
import 'package:native_datastore/native_datastore.dart';
// Also needed for Uint8List:
import 'dart:typed_data';
3. Use #
final datastore = NativeDatastore();
// -- Write --
await datastore.setString('username', 'sudhi');
await datastore.setBool('darkMode', true);
await datastore.setInt('loginCount', 42);
await datastore.setDouble('rating', 4.8);
await datastore.setStringList('tags', ['flutter', 'dart', 'mobile']);
await datastore.setBytes('avatar', Uint8List.fromList([0x89, 0x50, 0x4E]));
await datastore.setDateTime('lastLogin', DateTime.now());
await datastore.setMap('profile', {'name': 'sudhi', 'level': 5});
// -- Read --
final username = await datastore.getString('username'); // "sudhi"
final darkMode = await datastore.getBool('darkMode'); // true
final count = await datastore.getInt('loginCount'); // 42
final rating = await datastore.getDouble('rating'); // 4.8
final tags = await datastore.getStringList('tags'); // ["flutter", "dart", "mobile"]
final avatar = await datastore.getBytes('avatar'); // Uint8List [0x89, 0x50, 0x4E]
final lastLogin = await datastore.getDateTime('lastLogin'); // DateTime (UTC)
final profile = await datastore.getMap('profile'); // {"name": "sudhi", "level": 5}
// -- Query --
final allKeys = await datastore.getKeys(); // ["username", "darkMode", ...]
final allData = await datastore.getAll(); // {username: sudhi, darkMode: true, ...}
final exists = await datastore.containsKey('username'); // true
// -- Delete --
await datastore.remove('username'); // Remove a single key
await datastore.clear(); // Remove all data
Error Handling #
All operations throw NativeDatastoreException on failure. The exception wraps the underlying platform error with context:
try {
await datastore.getString('key');
} on NativeDatastoreException catch (e) {
print(e.message); // Human-readable description
print(e.cause); // Original PlatformException (if any)
}
Empty keys are rejected immediately:
await datastore.getString(''); // Throws NativeDatastoreException: Key must not be empty
Handling Null Values #
All getters return null when the key does not exist. Use null-aware operators or provide defaults:
// With default values
final username = await datastore.getString('username') ?? 'Guest';
final count = await datastore.getInt('loginCount') ?? 0;
// With null checks
final lastLogin = await datastore.getDateTime('lastLogin');
if (lastLogin != null) {
print('Last login: ${lastLogin.toIso8601String()}');
}
// Check before reading
if (await datastore.containsKey('profile')) {
final profile = await datastore.getMap('profile');
// Use profile...
}
API Reference #
Read operations #
| Method | Return Type | Description |
|---|---|---|
getString(key) |
Future<String?> |
Read a string value |
getBool(key) |
Future<bool?> |
Read a boolean value |
getInt(key) |
Future<int?> |
Read an integer value |
getDouble(key) |
Future<double?> |
Read a double value |
getStringList(key) |
Future<List<String>?> |
Read a string list |
getBytes(key) |
Future<Uint8List?> |
Read binary data |
getDateTime(key) |
Future<DateTime?> |
Read a date/time (stored as UTC millis) |
getMap(key) |
Future<Map<String, dynamic>?> |
Read a JSON-compatible map |
Write operations #
| Method | Return Type | Description |
|---|---|---|
setString(key, value) |
Future<void> |
Write a string value |
setBool(key, value) |
Future<void> |
Write a boolean value |
setInt(key, value) |
Future<void> |
Write an integer value |
setDouble(key, value) |
Future<void> |
Write a double value |
setStringList(key, value) |
Future<void> |
Write a string list |
setBytes(key, value) |
Future<void> |
Write binary data (Uint8List) |
setDateTime(key, value) |
Future<void> |
Write a date/time (stored as UTC millis) |
setMap(key, value) |
Future<void> |
Write a JSON-compatible map |
Query operations #
| Method | Return Type | Description |
|---|---|---|
getAll() |
Future<Map<String, Object>> |
Get all key-value pairs |
getKeys() |
Future<List<String>> |
Get all stored keys |
containsKey(key) |
Future<bool> |
Check if a key exists |
Delete operations #
| Method | Return Type | Description |
|---|---|---|
remove(key) |
Future<bool> |
Remove a key (returns true if it existed) |
clear() |
Future<bool> |
Remove all stored data |
Secure Storage #
For secrets (auth tokens, refresh tokens, encryption keys) use the
SecureDatastore class, which encrypts values at rest using platform key
management:
- iOS — Keychain Services (
kSecClassGenericPassword) withkSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Values are readable after the first device unlock following a boot (including from background work), never migrated to a new device or restored from backup. - Android — AES-256-GCM with a key minted in the AndroidKeyStore
(hardware-backed where the device supports it), encrypting values into a
dedicated DataStore file. Each write uses a fresh 96-bit IV. Requires
Android API 23 (Marshmallow) or higher; older devices throw
UnsupportedOperationExceptionfrom the secure API.
import 'package:native_datastore/native_datastore.dart';
final secure = SecureDatastore();
await secure.setString('refresh_token', tokenJwt);
final token = await secure.getString('refresh_token');
await secure.setBytes('symmetric_key', encryptionKey);
final key = await secure.getBytes('symmetric_key');
await secure.remove('refresh_token');
await secure.clear(); // wipes every value in the secure store
Surface is intentionally minimal: String and Uint8List only, plus
remove / clear / getKeys / containsKey. Values are capped at 1 MiB —
callers needing larger payloads should encrypt themselves and store via the
filesystem. Errors flow through the same NativeDatastoreException used by
NativeDatastore.
Storage Details #
Understanding how each type is stored on each platform:
| Dart Type | Android (DataStore) | iOS (UserDefaults) |
|---|---|---|
String |
stringPreferencesKey |
string(forKey:) |
bool |
booleanPreferencesKey |
bool(forKey:) |
int |
longPreferencesKey |
integer(forKey:) |
double |
doublePreferencesKey |
double(forKey:) |
List<String> |
JSON-encoded string | Native string array |
Uint8List |
Base64-encoded string | Native Data |
DateTime |
Long (millis since epoch UTC) |
Int64 (millis since epoch UTC) |
Map<String, dynamic> |
JSON-encoded string | JSON-encoded string |
Note:
DateTimevalues are always stored and retrieved in UTC. If you pass a localDateTime, it is converted to UTC before storage. The returnedDateTimeis always UTC -- use.toLocal()if you need local time.
Migrating from shared_preferences #
The API is intentionally similar -- switching is a one-line change:
// Before (shared_preferences)
final prefs = await SharedPreferences.getInstance();
await prefs.setString('key', 'value');
final value = prefs.getString('key');
// After (native_datastore)
final datastore = NativeDatastore();
await datastore.setString('key', 'value');
final value = await datastore.getString('key');
Key differences:
| shared_preferences | native_datastore | |
|---|---|---|
| Initialization | SharedPreferences.getInstance() |
NativeDatastore() |
| Reads | Synchronous (cached) | Future-based (async) |
| Android backend | SharedPreferences | Jetpack DataStore |
| Extra types | -- | Uint8List, DateTime, Map |
Platform Details #
Android #
- Uses
androidx.datastore:datastore-preferenceswith Kotlin Coroutines - All operations run on
Dispatchers.IO-- never blocks the UI thread - Data location:
data/data/<package>/files/datastore/native_datastore_prefs.preferences_pb - A
ReplaceFileCorruptionHandleris installed so a half-written prefs file (caused by the OS killing the process mid-write) recovers as empty instead of throwingCorruptionExceptionon every subsequent call. - Minimum SDK: 21 (Android 5.0)
iOS #
- Uses
UserDefaults.standard - Keys are namespaced with
native_datastore.prefix to avoid collisions - String lists stored natively as arrays (no JSON encoding overhead)
- Binary data (
Uint8List) stored natively asData - Numeric getters (
getBool,getInt,getDouble,getDateTime) are strict: they returnnullwhen the stored value's underlying type does not match (no silent coercion across types). - Minimum iOS: 12.0
Resilience on aggressive-kill OEMs #
Memory- and battery-optimised Android skins (MIUI, ColorOS, OriginOS,
HyperOS, ColorOS-derived ROMs, Realme UI, FuntouchOS, etc.) regularly
terminate background processes without warning. native_datastore is
built to survive that:
- Atomic writes. Each write goes through Jetpack DataStore, which uses the filesystem's atomic rename to replace the prefs file. A process kill mid-write either commits the full new file or leaves the previous file intact -- never a half-merged record.
- Durable acknowledgements.
await datastore.setX(...)only resolves after the underlyingedit { }block has fsync'd, so a successfully awaited write is on disk before the next line of Dart runs. - Auto-recovery from corruption. If a kill lands inside the fsync window and the file ends up unreadable, the corruption handler replaces it with empty preferences on the next read. The app keeps working -- at worst losing the single write that was interrupted, never bricked.
- Clean detach. When the Flutter engine is destroyed, in-flight coroutines are cancelled before the Pigeon channel is torn down, so the plugin never replies on a dead channel.
⚠️ Multi-process limitation #
The Android backend uses single-process Preferences DataStore. It is
not safe to use this plugin from more than one process at a time. This
matters if your app declares any of the following in
AndroidManifest.xml:
- A
<service>,<receiver>, or<activity>withandroid:process=":foo" - A vendor push SDK (Xiaomi, OPPO, Vivo, Huawei, FCM in a separate process, Tencent TPNS, etc.) that runs in its own process
- A
ContentProviderconfigured with a separate process
Symptoms of multi-process misuse: silently lost writes, "old" values
reappearing after an app upgrade, or in the worst case repeated
CorruptionExceptions (which the corruption handler will swallow by
resetting the file to empty -- i.e., data loss).
Recommended pattern: call native_datastore only from the main
process (the one Flutter runs in). If a secondary process needs to read
config, push a snapshot to it through Intent extras, a ContentProvider,
or a small SharedPreferences file dedicated to that process.
Library Size #
The library is lightweight with a minimal footprint:
| Layer | Hand-written | Generated (Pigeon) | Total |
|---|---|---|---|
| Dart | 358 lines | 463 lines | 821 lines |
| Kotlin (Android) | 301 lines | 509 lines | 810 lines |
| Swift (iOS) | 277 lines | 480 lines | 757 lines |
| Total | 936 lines | 1,452 lines | 2,388 lines |
- ~61% is auto-generated Pigeon code (message channel boilerplate)
- ~39% is hand-written plugin logic (~300 lines per platform)
- Total source size: ~87 KB across 7 files
- No external dependencies beyond Flutter SDK and platform-native APIs
Requirements #
| Dependency | Minimum Version |
|---|---|
| Flutter | 3.3.0 |
| Dart SDK | 3.11.4 |
| Android | API 21 (5.0) |
| iOS | 12.0 |
Contributing #
Contributions are welcome! Please open an issue or submit a pull request.
Releasing #
Releases are automated via .github/workflows/release.yml.
Pushing a tag that matches v* runs tests and, on pass, publishes to pub.dev
(via OIDC — no API key in the repo) and creates a matching GitHub Release.
# 1. Bump pubspec.yaml version (e.g. 1.3.0 → 1.3.1)
# 2. Commit and push the version bump
git commit -am "Release 1.3.1"
git push
# 3. Tag and push the tag — this triggers the workflow
git tag v1.3.1
git push origin v1.3.1
The workflow verifies the pushed tag matches v{pubspec.version} and aborts
if they disagree. If a publish fails partway, either bump the patch version
and push a new tag, or delete the bad tag locally and on the remote and retry:
git tag -d v1.3.1
git push --delete origin v1.3.1
License #
BSD 3-Clause License -- see the LICENSE file for details.