diff --git a/flutter_secure_storage/android/src/main/AndroidManifest.xml b/flutter_secure_storage/android/src/main/AndroidManifest.xml index 64a69a4b..d1612d7a 100644 --- a/flutter_secure_storage/android/src/main/AndroidManifest.xml +++ b/flutter_secure_storage/android/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + diff --git a/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStorage.java b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStorage.java index da7183a6..18940707 100644 --- a/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStorage.java +++ b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStorage.java @@ -2,12 +2,18 @@ import android.content.Context; import android.content.SharedPreferences; +import android.hardware.biometrics.BiometricManager; +import android.hardware.biometrics.BiometricPrompt; +import android.os.Build; +import android.os.CancellationSignal; import android.security.keystore.KeyGenParameterSpec; import android.security.keystore.KeyProperties; +import android.security.keystore.UserNotAuthenticatedException; import android.util.Base64; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; import com.it_nomads.fluttersecurestorage.ciphers.StorageCipher; import com.it_nomads.fluttersecurestorage.ciphers.StorageCipherFactory; @@ -18,50 +24,69 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; +import java.security.KeyStoreException; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; public class FlutterSecureStorage { private static final String TAG = "FlutterSecureStorage"; private static final Charset CHARSET = StandardCharsets.UTF_8; - private static final String DEFAULT_PREF_NAME = "FlutterSecureStorage"; - private static final String DEFAULT_KEY_PREFIX = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIHNlY3VyZSBzdG9yYWdlCg"; - private static final String PREF_OPTION_NAME = "sharedPreferencesName"; - private static final String PREF_OPTION_PREFIX = "preferencesKeyPrefix"; - private static final String PREF_OPTION_DELETE_ON_FAILURE = "resetOnError"; private static final String PREF_KEY_MIGRATED = "preferencesMigrated"; + @NonNull - private final SharedPreferences encryptedPreferences; + private final FlutterSecureStorageConfig config; @NonNull - private String preferencesKeyPrefix = DEFAULT_KEY_PREFIX; - - public FlutterSecureStorage(Context context, Map options) throws GeneralSecurityException, IOException { - String sharedPreferencesName = DEFAULT_PREF_NAME; - if (options.containsKey(PREF_OPTION_NAME)) { - var value = options.get(PREF_OPTION_NAME); - if (value instanceof String && !((String) value).isEmpty()) { - sharedPreferencesName = (String) value; - } - } + private final Context context; - if (options.containsKey(PREF_OPTION_PREFIX)) { - var value = options.get(PREF_OPTION_PREFIX); - if (value instanceof String && !((String) value).isEmpty()) { - preferencesKeyPrefix = (String) value; - } - } + private SharedPreferences encryptedPreferences; - boolean deleteOnFailure = false; + private FlutterSecureStorage(@NonNull Context context, Map options) { + this.context = context; + this.config = new FlutterSecureStorageConfig(options); + } - if (options.containsKey(PREF_OPTION_DELETE_ON_FAILURE)) { - var value = options.get(PREF_OPTION_DELETE_ON_FAILURE); - if (value instanceof String) { - deleteOnFailure = Boolean.parseBoolean((String) value); - } - } + public static void create(@NonNull Context context, Map options, SecureStorageInitCallback callback) { + FlutterSecureStorage storage = new FlutterSecureStorage(context, options); + + storage.authenticateIfNeeded(() -> { + try { + SharedPreferences encryptedPreferences = storage.initializeEncryptedSharedPreferencesManager(storage.config.getSharedPreferencesName()); + boolean migrated = encryptedPreferences.getBoolean(PREF_KEY_MIGRATED, false); + if (!migrated) { + storage.migrateToEncryptedPreferences(storage.config.getSharedPreferencesName(), encryptedPreferences, storage.config.shouldDeleteOnFailure(), options); + } + + Log.d(TAG, "Encrypted preferences initialized successfully."); + storage.encryptedPreferences = encryptedPreferences; + callback.onComplete(storage, null); // Initialization successful + + } catch (KeyStoreException | UserNotAuthenticatedException e) { + Log.w(TAG, "Authentication failed: Unable to access secure keystore. Check biometric settings.", e); + callback.onComplete(null, e); + + } catch (GeneralSecurityException | IOException e) { + if (!storage.config.shouldDeleteOnFailure()) { + Log.w(TAG, "Initialization failed: Secure storage could not be initialized. 'deleteOnFailure' is false, skipping reset.", e); + callback.onComplete(null, e); + } else { + Log.w(TAG, "Initialization failed: Resetting storage as 'deleteOnFailure' is enabled.", e); + context.getSharedPreferences(storage.config.getSharedPreferencesName(), Context.MODE_PRIVATE).edit().clear().apply(); - encryptedPreferences = getEncryptedSharedPreferences(deleteOnFailure, options, context.getApplicationContext(), sharedPreferencesName); + try { + SharedPreferences encryptedPreferences = storage.initializeEncryptedSharedPreferencesManager(storage.config.getSharedPreferencesName()); + Log.i(TAG, "Secure storage successfully re-initialized after reset."); + storage.encryptedPreferences = encryptedPreferences; + callback.onComplete(storage, null); // Re-initialization successful + } catch (Exception f) { + Log.e(TAG, "Critical failure: Initialization after reset failed.", f); + callback.onComplete(null, f); + } + } + } + }); } public boolean containsKey(String key) { @@ -90,55 +115,66 @@ public Map readAll() { for (Map.Entry entry : allEntries.entrySet()) { String key = entry.getKey(); Object value = entry.getValue(); - if (key.startsWith(preferencesKeyPrefix) && value instanceof String) { - String originalKey = key.replaceFirst(preferencesKeyPrefix + "_", ""); + if (key.startsWith(config.getSharedPreferencesKeyPrefix()) && value instanceof String) { + String originalKey = key.replaceFirst(config.getSharedPreferencesKeyPrefix() + "_", ""); result.put(originalKey, (String) value); } } return result; } - private String addPrefixToKey(String key) { - return preferencesKeyPrefix + "_" + key; + private void authenticateIfNeeded(Runnable onSuccess) { + if (config.shouldUseBiometrics()) { + authenticateUser(onSuccess); + } else { + onSuccess.run(); + } } - private SharedPreferences getEncryptedSharedPreferences(boolean deleteOnFailure, Map options, Context context, String sharedPreferencesName) throws GeneralSecurityException, IOException { - try { - final SharedPreferences encryptedPreferences = initializeEncryptedSharedPreferencesManager(context, sharedPreferencesName); - boolean migrated = encryptedPreferences.getBoolean(PREF_KEY_MIGRATED, false); - if (!migrated) { - migrateToEncryptedPreferences(context, sharedPreferencesName, encryptedPreferences, deleteOnFailure, options); - } - return encryptedPreferences; - } catch (GeneralSecurityException | IOException e) { - - if (!deleteOnFailure) { - Log.w(TAG, "initialization failed, resetOnError false, so throwing exception.", e); - throw e; - } - Log.w(TAG, "initialization failed, resetting storage", e); - - context.getSharedPreferences(sharedPreferencesName, Context.MODE_PRIVATE).edit().clear().apply(); + private String addPrefixToKey(String key) { + return config.getSharedPreferencesKeyPrefix() + "_" + key; + } - try { - return initializeEncryptedSharedPreferencesManager(context, sharedPreferencesName); - } catch (Exception f) { - Log.e(TAG, "initialization after reset failed", f); - throw f; - } - } + @RequiresApi(api = Build.VERSION_CODES.R) + private MasterKey createSecretKeyApi30() throws GeneralSecurityException, IOException { + return new MasterKey.Builder(context) + .setUserAuthenticationRequired(config.shouldUseBiometrics(), 2) + .setRequestStrongBoxBacked(true) + .setKeyGenParameterSpec(new KeyGenParameterSpec.Builder( + MasterKey.DEFAULT_MASTER_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setIsStrongBoxBacked(true) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(config.shouldUseBiometrics()) // Enforce user authentication + .setUserAuthenticationValidityDurationSeconds(2) + .setInvalidatedByBiometricEnrollment(true) + .setKeySize(256) + .build()) + .build(); } - private SharedPreferences initializeEncryptedSharedPreferencesManager(Context context, String sharedPreferencesName) throws GeneralSecurityException, IOException { - MasterKey masterKey = new MasterKey.Builder(context) + private MasterKey createSecretKeyApi2329() throws GeneralSecurityException, IOException { + return new MasterKey.Builder(context) .setKeyGenParameterSpec(new KeyGenParameterSpec.Builder( MasterKey.DEFAULT_MASTER_KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(config.shouldUseBiometrics()) // Enforce user authentication + .setUserAuthenticationValidityDurationSeconds(1) .setKeySize(256) .build()) .build(); + } + private SharedPreferences initializeEncryptedSharedPreferencesManager(String sharedPreferencesName) throws GeneralSecurityException, IOException { + MasterKey masterKey; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + masterKey = createSecretKeyApi30(); + } else { + masterKey = createSecretKeyApi2329(); + } return EncryptedSharedPreferences.create( context, @@ -149,7 +185,7 @@ private SharedPreferences initializeEncryptedSharedPreferencesManager(Context co ); } - private void migrateToEncryptedPreferences(Context context, String sharedPreferencesName, SharedPreferences target, boolean deleteOnFailure, Map options) { + private void migrateToEncryptedPreferences(String sharedPreferencesName, SharedPreferences target, boolean deleteOnFailure, Map options) { SharedPreferences source = context.getSharedPreferences(sharedPreferencesName, Context.MODE_PRIVATE); Map sourceEntries = source.getAll(); @@ -164,7 +200,7 @@ private void migrateToEncryptedPreferences(Context context, String sharedPrefere for (Map.Entry entry : sourceEntries.entrySet()) { String key = entry.getKey(); Object value = entry.getValue(); - if (key.startsWith(preferencesKeyPrefix) && value instanceof String) { + if (key.startsWith(config.getSharedPreferencesKeyPrefix()) && value instanceof String) { try { String decryptedValue = decryptValue((String) value, cipher); target.edit().putString(key, decryptedValue).apply(); @@ -208,4 +244,51 @@ private String decryptValue(String value, StorageCipher cipher) throws Exception byte[] data = Base64.decode(value, Base64.DEFAULT); return new String(cipher.decrypt(data), CHARSET); } + + private void authenticateUser(Runnable onSuccess) { + BiometricPrompt promptInfo = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + promptInfo = new BiometricPrompt.Builder(context) + .setTitle(config.getBiometricPromptTitle()) + .setSubtitle(config.getPrefOptionBiometricPromptSubtitle()) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL) + .build(); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + promptInfo = new BiometricPrompt.Builder(context) + .setTitle(config.getBiometricPromptTitle()) + .setSubtitle(config.getPrefOptionBiometricPromptSubtitle()) + .build(); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + CancellationSignal cancellationSignal = new CancellationSignal(); + Executor executor = Executors.newSingleThreadExecutor(); + + BiometricPrompt.AuthenticationCallback callback = new BiometricPrompt.AuthenticationCallback() { + @Override + public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) { + super.onAuthenticationSucceeded(result); + Log.d(TAG, "Authentication Succeeded!"); + onSuccess.run(); + } + + @Override + public void onAuthenticationFailed() { + super.onAuthenticationFailed(); + Log.d(TAG, "Authentication Failed. Try again."); + } + + @Override + public void onAuthenticationError(int errorCode, CharSequence errString) { + super.onAuthenticationError(errorCode, errString); + Log.e(TAG, "Authentication Error: " + errString); + } + }; + + promptInfo.authenticate(cancellationSignal, executor, callback); + } else { + onSuccess.run(); // Proceed without authentication on unsupported devices + } + } + } diff --git a/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStorageConfig.java b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStorageConfig.java new file mode 100644 index 00000000..acde167f --- /dev/null +++ b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStorageConfig.java @@ -0,0 +1,77 @@ +package com.it_nomads.fluttersecurestorage; + +import androidx.annotation.NonNull; + +import java.util.Map; + +public class FlutterSecureStorageConfig { + + private static final String DEFAULT_PREF_NAME = "FlutterSecureStorage"; + private static final String DEFAULT_KEY_PREFIX = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIHNlY3VyZSBzdG9yYWdlCg"; + + public static final String PREF_OPTION_NAME = "sharedPreferencesName"; + public static final String PREF_OPTION_PREFIX = "preferencesKeyPrefix"; + public static final String PREF_OPTION_DELETE_ON_FAILURE = "resetOnError"; + public static final String PREF_OPTION_SHOULD_USE_BIOMETRICS = "shouldUseBiometrics"; + + public static final String PREF_OPTION_BIOMETRIC_PROMPT_TITLE = "prefOptionBiometricPromptTitle"; + public static final String PREF_OPTION_BIOMETRIC_PROMPT_SUBTITLE = "prefOptionBiometricPromptSubtitle"; + + private final String sharedPreferencesName; + private final String sharedPreferencesKeyPrefix; + private final boolean deleteOnFailure; + private final boolean shouldUseBiometrics; + + private final String biometricPromptTitle; + private final String biometricPromptSubtitle; + + public FlutterSecureStorageConfig(Map options) { + this.sharedPreferencesName = getStringOption(options, PREF_OPTION_NAME, DEFAULT_PREF_NAME); + this.sharedPreferencesKeyPrefix = getStringOption(options, PREF_OPTION_PREFIX, DEFAULT_KEY_PREFIX); + this.deleteOnFailure = getBooleanOption(options, PREF_OPTION_DELETE_ON_FAILURE, false); + this.biometricPromptTitle = getStringOption(options, PREF_OPTION_BIOMETRIC_PROMPT_TITLE, "Authenticate to access"); + this.biometricPromptSubtitle = getStringOption(options, PREF_OPTION_BIOMETRIC_PROMPT_SUBTITLE, "Use biometrics or device credentials"); + + this.shouldUseBiometrics = getBooleanOption(options, PREF_OPTION_SHOULD_USE_BIOMETRICS, false); + } + + private String getStringOption(Map options, String key, String defaultValue) { + if (options.containsKey(key)) { + Object value = options.get(key); + if (value instanceof String strValue) { + if (!strValue.isEmpty()) { + return strValue; + } + } + } + return defaultValue; + } + + private boolean getBooleanOption(Map options, String key, boolean defaultValue) { + Object value = options.get(key); + if (value instanceof String) { + return Boolean.parseBoolean((String) value); + } + + return defaultValue; + } + + public String getSharedPreferencesName() { return sharedPreferencesName; } + public String getSharedPreferencesKeyPrefix() { return sharedPreferencesKeyPrefix; } + public boolean shouldDeleteOnFailure() { return deleteOnFailure; } + + public boolean shouldUseBiometrics() { return shouldUseBiometrics; } + + public String getBiometricPromptTitle() { return biometricPromptTitle; } + public String getPrefOptionBiometricPromptSubtitle() { return biometricPromptSubtitle; } + + @NonNull + @Override + public String toString() { + return "FlutterSecureStorageConfig{" + + "sharedPreferencesName='" + sharedPreferencesName + '\'' + + ", sharedPreferencesKeyPrefix='" + sharedPreferencesKeyPrefix + '\'' + + ", deleteOnFailure=" + deleteOnFailure + + '}'; + } +} diff --git a/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStoragePlugin.java b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStoragePlugin.java index bf28a2ee..45b75a03 100644 --- a/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStoragePlugin.java +++ b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStoragePlugin.java @@ -3,6 +3,7 @@ import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; +import android.security.keystore.UserNotAuthenticatedException; import androidx.annotation.NonNull; @@ -19,11 +20,11 @@ public class FlutterSecureStoragePlugin implements MethodCallHandler, FlutterPlugin { + private FlutterPluginBinding binding; private MethodChannel channel; private FlutterSecureStorage secureStorage; private HandlerThread workerThread; private Handler workerThreadHandler; - private FlutterPluginBinding binding; @Override public void onAttachedToEngine(FlutterPluginBinding binding) { @@ -48,22 +49,20 @@ public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { secureStorage = null; } - private boolean initSecureStorage(Result result, Map options) { - if (secureStorage != null) return true; - - try { - secureStorage = new FlutterSecureStorage(binding.getApplicationContext(), options); - return true; - } catch (Exception e) { - if (result != null) { - result.error( - "RESET_FAILED", // Error code - "Failed to reset and initialize encrypted preferences", // Error message - e.toString() // Details (stack trace or additional info) - ); - } - return false; + public void initSecureStorage(Map options, SecureStorageInitCallback callback) { + if (secureStorage != null) { + callback.onComplete(secureStorage, null); + return; } + + FlutterSecureStorage.create(binding.getApplicationContext(), options, (initializedStorage, exception) -> { + if (initializedStorage != null) { + secureStorage = initializedStorage; + callback.onComplete(secureStorage, null); + } else { + callback.onComplete(null, exception); + } + }); } @Override @@ -101,31 +100,39 @@ private void handleMethodCall(MethodCall call, Result result) { Map options = extractMapFromObject(arguments.get("options")); - boolean isInitialized = initSecureStorage(result, options); - if (!isInitialized) return; - - switch (method) { - case "write": - handleWrite(arguments, result); - break; - case "read": - handleRead(arguments, result); - break; - case "readAll": - handleReadAll(result); - break; - case "containsKey": - handleContainsKey(arguments, result); - break; - case "delete": - handleDelete(arguments, result); - break; - case "deleteAll": - handleDeleteAll(result); - break; - default: - result.notImplemented(); - } + initSecureStorage(options, (secureStorage, exception) -> { + if (secureStorage == null) { + String code = "INIT_FAILED"; + if (exception instanceof UserNotAuthenticatedException) { + code = "AUTHENTICATION_FAILED"; + } + result.error(code, exception.getMessage(), null); + return; + } + + switch (method) { + case "write": + handleWrite(arguments, result); + break; + case "read": + handleRead(arguments, result); + break; + case "readAll": + handleReadAll(result); + break; + case "containsKey": + handleContainsKey(arguments, result); + break; + case "delete": + handleDelete(arguments, result); + break; + case "deleteAll": + handleDeleteAll(result); + break; + default: + result.notImplemented(); + } + }); } private void handleWrite(Map args, Result result) { diff --git a/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/SecureStorageInitCallback.java b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/SecureStorageInitCallback.java new file mode 100644 index 00000000..15d3859e --- /dev/null +++ b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/SecureStorageInitCallback.java @@ -0,0 +1,7 @@ +package com.it_nomads.fluttersecurestorage; + +public interface SecureStorageInitCallback { + void onComplete(FlutterSecureStorage success, Exception e); + +} + diff --git a/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/SharedPreferencesCallback.java b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/SharedPreferencesCallback.java new file mode 100644 index 00000000..e33561ed --- /dev/null +++ b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/SharedPreferencesCallback.java @@ -0,0 +1,7 @@ +package com.it_nomads.fluttersecurestorage; + +import android.content.SharedPreferences; + +public interface SharedPreferencesCallback { + void onComplete(SharedPreferences sharedPreferences); +} \ No newline at end of file diff --git a/flutter_secure_storage/example/lib/main.dart b/flutter_secure_storage/example/lib/main.dart index 1b2285c1..44d97094 100644 --- a/flutter_secure_storage/example/lib/main.dart +++ b/flutter_secure_storage/example/lib/main.dart @@ -2,13 +2,14 @@ import 'dart:math' show Random; import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; void main() { runApp(const MaterialApp(home: HomePage())); } -enum _Actions { deleteAll, isProtectedDataAvailable } +enum _Actions { deleteAll, isProtectedDataAvailable, readALl } enum _ItemActions { delete, edit, containsKey, read } @@ -30,11 +31,33 @@ class HomePageState extends State { TextEditingController(text: AppleOptions.defaultAccountName); final List<_SecItem> _items = []; + bool useBiometrics = false; void _initializeFlutterSecureStorage(String accountName) { _storage = FlutterSecureStorage( - iOptions: IOSOptions(accountName: accountName), - mOptions: MacOsOptions(accountName: accountName), + aOptions: AndroidOptions( + biometricPromptTitle: 'Flutter Secure Storage Example', + biometricPromptSubtitle: 'Please unlock to access data.', + shouldUseBiometrics: useBiometrics, + ), + iOptions: IOSOptions( + accountName: accountName, + synchronizable: true, + accessControlFlags: [ + AccessControlFlag.biometryCurrentSet, + AccessControlFlag.devicePasscode, + AccessControlFlag.and, + ], + ), + mOptions: MacOsOptions( + accountName: accountName, + synchronizable: true, + accessControlFlags: [ + AccessControlFlag.biometryCurrentSet, + AccessControlFlag.devicePasscode, + AccessControlFlag.and, + ], + ), ); } @@ -63,41 +86,91 @@ class HomePageState extends State { } Future _readAll() async { - final all = await _storage.readAll(); - setState(() { - _items - ..clear() - ..addAll(all.entries.map((e) => _SecItem(e.key, e.value))) - ..sort( - (a, b) => - (int.tryParse(a.key) ?? 10).compareTo(int.tryParse(b.key) ?? 11), - ); - }); + try { + final all = await _storage.readAll(); + setState(() { + _items + ..clear() + ..addAll(all.entries.map((e) => _SecItem(e.key, e.value))) + ..sort( + (a, b) => (int.tryParse(a.key) ?? 10) + .compareTo(int.tryParse(b.key) ?? 11), + ); + }); + } on PlatformException catch (e) { + _handleInitializationError(e); + } } Future _deleteAll() async { - await _storage.deleteAll(); - await _readAll(); + try { + await _storage.deleteAll(); + await _readAll(); + } on PlatformException catch (e) { + _handleInitializationError(e); + } } Future _isProtectedDataAvailable() async { final scaffold = ScaffoldMessenger.of(context); - final result = await _storage.isCupertinoProtectedDataAvailable(); + try { + final result = await _storage.isCupertinoProtectedDataAvailable(); - scaffold.showSnackBar( - SnackBar( - content: Text('Protected data available: $result'), - backgroundColor: result != null && result ? Colors.green : Colors.red, - ), - ); + scaffold.showSnackBar( + SnackBar( + content: Text('Protected data available: $result'), + backgroundColor: result != null && result ? Colors.green : Colors.red, + ), + ); + } on PlatformException catch (e) { + _handleInitializationError(e); + } } Future _addNewItem() async { - await _storage.write( - key: DateTime.timestamp().microsecondsSinceEpoch.toString(), - value: _randomValue(), - ); - await _readAll(); + try { + await _storage.write( + key: DateTime.timestamp().microsecondsSinceEpoch.toString(), + value: _randomValue(), + ); + await _readAll(); + } on PlatformException catch (e) { + _handleInitializationError(e); + } + } + + void _handleInitializationError(PlatformException e) { + switch (e.code) { + case 'INIT_FAILED': + _showErrorDialog('Initialization Failed', + e.message ?? 'An unknown error occurred during initialization.'); + case 'AUTHENTICATION_FAILED': + _showErrorDialog('Authentication Failed', + 'Biometric authentication failed. Please try again.'); + case 'InvalidArgument': + _showErrorDialog( + 'Argument Error', 'A with an argument occured. ${e.message}'); + default: + _showErrorDialog('Error', 'An unexpected error occurred: ${e.message}'); + } + } + + void _showErrorDialog(String title, String message) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + }); } @override @@ -117,6 +190,8 @@ class HomePageState extends State { switch (action) { case _Actions.deleteAll: _deleteAll(); + case _Actions.readALl: + _readAll(); case _Actions.isProtectedDataAvailable: _isProtectedDataAvailable(); } @@ -127,11 +202,30 @@ class HomePageState extends State { value: _Actions.deleteAll, child: Text('Delete all'), ), + const PopupMenuItem( + key: Key('read_all'), + value: _Actions.readALl, + child: Text('Read all'), + ), const PopupMenuItem( key: Key('is_protected_data_available'), value: _Actions.isProtectedDataAvailable, child: Text('IsProtectedDataAvailable'), ), + PopupMenuItem( + key: const Key('use_biometrics'), + child: StatefulBuilder( + builder: (_context, _setState) => CheckboxListTile( + value: useBiometrics, + onChanged: (value) { + _setState(() => useBiometrics = !useBiometrics); + _initializeFlutterSecureStorage( + _accountNameController.text); + }, + title: const Text('Use Biometrics'), + ), + ), + ), ], ), ], @@ -214,44 +308,48 @@ class HomePageState extends State { _SecItem item, BuildContext context, ) async { - switch (action) { - case _ItemActions.delete: - await _storage.delete( - key: item.key, - ); - await _readAll(); - case _ItemActions.edit: - if (!context.mounted) return; - final result = await showDialog( - context: context, - builder: (_) => _EditItemWidget(item.value), - ); - if (result != null) { - await _storage.write( + try { + switch (action) { + case _ItemActions.delete: + await _storage.delete( key: item.key, - value: result, ); await _readAll(); - } - case _ItemActions.containsKey: - final key = await _displayTextInputDialog(context, item.key); - final result = await _storage.containsKey(key: key); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Contains Key: $result, key checked: $key'), - backgroundColor: result ? Colors.green : Colors.red, - ), - ); - case _ItemActions.read: - final key = await _displayTextInputDialog(context, item.key); - final result = await _storage.read(key: key); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('value: $result'), - ), - ); + case _ItemActions.edit: + if (!context.mounted) return; + final result = await showDialog( + context: context, + builder: (_) => _EditItemWidget(item.value), + ); + if (result != null) { + await _storage.write( + key: item.key, + value: result, + ); + await _readAll(); + } + case _ItemActions.containsKey: + final key = await _displayTextInputDialog(context, item.key); + final result = await _storage.containsKey(key: key); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Contains Key: $result, key checked: $key'), + backgroundColor: result ? Colors.green : Colors.red, + ), + ); + case _ItemActions.read: + final key = await _displayTextInputDialog(context, item.key); + final result = await _storage.read(key: key); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('value: $result'), + ), + ); + } + } on PlatformException catch (e) { + _handleInitializationError(e); } } diff --git a/flutter_secure_storage/lib/options/android_options.dart b/flutter_secure_storage/lib/options/android_options.dart index b6609e52..265d8bf4 100644 --- a/flutter_secure_storage/lib/options/android_options.dart +++ b/flutter_secure_storage/lib/options/android_options.dart @@ -27,6 +27,9 @@ class AndroidOptions extends Options { StorageCipherAlgorithm.AES_CBC_PKCS7Padding, this.sharedPreferencesName, this.preferencesKeyPrefix, + this.biometricPromptTitle, + this.biometricPromptSubtitle, + this.shouldUseBiometrics = false, }) : _encryptedSharedPreferences = encryptedSharedPreferences, _resetOnError = resetOnError, _keyCipherAlgorithm = keyCipherAlgorithm, @@ -70,6 +73,10 @@ class AndroidOptions extends Options { /// WARNING: If you change this you can't retrieve already saved preferences. final String? preferencesKeyPrefix; + final String? biometricPromptTitle; + final String? biometricPromptSubtitle; + final bool shouldUseBiometrics; + static const AndroidOptions defaultOptions = AndroidOptions(); @override @@ -80,6 +87,12 @@ class AndroidOptions extends Options { 'storageCipherAlgorithm': _storageCipherAlgorithm.name, 'sharedPreferencesName': sharedPreferencesName ?? '', 'preferencesKeyPrefix': preferencesKeyPrefix ?? '', + 'biometricPromptTitle': biometricPromptTitle ?? + 'Authenticate to access', + 'biometricPromptSubtitle': biometricPromptSubtitle ?? + 'Use biometrics or device credentials', + 'shouldUseBiometrics': '$shouldUseBiometrics', + }; AndroidOptions copyWith({