Adopt shared wallet_ui dialogs, logging, and biometric auth from wallet-core; Bug fixes
What changed, and why it matters
This commit mostly moves existing features—log exporting, delete-wallet dialog, and biometric unlock—into a shared library called wallet-core, and tweaks on-screen keyboard behavior. It is a refactoring and bug-fix patch, not a clear security fix. There is no direct evidence in the commit that it repairs a vulnerability, though centralizing sensitive code like biometric auth and wallet deletion in a shared library can make future security maintenance easier.
Treat as a routine refactor. Review the shared wallet-core implementations of BiometricAuth, DeleteWalletDialog, and FileLogSink to ensure they preserve or improve security guarantees (e.g., biometric result handling, destructive-action confirmation, log file permissions and rotation). No immediate user action is required.
Security signals we found
Refactored biometric authentication into shared wallet-core BiometricAuth wrapper
Refactored delete-wallet confirmation dialog into shared wallet_ui component
Refactored log export dialog and file sink into shared wallet-core implementation
Removed direct local_auth dependency from app settings and unlock screens
Added textInputAction keyboard hints across password, send, address book, and settings forms
Evidence from the diff
The diff removes local implementations of logging, export-logs dialog, delete-wallet dialog, and biometric authentication from the Skylight Wallet Flutter app and replaces them with exports/widgets from wallet_infra/wallet_ui. It also adds textInputAction hints across forms. The logging change replaces a custom _SkylightLogSink with a CompositeLogSink combining DebugPrintLogSink and FileLogSink. The biometric change replaces direct local_auth usage with BiometricAuth.authenticate and adjusts snackbar behavior. No explicit security bug is described or fixed in the diff.
Changed components
lib/screens/settings.dartlib/screens/unlock.dartlib/util/logging.dartlib/wallet_core_glue.dartlib/screens/address_book.dartlib/screens/create_wallet_password.dartlib/screens/restore_wallet.dartlib/screens/send.dartlib/widgets/connection_settings_form.dartlib/widgets/tor_settings_form.dartInspect captured patch +61 / −318
diff --git a/lib/screens/address_book.dart b/lib/screens/address_book.dart
index d5f7d49..25bafc6 100644
--- a/lib/screens/address_book.dart
+++ b/lib/screens/address_book.dart
@@ -86,6 +86,7 @@ class _AddressBookScreenState extends State<AddressBookScreen> {
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
+ textInputAction: TextInputAction.done,
decoration: InputDecoration(
hintText: i18n.addressBookSearchHint,
prefixIcon: Icon(Icons.search),
@@ -358,6 +359,7 @@ class _ContactDialogState extends State<_ContactDialog> {
),
validator: _validateName,
textCapitalization: TextCapitalization.words,
+ textInputAction: TextInputAction.next,
),
SizedBox(height: 16),
TextFormField(
@@ -368,6 +370,7 @@ class _ContactDialogState extends State<_ContactDialog> {
),
validator: _validateAddress,
maxLines: 3,
+ textInputAction: TextInputAction.done,
),
],
),
diff --git a/lib/screens/create_wallet_password.dart b/lib/screens/create_wallet_password.dart
index 66c188c..e25311e 100644
--- a/lib/screens/create_wallet_password.dart
+++ b/lib/screens/create_wallet_password.dart
@@ -113,6 +113,7 @@ class _CreateWalletPasswordScreenState extends State<CreateWalletPasswordScreen>
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
+ textInputAction: TextInputAction.next,
validator: _validatePassword,
decoration: InputDecoration(
labelText: 'Password',
@@ -132,6 +133,7 @@ class _CreateWalletPasswordScreenState extends State<CreateWalletPasswordScreen>
TextFormField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
+ textInputAction: TextInputAction.done,
validator: _validateConfirmPassword,
decoration: InputDecoration(
labelText: 'Confirm Password',
diff --git a/lib/screens/restore_wallet.dart b/lib/screens/restore_wallet.dart
index 195adfb..2267508 100644
--- a/lib/screens/restore_wallet.dart
+++ b/lib/screens/restore_wallet.dart
@@ -277,6 +277,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> with SecureSc
controller: _restoreHeightController,
onChanged: _onRestoreHeightChanged,
keyboardType: TextInputType.number,
+ textInputAction: TextInputAction.done,
inputFormatters: <TextInputFormatter>[FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
labelText: i18n.restoreWalletRestoreHeightLabel,
diff --git a/lib/screens/send.dart b/lib/screens/send.dart
index e7389c0..caddb8d 100644
--- a/lib/screens/send.dart
+++ b/lib/screens/send.dart
@@ -572,6 +572,7 @@ class _SendScreenState extends State<SendScreen> {
TextField(
controller: _destinationAddressController,
maxLines: null,
+ textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: i18n.address,
border: OutlineInputBorder(),
@@ -663,6 +664,7 @@ class _SendScreenState extends State<SendScreen> {
TextField(
controller: _amountController,
keyboardType: TextInputType.numberWithOptions(decimal: true),
+ textInputAction: TextInputAction.done,
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+(\.\d*)?'))],
decoration: InputDecoration(
labelText: i18n.amount,
@@ -842,6 +844,7 @@ class _ContactPickerDialogState extends State<_ContactPickerDialog> {
TextField(
controller: _searchController,
onChanged: _onSearchChanged,
+ textInputAction: TextInputAction.done,
decoration: InputDecoration(
hintText: i18n.addressBookSearchHint,
prefixIcon: Icon(Icons.search),
diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart
index ac3401b..6501875 100644
--- a/lib/screens/settings.dart
+++ b/lib/screens/settings.dart
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:skylight_wallet/util/logging.dart';
import 'package:provider/provider.dart';
-import 'package:local_auth/local_auth.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/models/fiat_rate_model.dart';
@@ -18,6 +17,9 @@ import 'package:skylight_wallet/periodic_tasks.dart';
import 'package:skylight_wallet/services/notifications_service.dart';
import 'package:skylight_wallet/services/shared_preferences_service.dart';
import 'package:skylight_wallet/widgets/wallet_navigation_bar.dart';
+import 'package:wallet_infra/wallet_infra.dart' show BiometricAuth, BiometricAuthResult;
+import 'package:wallet_ui/wallet_ui.dart'
+ show DeleteWalletDialog, DeleteWalletLabels, ExportLogsDialog, ExportLogsLabels;
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@@ -94,25 +96,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final i18n = AppLocalizations.of(context)!;
if (value) {
- final auth = LocalAuthentication();
-
- try {
- final didAuthenticate = await auth.authenticate(
- localizedReason: i18n.settingsAppLockUnlockReason,
- options: AuthenticationOptions(useErrorDialogs: true, sensitiveTransaction: true),
- );
-
- if (!didAuthenticate) {
- if (mounted) {
- ScaffoldMessenger.of(
- context,
- ).showSnackBar(SnackBar(content: Text(i18n.settingsAppLockUnableToAuthError)));
- }
- return;
- }
- } catch (error) {
- log(LogLevel.error, 'Unable to authenticate: ${error.toString()}');
-
+ final result = await BiometricAuth.authenticate(reason: i18n.settingsAppLockUnlockReason);
+ // Enabling app-lock is an explicit opt-in, so decline and error both report.
+ if (result != BiometricAuthResult.authenticated) {
if (mounted) {
ScaffoldMessenger.of(
context,
@@ -153,7 +139,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
if (mounted) {
- _showExportLogsDialog(logFiles);
+ ExportLogsDialog.show(
+ context,
+ logFiles,
+ ExportLogsLabels(
+ title: i18n.settingsExportLogsLabel,
+ cancel: i18n.cancel,
+ exportError: i18n.settingsExportLogsError,
+ ),
+ );
}
} catch (e) {
if (mounted) {
@@ -164,83 +158,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
- void _showExportLogsDialog(List<LogFileInfo> logFiles) {
- final i18n = AppLocalizations.of(context)!;
- final screenWidth = MediaQuery.of(context).size.width;
- final dialogWidth = screenWidth.clamp(0.0, 500.0);
-
- showDialog(
- context: context,
- builder: (context) => AlertDialog(
- constraints: BoxConstraints.tightFor(width: dialogWidth),
- insetPadding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0),
- title: Text(i18n.settingsExportLogsLabel),
- content: SizedBox(
- width: double.maxFinite,
- height: 300,
- child: ListView.builder(
- itemCount: logFiles.length,
- itemBuilder: (context, index) {
- final file = logFiles[index];
- final dateStr =
- '${file.modified.year}-${file.modified.month.toString().padLeft(2, '0')}-${file.modified.day.toString().padLeft(2, '0')}';
- final sizeKb = (file.size / 1024).toStringAsFixed(1);
-
- return ListTile(
- onTap: () async {
- Navigator.pop(context);
- await exportLogFiles([file]);
- },
- leading: Icon(Icons.description_outlined),
- title: Text(file.name),
- subtitle: Text('$dateStr • $sizeKb KB'),
- trailing: Icon(Icons.ios_share),
- );
- },
- ),
- ),
- actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(i18n.cancel))],
- ),
- );
- }
-
void _showDeleteWalletDialog() {
final i18n = AppLocalizations.of(context)!;
-
- showDialog(
- context: context,
- builder: (BuildContext context) {
- final screenWidth = MediaQuery.of(context).size.width;
- final dialogWidth = screenWidth.clamp(0.0, 500.0);
-
- return AlertDialog(
- constraints: BoxConstraints.tightFor(width: dialogWidth),
- insetPadding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0),
- title: Row(
- spacing: 6,
- children: [
- Icon(Icons.delete_forever, color: Colors.red),
- Text(i18n.settingsDeleteWalletButton),
- ],
- ),
- content: Text(i18n.settingsDeleteWalletDialogText),
- actions: [
- TextButton.icon(
- onPressed: _deleteWallet,
- icon: Icon(Icons.delete_forever),
- label: Text(i18n.settingsDeleteWalletDialogDeleteButton),
- style: TextButton.styleFrom(foregroundColor: Colors.red),
- ),
- FilledButton.icon(
- onPressed: () {
- Navigator.pop(context);
- },
- icon: Icon(Icons.cancel),
- label: Text(i18n.cancel),
- ),
- ],
- );
- },
+ DeleteWalletDialog.show(
+ context,
+ DeleteWalletLabels(
+ title: i18n.settingsDeleteWalletButton,
+ body: i18n.settingsDeleteWalletDialogText,
+ confirm: i18n.settingsDeleteWalletDialogDeleteButton,
+ cancel: i18n.cancel,
+ ),
+ onConfirm: _deleteWallet,
);
}
@@ -517,13 +445,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
Switch(value: _verboseLoggingEnabled, onChanged: _setVerboseLoggingEnabled),
],
),
- if (Platform.isIOS)
+ // Only meaningful with logs to export, so hide the whole row when
+ // verbose logging is off rather than showing a disabled button.
+ if (Platform.isIOS && _verboseLoggingEnabled)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(i18n.settingsExportLogsLabel, style: TextStyle(fontSize: 18)),
TextButton.icon(
- onPressed: _verboseLoggingEnabled ? _exportLogs : null,
+ onPressed: _exportLogs,
icon: Icon(Icons.ios_share),
label: Text(i18n.settingsExportLogsButton),
),
diff --git a/lib/screens/unlock.dart b/lib/screens/unlock.dart
index f21ed65..7243236 100644
--- a/lib/screens/unlock.dart
+++ b/lib/screens/unlock.dart
@@ -1,11 +1,10 @@
import 'dart:io';
import 'package:flutter/material.dart';
-import 'package:local_auth/local_auth.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
-import 'package:skylight_wallet/util/logging.dart';
import 'package:skylight_wallet/widgets/loading_button.dart';
import 'package:skylight_wallet/wallet_core_glue.dart';
+import 'package:wallet_infra/wallet_infra.dart' show BiometricAuth, BiometricAuthResult;
class UnlockScreen extends StatefulWidget {
const UnlockScreen({super.key});
@@ -36,28 +35,19 @@ class _UnlockScreenState extends State<UnlockScreen> {
}
Future<void> _promptUnlock() async {
- final auth = LocalAuthentication();
-
- try {
- final i18n = AppLocalizations.of(context)!;
- final didAuthenticate = await auth.authenticate(
- localizedReason: i18n.unlockReason,
- options: AuthenticationOptions(useErrorDialogs: true, sensitiveTransaction: true),
- );
-
- if (didAuthenticate) {
- if (mounted) Navigator.pushReplacementNamed(context, '/wallet_home');
- }
- } catch (error) {
- log(LogLevel.error, 'Unable to authenticate: ${error.toString()}');
+ final i18n = AppLocalizations.of(context)!;
+ final result = await BiometricAuth.authenticate(reason: i18n.unlockReason);
+ // Auto-prompted with a password field right there: stay silent on a decline
+ // (the user chose to type instead), report only a real error.
+ if (result == BiometricAuthResult.authenticated) {
+ if (mounted) Navigator.pushReplacementNamed(context, '/wallet_home');
+ } else if (result == BiometricAuthResult.error) {
if (mounted) {
- final i18n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(i18n.unlockUnableToAuthError)));
}
- return;
}
}
@@ -136,6 +126,7 @@ class _UnlockScreenState extends State<UnlockScreen> {
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
+ textInputAction: TextInputAction.done,
validator: _validatePasswordField,
enabled: !_isLoading,
decoration: InputDecoration(
diff --git a/lib/util/logging.dart b/lib/util/logging.dart
index 521941c..c5b4e88 100644
--- a/lib/util/logging.dart
+++ b/lib/util/logging.dart
@@ -1,188 +1,6 @@
-import 'dart:async';
-import 'dart:io';
-import 'package:flutter/foundation.dart';
-import 'package:path_provider/path_provider.dart';
-import 'package:share_plus/share_plus.dart';
-import 'package:skylight_wallet/services/shared_preferences_service.dart';
-import 'package:skylight_wallet/util/dirs.dart';
-
-enum LogLevel { info, warn, error }
-
-class _LogQueue {
- Future<void>? _lastWrite;
-
- Future<void> add(Future<void> Function() operation) {
- _lastWrite = _lastWrite?.then((_) => operation()) ?? operation();
- return _lastWrite!;
- }
-}
-
-final _logQueue = _LogQueue();
-
-String _timestamp() => DateTime.now().toUtc().toIso8601String();
-
-Future<File> _getLogFile() async {
- Directory? directory;
-
- // Get external storage directory for Android, or documents directory for iOS
- if (Platform.isAndroid) {
- directory = await getExternalStorageDirectory();
- } else {
- directory = await getAppDir();
- }
-
- if (directory == null) {
- throw Exception('Could not access storage directory');
- }
-
- // Create logs subdirectory
- final logsDir = Directory('${directory.path}/logs');
- if (!await logsDir.exists()) {
- await logsDir.create(recursive: true);
- }
-
- // Create filename with current date (YYYY-MM-DD)
- final dateStr = DateTime.now().toIso8601String().split('T').first;
- final filePath = '${logsDir.path}/log_$dateStr.txt';
-
- return File(filePath);
-}
-
-Future<void> cleanOldLogFiles() async {
- try {
- Directory? directory;
-
- if (Platform.isAndroid) {
- directory = await getExternalStorageDirectory();
- } else {
- directory = await getAppDir();
- }
-
- if (directory == null) {
- return;
- }
-
- final logsDir = Directory('${directory.path}/logs');
- if (!await logsDir.exists()) {
- return;
- }
-
- // Get current time and calculate cutoff date
- const daysToKeep = 30;
- final now = DateTime.now();
- final cutoffDate = now.subtract(const Duration(days: daysToKeep));
-
- // List all files in logs directory
- final files = await logsDir.list().toList();
-
- for (var entity in files) {
- if (entity is File) {
- final stat = await entity.stat();
- final modifiedDate = stat.modified;
-
- // Delete file if it's older than the cutoff date
- if (modifiedDate.isBefore(cutoffDate)) {
- await entity.delete();
- debugPrint('Deleted old log file: ${entity.path}');
- }
- }
- }
- } catch (error) {
- debugPrint('Failed to clean old logs: $error');
- }
-}
-
-Future<void> log(LogLevel level, String message, [Map<String, dynamic>? meta]) async {
- final verboseLoggingEnabled =
- await SharedPreferencesService.get<bool>(SharedPreferencesKeys.verboseLoggingEnabled) ??
- false;
-
- if (level == LogLevel.info && !verboseLoggingEnabled) {
- if (!verboseLoggingEnabled) {
- return;
- }
- }
-
- final ts = _timestamp();
- final label = level.toString().split('.').last.toUpperCase();
- final metaStr = (meta == null || meta.isEmpty) ? '' : ' ${meta.toString()}';
- final output = '[$ts] [$label] $message $metaStr';
-
- if (level == LogLevel.error) {
- debugPrint(output);
- } else {
- if (kDebugMode) debugPrint(output);
- }
-
- if (verboseLoggingEnabled) {
- _logQueue.add(() async {
- try {
- final file = await _getLogFile();
- await file.writeAsString('$output\n', mode: FileMode.append);
- } catch (error) {
- debugPrint('Failed to write log to file: $error');
- }
- });
- }
-}
-
-class LogFileInfo {
- final String path;
- final String name;
- final DateTime modified;
- final int size;
-
- LogFileInfo({required this.path, required this.name, required this.modified, required this.size});
-}
-
-/// Returns a list of available log files sorted by date (newest first)
-Future<List<LogFileInfo>> getLogFiles() async {
- Directory? directory;
-
- if (Platform.isAndroid) {
- directory = await getExternalStorageDirectory();
- } else {
- directory = await getAppDir();
- }
-
- if (directory == null) {
- return [];
- }
-
- final logsDir = Directory('${directory.path}/logs');
- if (!await logsDir.exists()) {
- return [];
- }
-
- final files = await logsDir.list().toList();
- final logFiles = <LogFileInfo>[];
-
- for (var entity in files) {
- if (entity is File && entity.path.endsWith('.txt')) {
- final stat = await entity.stat();
- final name = entity.path.split('/').last;
- logFiles.add(
- LogFileInfo(path: entity.path, name: name, modified: stat.modified, size: stat.size),
- );
- }
- }
-
- // Sort by date (newest first)
- logFiles.sort((a, b) => b.modified.compareTo(a.modified));
- return logFiles;
-}
-
-/// Exports selected log files via the system share sheet (iOS/Android)
-Future<void> exportLogFiles(List<LogFileInfo> files) async {
- if (files.isEmpty) {
- throw Exception('No log files selected');
- }
-
- try {
- final xFiles = files.map((f) => XFile(f.path)).toList();
- await SharePlus.instance.share(ShareParams(files: xFiles));
- } catch (error) {
- debugPrint('Failed to export logs: $error');
- rethrow;
- }
-}
+// The whole logger lives in wallet-core (wallet_infra, D25): the log() API +
+// formatter, the verbose gate, the file sink (FileLogSink), log rotation
+// (cleanOldLogFiles), and file listing/export. The app only installs the sink
+// (console + file) in wallet_core_glue.dart.
+export 'package:wallet_infra/wallet_infra.dart'
+ show LogLevel, log, LogFileInfo, getLogFiles, exportLogFiles, cleanOldLogFiles;
diff --git a/lib/wallet_core_glue.dart b/lib/wallet_core_glue.dart
index 7c79d5c..fd02df1 100644
--- a/lib/wallet_core_glue.dart
+++ b/lib/wallet_core_glue.dart
@@ -15,7 +15,6 @@ import 'package:skylight_wallet/services/notifications_service.dart';
import 'package:skylight_wallet/services/shared_preferences_service.dart';
import 'package:skylight_wallet/services/tor_service.dart';
import 'package:skylight_wallet/services/tor_settings_service.dart';
-import 'package:skylight_wallet/util/logging.dart';
import 'package:wallet_infra/wallet_infra.dart' as wcore;
import 'package:wallet_background/wallet_background.dart' show BackgroundSync;
@@ -62,7 +61,12 @@ void installWalletCore() {
FiatRates.install(getTorProxy: TorSettingsService.sharedInstance.getProxy);
- wcore.WalletLog.sink = const _SkylightLogSink();
+ // The whole logger lives in wallet-core now (D25): console + file sinks fan out
+ // from one installed sink; the file sink is verbose-gated internally.
+ wcore.WalletLog.sink = wcore.CompositeLogSink([
+ const wcore.DebugPrintLogSink(),
+ wcore.FileLogSink(),
+ ]);
wcore.WalletLog.isVerbose = () async =>
await SharedPreferencesService.get<bool>(SharedPreferencesKeys.verboseLoggingEnabled) ??
false;
@@ -217,15 +221,3 @@ Future<void> deleteWallet(BuildContext context) async {
void applyConnectionChange(BuildContext context) {
unawaited(Provider.of<WalletManager>(context, listen: false).applyConnectionChange('XMR'));
}
-
-/// Routes wallet-core log lines into skylight's logger.
-class _SkylightLogSink extends wcore.LogSink {
- const _SkylightLogSink();
-
- @override
- Future<void> write(wcore.LogLevel level, String line) => log(switch (level) {
- wcore.LogLevel.info => LogLevel.info,
- wcore.LogLevel.warn => LogLevel.warn,
- wcore.LogLevel.error => LogLevel.error,
- }, line);
-}
diff --git a/lib/widgets/connection_settings_form.dart b/lib/widgets/connection_settings_form.dart
index 07843ab..17ade88 100644
--- a/lib/widgets/connection_settings_form.dart
+++ b/lib/widgets/connection_settings_form.dart
@@ -559,6 +559,7 @@ class _ConnectionSettingsFormState extends State<ConnectionSettingsForm> {
),
),
keyboardType: TextInputType.url,
+ textInputAction: TextInputAction.done,
),
if (_errorMessage != null)
Text(_errorMessage!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
@@ -572,6 +573,7 @@ class _ConnectionSettingsFormState extends State<ConnectionSettingsForm> {
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)),
),
keyboardType: TextInputType.number,
+ textInputAction: TextInputAction.done,
inputFormatters: <TextInputFormatter>[FilteringTextInputFormatter.digitsOnly],
),
Column(
diff --git a/lib/widgets/tor_settings_form.dart b/lib/widgets/tor_settings_form.dart
index 0b057b6..3121b1a 100644
--- a/lib/widgets/tor_settings_form.dart
+++ b/lib/widgets/tor_settings_form.dart
@@ -217,6 +217,7 @@ class _TorSettingsFormState extends State<TorSettingsForm> {
suffixIconColor: _connectionSuccess ? Colors.teal : Colors.red,
),
keyboardType: TextInputType.number,
+ textInputAction: TextInputAction.done,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) {
setState(() {
Why this scored 17/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.