Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,39 @@
## 6.0.0-dev.5

* `BiometricStorageFile.dispose()` forgets a store's initialization without
touching its content, so the next `getStorage` applies a fresh
`StorageFileInitOptions`. Until now the options for a name were resolved once
and then ignored for the lifetime of the process, so a store could not be
reconfigured at runtime — changing, say, an authentication validity duration
meant restarting the app. Thanks to @luckyrat, who identified the gap and
proposed an API for it in #138 (see also #75 and #137); this implements the
standalone `dispose()` rather than that PR's delete-coupled form, so
reconfiguring no longer costs you the stored value. Call `delete()` first if
you want both.

Returns `true` when there was an initialization to forget and `false` when
there was not, so disposing twice is safe. Android previously raised
`NoSuchStorage` on a repeat, the only backend where that was an error.

The method was reachable natively before this, and did not work: Linux had no
handler at all, so the call reached Dart as a `MissingPluginException`, and
iOS/macOS answered `true` from a no-op that never cleared the entry — so
re-initializing silently kept the original configuration while reporting
success at every step. Both are fixed, and the store's content is deliberately
left alone on every platform.

A disposed `BiometricStorageFile` now refuses `read`, `write` and `delete`
with a `StateError` — not `dispose` itself, which stays idempotent. The
backends do not agree on their own: Android and iOS/macOS resolve those calls
against a registry and fail once the entry is gone, while Linux, Windows and
web answer from the name alone and would carry on working. The guard sits in
Dart so the answer is the same everywhere, rather than being a mistake you
make on one platform and discover on another.

* **Breaking for platform implementations only.** `dispose` is a new member on
the `BiometricStorage` platform interface, so a third-party implementation
that extends it must add one. No change for callers of the package.

## 6.0.0-dev.4

* `BiometricStorageException` carries a `code`. It previously held only a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,14 +244,17 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
result.success(true)
}

"dispose" -> storageFiles.remove(getName())?.apply {
dispose()
result.success(true)
} ?: throw MethodCallException(
"NoSuchStorage",
"Tried to dispose non existing storage.",
null
)
// Reports whether there was an initialization to forget rather
// than throwing when there was not, so that disposing twice is
// safe and every platform answers alike. It used to raise
// NoSuchStorage, which made Android the only backend where a
// repeat dispose was an error. The stored content is left
// alone: this reconfigures, it does not erase.
"dispose" -> {
val existing = storageFiles.remove(getName())
existing?.dispose()
result.success(existing != null)
}

