mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-08 23:53:57 +02:00
feat: add SFTP host key management and security improvements
- Add HTTPS URL validation for extension store registry and downloads - Add Reset SFTP Host Key button (per-server) - Add Reset All SFTP Host Keys button - Add SFTP host key verification with TOFU (Trust On First Use) - Update cloud upload service with host key storage - Add flutter_secure_storage dependency for secure password storage
This commit is contained in:
@@ -20,8 +20,7 @@
|
||||
android:label="SpotiFLAC"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
|
||||
<activity
|
||||
|
||||
@@ -26,6 +26,13 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme == "" {
|
||||
return fmt.Errorf("invalid URL: scheme is required")
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("network access denied: only https is allowed")
|
||||
}
|
||||
|
||||
domain := parsed.Hostname()
|
||||
|
||||
// Block private/local network access (SSRF protection)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -214,6 +215,10 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
return s.cache, nil
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(s.registryURL, "registry"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
@@ -303,6 +308,10 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
@@ -332,6 +341,20 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireHTTPSURL(rawURL string, context string) error {
|
||||
if rawURL == "" {
|
||||
return fmt.Errorf("%s URL is empty", context)
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return fmt.Errorf("%s URL is invalid: %s", context, rawURL)
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("%s URL must use https: %s", context, rawURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetCategories() []string {
|
||||
return []string{
|
||||
CategoryMetadata,
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<!-- File Sharing - Allow access via Files app -->
|
||||
|
||||
@@ -142,9 +142,14 @@ class UploadQueueNotifier extends Notifier<UploadQueueState> {
|
||||
_isProcessing = true;
|
||||
state = state.copyWith(isProcessing: true);
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
while (true) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
// Stop processing if cloud upload is disabled or provider is unset
|
||||
if (!settings.cloudUploadEnabled || settings.cloudProvider == 'none') {
|
||||
break;
|
||||
}
|
||||
|
||||
// Find next pending item
|
||||
final pendingIndex = state.items.indexWhere(
|
||||
(i) => i.status == UploadStatus.pending,
|
||||
|
||||
@@ -19,6 +19,8 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
|
||||
late TextEditingController _passwordController;
|
||||
late TextEditingController _remotePathController;
|
||||
bool _isTestingConnection = false;
|
||||
bool _isResettingHostKey = false;
|
||||
bool _isResettingAllHostKeys = false;
|
||||
String? _connectionTestResult;
|
||||
|
||||
@override
|
||||
@@ -220,6 +222,42 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (settings.cloudProvider == 'sftp')
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: (_isResettingHostKey || settings.cloudServerUrl.isEmpty)
|
||||
? null
|
||||
: _resetSftpHostKey,
|
||||
icon: _isResettingHostKey
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.security),
|
||||
label: Text(context.l10n.cloudSettingsResetSftpHostKey),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (settings.cloudProvider == 'sftp')
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isResettingAllHostKeys ? null : _resetAllSftpHostKeys,
|
||||
icon: _isResettingAllHostKeys
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.delete_sweep),
|
||||
label: Text(context.l10n.cloudSettingsResetAllSftpHostKeys),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Connection Test Result
|
||||
if (_connectionTestResult != null)
|
||||
@@ -433,6 +471,101 @@ class _CloudSettingsPageState extends ConsumerState<CloudSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetSftpHostKey() async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.cloudServerUrl.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.cloudSettingsServerUrlRequired)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.l10n.cloudSettingsResetSftpHostKey),
|
||||
content: Text(context.l10n.cloudSettingsResetSftpHostKeyMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.cloudSettingsResetConfirm),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
setState(() {
|
||||
_isResettingHostKey = true;
|
||||
});
|
||||
|
||||
final cleared = await CloudUploadService.instance.clearSftpHostKey(
|
||||
serverUrl: settings.cloudServerUrl,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isResettingHostKey = false;
|
||||
});
|
||||
|
||||
final message = cleared
|
||||
? context.l10n.cloudSettingsResetSftpHostKeySuccess
|
||||
: context.l10n.cloudSettingsResetSftpHostKeyNotFound;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _resetAllSftpHostKeys() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.l10n.cloudSettingsResetAllSftpHostKeys),
|
||||
content: Text(context.l10n.cloudSettingsResetAllSftpHostKeysMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.cloudSettingsResetAllConfirm),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
setState(() {
|
||||
_isResettingAllHostKeys = true;
|
||||
});
|
||||
|
||||
final clearedCount = await CloudUploadService.instance.clearAllSftpHostKeys();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isResettingAllHostKeys = false;
|
||||
});
|
||||
|
||||
final message = clearedCount > 0
|
||||
? context.l10n.cloudSettingsResetAllSftpHostKeysCleared(clearedCount)
|
||||
: context.l10n.cloudSettingsResetAllSftpHostKeysNone;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadQueueSection(BuildContext context) {
|
||||
final uploadState = ref.watch(uploadQueueProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:webdav_client/webdav_client.dart' as webdav;
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
/// Result of a cloud upload operation
|
||||
@@ -43,10 +45,15 @@ class CloudUploadService {
|
||||
CloudUploadService._();
|
||||
|
||||
final LogBuffer _log = LogBuffer();
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
webdav.Client? _webdavClient;
|
||||
String? _currentServerUrl;
|
||||
String? _currentUsername;
|
||||
String? _currentPassword;
|
||||
|
||||
static const _sftpHostKeysKey = 'sftp_known_host_keys';
|
||||
Map<String, Map<String, String>>? _knownHostKeys;
|
||||
|
||||
void _logInfo(String tag, String message) {
|
||||
_log.add(LogEntry(
|
||||
@@ -71,16 +78,26 @@ class CloudUploadService {
|
||||
// WebDAV Methods
|
||||
// ============================================================
|
||||
|
||||
bool _isHttpsUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
return uri != null && uri.scheme == 'https';
|
||||
}
|
||||
|
||||
/// Initialize WebDAV client with server credentials
|
||||
Future<void> initializeWebDAV({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
if (!_isHttpsUrl(serverUrl)) {
|
||||
throw ArgumentError('WebDAV URL must use https');
|
||||
}
|
||||
|
||||
// Reuse existing client if credentials haven't changed
|
||||
if (_webdavClient != null &&
|
||||
_currentServerUrl == serverUrl &&
|
||||
_currentUsername == username) {
|
||||
_currentUsername == username &&
|
||||
_currentPassword == password) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,6 +110,7 @@ class CloudUploadService {
|
||||
|
||||
_currentServerUrl = serverUrl;
|
||||
_currentUsername = username;
|
||||
_currentPassword = password;
|
||||
|
||||
_logInfo('CloudUpload', 'WebDAV client initialized for $serverUrl');
|
||||
}
|
||||
@@ -103,6 +121,9 @@ class CloudUploadService {
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
if (!_isHttpsUrl(serverUrl)) {
|
||||
return CloudUploadResult.failure('WebDAV URL must use https.');
|
||||
}
|
||||
try {
|
||||
final client = webdav.newClient(
|
||||
serverUrl,
|
||||
@@ -131,6 +152,9 @@ class CloudUploadService {
|
||||
required String password,
|
||||
void Function(int sent, int total)? onProgress,
|
||||
}) async {
|
||||
if (!_isHttpsUrl(serverUrl)) {
|
||||
return CloudUploadResult.failure('WebDAV URL must use https.');
|
||||
}
|
||||
try {
|
||||
// Initialize client if needed
|
||||
await initializeWebDAV(
|
||||
@@ -266,6 +290,12 @@ class CloudUploadService {
|
||||
socket,
|
||||
username: username,
|
||||
onPasswordRequest: () => password,
|
||||
onVerifyHostKey: (type, fingerprint) => _verifySftpHostKey(
|
||||
host: serverInfo.host,
|
||||
port: serverInfo.port,
|
||||
type: type,
|
||||
fingerprint: fingerprint,
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for authentication
|
||||
@@ -273,7 +303,7 @@ class CloudUploadService {
|
||||
|
||||
// Test SFTP subsystem
|
||||
final sftp = await client.sftp();
|
||||
await sftp.listdir('/');
|
||||
await sftp.listdir('.');
|
||||
sftp.close();
|
||||
|
||||
_logInfo('CloudUpload', 'SFTP connection test successful: ${serverInfo.host}');
|
||||
@@ -318,6 +348,12 @@ class CloudUploadService {
|
||||
socket,
|
||||
username: username,
|
||||
onPasswordRequest: () => password,
|
||||
onVerifyHostKey: (type, fingerprint) => _verifySftpHostKey(
|
||||
host: serverInfo.host,
|
||||
port: serverInfo.port,
|
||||
type: type,
|
||||
fingerprint: fingerprint,
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for authentication
|
||||
@@ -368,10 +404,16 @@ class CloudUploadService {
|
||||
/// Ensure a directory exists on the SFTP server, creating it if necessary
|
||||
Future<void> _ensureSFTPDirectoryExists(SftpClient sftp, String path) async {
|
||||
final parts = path.split('/').where((p) => p.isNotEmpty).toList();
|
||||
if (parts.isEmpty) return;
|
||||
final isAbsolute = path.startsWith('/');
|
||||
var currentPath = '';
|
||||
|
||||
for (final part in parts) {
|
||||
currentPath += '/$part';
|
||||
if (currentPath.isEmpty) {
|
||||
currentPath = isAbsolute ? '/$part' : part;
|
||||
} else {
|
||||
currentPath += '/$part';
|
||||
}
|
||||
try {
|
||||
await sftp.mkdir(currentPath);
|
||||
} catch (e) {
|
||||
@@ -465,5 +507,117 @@ class CloudUploadService {
|
||||
_webdavClient = null;
|
||||
_currentServerUrl = null;
|
||||
_currentUsername = null;
|
||||
_currentPassword = null;
|
||||
}
|
||||
|
||||
Future<bool> clearSftpHostKey({required String serverUrl}) async {
|
||||
final serverInfo = _parseSftpUrl(serverUrl);
|
||||
final knownHostKeys = await _loadKnownHostKeys();
|
||||
final keyId = '${serverInfo.host}:${serverInfo.port}';
|
||||
|
||||
final removed = knownHostKeys.remove(keyId) != null;
|
||||
if (removed) {
|
||||
await _saveKnownHostKeys();
|
||||
_logInfo('CloudUpload', 'Cleared SFTP host key for $keyId');
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
Future<int> clearAllSftpHostKeys() async {
|
||||
final knownHostKeys = await _loadKnownHostKeys();
|
||||
final count = knownHostKeys.length;
|
||||
if (count == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
knownHostKeys.clear();
|
||||
await _saveKnownHostKeys();
|
||||
_logInfo('CloudUpload', 'Cleared all SFTP host keys');
|
||||
return count;
|
||||
}
|
||||
|
||||
Future<Map<String, Map<String, String>>> _loadKnownHostKeys() async {
|
||||
if (_knownHostKeys != null) {
|
||||
return _knownHostKeys!;
|
||||
}
|
||||
|
||||
final prefs = await _prefs;
|
||||
final raw = prefs.getString(_sftpHostKeysKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
_knownHostKeys = <String, Map<String, String>>{};
|
||||
return _knownHostKeys!;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final map = <String, Map<String, String>>{};
|
||||
decoded.forEach((key, value) {
|
||||
if (value is Map) {
|
||||
final type = value['type'];
|
||||
final fingerprint = value['fingerprint'];
|
||||
if (type is String && fingerprint is String) {
|
||||
map[key] = {'type': type, 'fingerprint': fingerprint};
|
||||
}
|
||||
}
|
||||
});
|
||||
_knownHostKeys = map;
|
||||
return map;
|
||||
}
|
||||
} catch (e) {
|
||||
_logError('CloudUpload', 'Failed to parse known host keys', e.toString());
|
||||
}
|
||||
|
||||
_knownHostKeys = <String, Map<String, String>>{};
|
||||
return _knownHostKeys!;
|
||||
}
|
||||
|
||||
Future<void> _saveKnownHostKeys() async {
|
||||
if (_knownHostKeys == null) return;
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(_sftpHostKeysKey, jsonEncode(_knownHostKeys));
|
||||
}
|
||||
|
||||
String _formatFingerprint(Uint8List fingerprint) {
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < fingerprint.length; i++) {
|
||||
if (i > 0) buffer.write(':');
|
||||
buffer.write(fingerprint[i].toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<bool> _verifySftpHostKey({
|
||||
required String host,
|
||||
required int port,
|
||||
required String type,
|
||||
required Uint8List fingerprint,
|
||||
}) async {
|
||||
final knownHostKeys = await _loadKnownHostKeys();
|
||||
final keyId = '$host:$port';
|
||||
final fingerprintHex = _formatFingerprint(fingerprint);
|
||||
final existing = knownHostKeys[keyId];
|
||||
|
||||
if (existing == null) {
|
||||
knownHostKeys[keyId] = {
|
||||
'type': type,
|
||||
'fingerprint': fingerprintHex,
|
||||
};
|
||||
await _saveKnownHostKeys();
|
||||
_logInfo('CloudUpload', 'Saved new SFTP host key for $keyId');
|
||||
return true;
|
||||
}
|
||||
|
||||
final existingFingerprint = existing['fingerprint'];
|
||||
if (existingFingerprint == fingerprintHex) {
|
||||
return true;
|
||||
}
|
||||
|
||||
_logError(
|
||||
'CloudUpload',
|
||||
'SFTP host key mismatch for $keyId',
|
||||
'expected=$existingFingerprint got=$fingerprintHex',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+50
-2
@@ -451,6 +451,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -561,10 +609,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -24,6 +24,7 @@ dependencies:
|
||||
|
||||
# Storage & Persistence
|
||||
shared_preferences: ^2.5.3
|
||||
flutter_secure_storage: ^9.2.2
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.0
|
||||
sqflite: ^2.4.1
|
||||
|
||||
Reference in New Issue
Block a user