"read" -> withStorage {
if (exists()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,19 @@ class BiometricStorageImpl {
}
}
} else if ("dispose" == call.method) {
// nothing to dispose
result(true)
// This used to be a no-op replying `true`, which made the whole feature
// silently useless here: `stores` kept the entry, so the next `init`
// took the short circuit above, discarded the freshly passed
// InitOptions and replied `false` — reporting success at every step
// while leaving the file on its original configuration. Removing the
// entry is what lets a later init apply new options.
//
// The keychain item itself is deliberately untouched; this reconfigures
// rather than erases. Replies whether there was anything to forget, so
// a repeat dispose is not an error.
requiredArg("name") { (name: String) in
result(stores.removeValue(forKey: name) != nil)
}
} else if ("read" == call.method) {
requiredArg("name") { name in
requiredArg("iosPromptInfo") { promptInfo in
Expand Down
106 changes: 106 additions & 0 deletions example/integration_test/storage_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ void main() {
return file;
}

/// A name for a test that disposes the store itself.
///
/// [freshStore] cannot be used for those: it holds the file it handed out
/// and deletes through it, and a disposed handle refuses every operation.
/// Re-acquiring first is what makes the cleanup work whether or not the test
/// left the name disposed.
String disposableName(String label) {
final name =
'integration_${label}_${DateTime.now().microsecondsSinceEpoch}';
addTearDown(() async {
final file = await storage.getStorage(name, options: options);
await file.delete();
});
return name;
}

testWidgets('a value written can be read back, and is gone after delete', (
tester,
) async {
Expand Down Expand Up @@ -132,4 +148,94 @@ void main() {
);
}
});

testWidgets('dispose lets a later getStorage initialize the name again', (
tester,
) async {
// The load-bearing test for dispose, and the one that fails against every
// backend as it stood before this change: Linux had no `dispose` handler
// at all, so the call raised MissingPluginException, and darwin replied
// `true` from a no-op that never cleared its `stores` entry.
//
// forceInit is what makes the effect observable through the public API.
// It means "assert this name is not initialized", so it succeeds only if
// dispose really did forget the name — a dispose that silently does
// nothing leaves this throwing alreadyInitialized.
final name = disposableName('dispose_reinit');
final file = await storage.getStorage(name, options: options);

expect(await file.dispose(), isTrue, reason: 'there was one to forget');

await expectLater(
storage.getStorage(name, options: options, forceInit: true),
completes,
);
});

testWidgets('dispose keeps the stored content', (tester) async {
// The contract that separates dispose from delete: it reconfigures, it
// does not erase. Worth pinning because every backend implements it by
// dropping a registry entry, and reaching one line further to remove the
// secret would be an easy and very expensive mistake.
final name = disposableName('dispose_keeps');
final file = await storage.getStorage(name, options: options);
await file.write('survives');

expect(await file.dispose(), isTrue);

final reopened = await storage.getStorage(name, options: options);
expect(await reopened.read(), 'survives');
});

testWidgets('disposing twice is not an error', (tester) async {
// Android used to throw NoSuchStorage on the second call, which made it
// the only backend where a repeat dispose failed. The bool distinguishes
// "forgot one" from "there was none" without anybody having to catch.
final name = disposableName('dispose_twice');
final file = await storage.getStorage(name, options: options);

expect(await file.dispose(), isTrue);
expect(await file.dispose(), isFalse, reason: 'nothing left to forget');
});

testWidgets('dispose is scoped to the name, not to the handle', (
tester,
) async {
// Every handle is a view onto one name-keyed initialization, so a spent
// handle can still forget an initialization made after it. Pinned because
// the tempting optimization — short-circuiting a repeat dispose on the
// local flag instead of asking the platform — would quietly turn dispose
// from name-scoped into handle-scoped and make this return false.
final name = disposableName('dispose_scope');

final first = await storage.getStorage(name, options: options);
expect(await first.dispose(), isTrue);

// A fresh initialization of the same name, which `first` knows nothing of.
await storage.getStorage(name, options: options);

expect(
await first.dispose(),
isTrue,
reason: 'the spent handle still forgets the name it names',
);
});

testWidgets('a disposed file refuses to be used again', (tester) async {
// The backends do not agree on their own: Android and darwin resolve
// read/write/delete against a registry and fail once the entry is gone,
// while Linux answers from the name alone and would carry on. The guard
// is in Dart so the answer is the same everywhere — otherwise this is a
// mistake you make on one platform and discover on another.
final name = disposableName('dispose_spent');
final file = await storage.getStorage(name, options: options);

expect(await file.dispose(), isTrue);

expect(() => file.read(), throwsStateError);
expect(() => file.write('x'), throwsStateError);
expect(() => file.delete(), throwsStateError);
// Not dispose itself, which stays idempotent.
expect(await file.dispose(), isFalse);
});
}
17 changes: 17 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,23 @@ class StorageActions extends StatelessWidget {
_logger.info('Deleted.');
},
),
ElevatedButton(
child: const Text('dispose'),
onPressed: () async {
_logger.fine('disposing ${storageFile.name}...');
final disposed = await storageFile.dispose();
// The content survives; what is gone is the initialization, so
// the next getStorage for this name applies fresh options. This
// handle is spent — read, write and delete through it throw from
// here on, so the buttons beside this one will fail until the app
// fetches the storage again.
_logger.info(
disposed
? 'Disposed. Init it again to change its options.'
: 'Nothing to dispose; it was not initialized.',
);
},
),
],
);
}
Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ packages:
path: ".."
relative: true
source: path
version: "6.0.0-dev.4"
version: "6.0.0-dev.5"
boolean_selector:
dependency: transitive
description:
Expand Down
86 changes: 80 additions & 6 deletions lib/src/biometric_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,13 @@ abstract class BiometricStorage extends PlatformInterface {

@protected
Future<void> write(String name, String content, PromptInfo promptInfo);

/// Forgets the initialization of [name], leaving its stored content alone.
///
/// Returns `true` when there was an initialization to forget, and `false`
/// when there was not — disposing twice is not an error on any platform.
@protected
Future<bool> dispose(String name, PromptInfo promptInfo);
}

class MethodChannelBiometricStorage extends BiometricStorage {
Expand Down Expand Up @@ -501,6 +508,21 @@ class MethodChannelBiometricStorage extends BiometricStorage {
}),
);

@override
Future<bool> dispose(String name, PromptInfo promptInfo) async {
// The type argument is explicit because the `Future<bool>` return type
// otherwise infers it as non-nullable, and the channel reply is
// `bool?` — an older plugin build that predates this method replies null
// rather than a bool.
final disposed = await _transformErrors<bool?>(
_channel.invokeMethod<bool>('dispose', <String, dynamic>{
'name': name,
..._promptInfoForCurrentPlatform(promptInfo),
}),
);
return disposed ?? false;
}

Map<String, dynamic> _promptInfoForCurrentPlatform(PromptInfo promptInfo) =>
switch (operatingSystem) {
// Don't expose Android configurations to other platforms.
Expand Down Expand Up @@ -564,16 +586,68 @@ class BiometricStorageFile {
final String name;
final PromptInfo defaultPromptInfo;

bool _disposed = false;

/// Enforces the one rule [dispose] documents, which the platforms disagree
/// about on their own: Android and darwin resolve these calls against a
/// registry and fail once the entry is gone, while Linux, Windows and web
/// keep working from the name alone. Left to the backends, the same mistake
/// would throw for some users and silently succeed for others — the shape of
/// bug that gets written on one platform and found on another.
void _checkNotDisposed(String operation) {
if (_disposed) {
throw StateError(
'Tried to $operation a disposed storage file ($name). Fetch a new one '
'from BiometricStorage.getStorage.',
);
}
}

/// read from the secure file and returns the content.
/// Will return `null` if file does not exist.
Future<String?> read({PromptInfo? promptInfo}) =>
_plugin.read(name, promptInfo ?? defaultPromptInfo);
Future<String?> read({PromptInfo? promptInfo}) {
_checkNotDisposed('read');
return _plugin.read(name, promptInfo ?? defaultPromptInfo);
}

/// Write content of this file. Previous value will be overwritten.
Future<void> write(String content, {PromptInfo? promptInfo}) =>
_plugin.write(name, content, promptInfo ?? defaultPromptInfo);
Future<void> write(String content, {PromptInfo? promptInfo}) {
_checkNotDisposed('write');
return _plugin.write(name, content, promptInfo ?? defaultPromptInfo);
}

/// Delete the content of this storage.
Future<void> delete({PromptInfo? promptInfo}) =>
_plugin.delete(name, promptInfo ?? defaultPromptInfo);
Future<void> delete({PromptInfo? promptInfo}) {
_checkNotDisposed('delete');
return _plugin.delete(name, promptInfo ?? defaultPromptInfo);
}

/// Forgets this storage's initialization, **without touching its content**.
///
/// [BiometricStorage.getStorage] resolves the options for a name once and
/// then ignores them, so a store initialized with, say, a five second
/// `authenticationValidityDurationSeconds` keeps it for the lifetime of the
/// process however it is fetched again. Disposing is what makes the next
/// `getStorage` apply a fresh [StorageFileInitOptions] — reconfiguring, not
/// erasing. Use [delete] to remove the content, or call it first if you want
/// both.
///
/// Returns `true` when there was an initialization to forget, and `false`
/// when there was not, so calling it twice is safe.
///
/// **Scoped to the name, not to this object.** Every handle for a name is a
/// view onto one initialization, so disposing through any of them forgets it
/// — including an initialization created after this handle was disposed. Two
/// handles for the same name are not two stores.
///
/// This instance must not be used afterwards; fetch a new one from
/// [BiometricStorage.getStorage]. [read], [write] and [delete] throw a
/// [StateError] once it has been disposed. Deliberately not this method
/// itself, so that disposing twice stays legal. The handle is spent as soon
/// as this is called, whether or not the call goes on to succeed — recovering
/// from a failed dispose means fetching a new one, not reusing this.
Future<bool> dispose({PromptInfo? promptInfo}) {
_disposed = true;
return _plugin.dispose(name, promptInfo ?? defaultPromptInfo);
}
}
14 changes: 12 additions & 2 deletions lib/src/biometric_storage_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,17 @@ class BiometricStoragePluginWeb extends BiometricStorage {
bool forceInit = false,
PromptInfo promptInfo = PromptInfo.defaultValues,
}) async {
if (!_initialized.add(name) && forceInit) {
// Keyed by the prefixed name, which is what every other method on this
// class is handed — BiometricStorageFile carries the prefixed form, so
// tracking the bare one here would leave dispose() unable to find it.
final prefixedName = namePrefix + name;
if (!_initialized.add(prefixedName) && forceInit) {
throw BiometricStorageException(
"A storage file with the name '$name' was already initialized.",
code: BiometricStorageExceptionCode.alreadyInitialized,
);
}
return BiometricStorageFile(this, namePrefix + name, promptInfo);
return BiometricStorageFile(this, prefixedName, promptInfo);
}

@override
Expand All @@ -58,4 +62,10 @@ class BiometricStoragePluginWeb extends BiometricStorage {
Future<void> write(String name, String content, PromptInfo promptInfo) async {
web.window.localStorage.setItem(name, content);
}

@override
Future<bool> dispose(String name, PromptInfo promptInfo) async =>
// [name] arrives prefixed, matching what getStorage recorded. The
// localStorage entry is deliberately left alone.
_initialized.remove(name);
}
Loading
Loading