Merge pull request #162 from MAGICGrants/fixes
What changed, and why it matters
This is a large bug-fix and refactoring update for the Skylight Wallet app. The most important security-relevant changes fix ways the app could send or store money incorrectly: amounts were being converted through imprecise 'double' numbers, which could slightly alter the value the user intended to send; the 'Max' send button could fail or produce wrong values for large balances; the address book was moved into shared wallet-core code so both apps use one secure storage format; and deleting a wallet now properly stops the background sync service first, preventing the deleted wallet from being recreated by a still-running background task. Several other changes improve error handling, sharing, and connection settings so users do not accidentally save a private-server address as a public one.
Reviewers should verify the new decimal-string amount path end-to-end (QR scan, manual entry, Max button, fee estimation, transaction creation) and confirm no remaining double.parse/double-to-string conversions influence spend values. Validate that wallet deletion with an active foreground sync no longer leaves a resurrected wallet. Confirm the connection-settings type-specific server load prevents cross-type misconfiguration. Because the bulk of the logic moved into wallet-core, the corresponding wallet-core commit (6860cbd8eaca4c2e1f9b1487ec8292592fdc205f) should also be reviewed for the same fixes.
Security signals we found
Fixed precision loss in cryptocurrency spend amounts by replacing double-based amount handling with exact decimal-string-to-BigInt conversion
Added unlockedBalanceBaseUnits and used it for balance validation and Max/sweep-all amount calculation
Moved address book model and secure storage into shared wallet-core package with chain-keyed address map
Fixed wallet deletion to stop foreground sync before deleting files, preventing race where deleted wallet is rewritten
Fixed connection settings to load per-type persisted server, avoiding misconfigured server type that could expose private view key
Gated secret-view-key reveal on app-lock being enabled
Fixed iOS share sheet invocation with required sharePositionOrigin and error handling
Moved Tor and TorSettings services into shared wallet-core package
Evidence from the diff
The commit merges PR #162 (‘2.0.0 Fixes’). It bumps the wallet-core git ref across all packages and adapts the app to API changes. Key security-related diffs: (1) spend amounts are now passed as exact decimal strings and converted to base units via BigInt, removing double-precision loss in send/estimateFee/createTx flows; (2) unlockedBalanceBaseUnits is exposed and used for balance checks and the Max/sweep-all logic; (3) address-book Contact/ContactModel and storage are exported from wallet_domain, changing the per-contact address to a chain map (XMR only today) and removing the local implementation plus its tests; (4) deleteWallet now calls stopSyncAndDeleteWallets to stop the foreground sync isolate before deleting wallet files; (5) connection settings now load the persisted server for the selected connection type, preventing saving an LWS config with a node address (which would leak the private view key); (6) view-secret-key authentication now only prompts when app lock is enabled; (7) share sheet now passes the required origin rect on iOS and catches errors; (8) Tor/TorSettings services are exported from wallet_infra. The commit is a patch-level fix set with no CVE or vendor security disclosure supplied.
Changed components
lib/models/app_wallet.dartlib/models/contact_model.dartlib/models/monero_wallet_adapter.dartlib/screens/address_book.dartlib/screens/send.dartlib/screens/settings.dartlib/services/foreground_sync_service.dartlib/services/tor_service.dartlib/services/tor_settings_service.dartlib/util/contacts_store.dartlib/wallet_core_glue.dartlib/widgets/connection_settings_form.dartwallet-core git dependencies (wallet_background, wallet_domain, wallet_infra, wallet_monero, wallet_ui, wallet_fiat, wallet_openalias)Inspect captured patch +425 / −922
### lib/l10n/app_en.arb
@@ -190,6 +190,7 @@
"receiveSubaddressTab": "Subaddress",
"receivePrimaryTab": "Primary address",
"receiveCopyAddress": "Copy address",
+ "receiveShareError": "Could not open the share sheet",
"receiveAddressHeading": "Your {coin} address",
"@receiveAddressHeading": {
"placeholders": {
@@ -230,6 +231,7 @@
"settingsExportLogsLabel": "Export Logs",
"settingsExportLogsButton": "Export",
"settingsExportLogsError": "No logs found to export.",
+ "settingsExportLogsFailed": "Could not export the log file",
"settingsThemeLabel": "Theme",
"settingsThemeSystem": "System",
"settingsThemeLight": "Light",
### lib/l10n/app_localizations.dart
@@ -1065,6 +1065,12 @@ abstract class AppLocalizations {
/// **'Copy address'**
String get receiveCopyAddress;
+ /// No description provided for @receiveShareError.
+ ///
+ /// In en, this message translates to:
+ /// **'Could not open the share sheet'**
+ String get receiveShareError;
+
/// No description provided for @receiveAddressHeading.
///
/// In en, this message translates to:
@@ -1221,6 +1227,12 @@ abstract class AppLocalizations {
/// **'No logs found to export.'**
String get settingsExportLogsError;
+ /// No description provided for @settingsExportLogsFailed.
+ ///
+ /// In en, this message translates to:
+ /// **'Could not export the log file'**
+ String get settingsExportLogsFailed;
+
/// No description provided for @settingsThemeLabel.
///
/// In en, this message translates to:
### lib/l10n/app_localizations_en.dart
@@ -524,6 +524,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get receiveCopyAddress => 'Copy address';
+ @override
+ String get receiveShareError => 'Could not open the share sheet';
+
@override
String receiveAddressHeading(String coin) {
return 'Your $coin address';
@@ -612,6 +615,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsExportLogsError => 'No logs found to export.';
+ @override
+ String get settingsExportLogsFailed => 'Could not export the log file';
+
@override
String get settingsThemeLabel => 'Theme';
### lib/l10n/app_localizations_pt.dart
@@ -524,6 +524,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get receiveCopyAddress => 'Copiar endereço';
+ @override
+ String get receiveShareError => 'Não foi possível abrir a janela de compartilhamento';
+
@override
String receiveAddressHeading(String coin) {
return 'Seu endereço $coin';
@@ -612,6 +615,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settingsExportLogsError => 'Nenhum log encontrado para exportar.';
+ @override
+ String get settingsExportLogsFailed => 'Não foi possível exportar o arquivo de log';
+
@override
String get settingsThemeLabel => 'Tema';
### lib/l10n/app_pt.arb
@@ -190,6 +190,7 @@
"receiveSubaddressTab": "Subendereço",
"receivePrimaryTab": "Endereço primário",
"receiveCopyAddress": "Copiar endereço",
+ "receiveShareError": "Não foi possível abrir a janela de compartilhamento",
"receiveAddressHeading": "Seu endereço {coin}",
"@receiveAddressHeading": {
"placeholders": {
@@ -230,6 +231,7 @@
"settingsExportLogsLabel": "Exportar Logs",
"settingsExportLogsButton": "Exportar",
"settingsExportLogsError": "Nenhum log encontrado para exportar.",
+ "settingsExportLogsFailed": "Não foi possível exportar o arquivo de log",
"settingsThemeLabel": "Tema",
"settingsThemeSystem": "Sistema",
"settingsThemeLight": "Claro",
### lib/models/app_wallet.dart
@@ -39,6 +39,10 @@ abstract interface class AppWallet implements Listenable {
// Balances + history
double? get unlockedBalance;
+
+ /// The same balance, exactly. [unlockedBalance] is a double for display and
+ /// fiat maths; anything that becomes a spend amount must start here.
+ BigInt? get unlockedBalanceBaseUnits;
double? get totalBalance;
List<TxDetails> get txHistory;
bool? get serverSupportsSubaddresses;
@@ -56,6 +60,13 @@ abstract interface class AppWallet implements Listenable {
// Connection ops
Future<LWSConnectionDetails> getPersistedConnection();
+
+ /// The server saved for [type], whichever type is currently active.
+ ///
+ /// Servers are stored per connection type, so the setup form can show the
+ /// server belonging to the mode being selected instead of leaving the other
+ /// mode's address in the field.
+ Future<LWSConnectionDetails> getPersistedConnectionForType(String type);
void setConnection({
required String address,
required String proxyPort,
@@ -103,18 +114,14 @@ abstract interface class AppWallet implements Listenable {
/// Estimated network fee in base units (piconero), or null when the backend
/// can't estimate it (typically insufficient balance for that priority).
- Future<int?> estimateFee(
- String destinationAddress,
- double amount, {
- int priority,
- String? amountText,
- });
+ /// [amount] is exact decimal text, never a double. A spend amount that has
+ /// been through a double is no longer the amount the user asked for.
+ Future<int?> estimateFee(String destinationAddress, String amount, {int priority});
Future<AppPendingTx> createTx(
String destinationAddress,
- double amount,
+ String amount,
bool isSweepAll, {
int priority,
- String? amountText,
});
Future<void> commitTx(AppPendingTx tx, String destinationAddress);
Future<ResolvedOpenAlias?> resolveOpenAlias(String alias);
### lib/models/contact_model.dart
@@ -1,123 +1,7 @@
-import 'dart:convert';
-import 'package:flutter/foundation.dart';
-import 'package:skylight_wallet/util/contacts_store.dart';
-import 'package:skylight_wallet/util/logging.dart';
-
-class Contact {
- final String id;
- final String name;
- final String address;
-
- Contact({required this.id, required this.name, required this.address});
-
- Map<String, dynamic> toJson() => {'id': id, 'name': name, 'address': address};
-
- factory Contact.fromJson(Map<String, dynamic> json) => Contact(
- id: json['id'] as String,
- name: json['name'] as String,
- address: json['address'] as String,
- );
-
- Contact copyWith({String? id, String? name, String? address}) {
- return Contact(id: id ?? this.id, name: name ?? this.name, address: address ?? this.address);
- }
-}
-
-class ContactModel with ChangeNotifier {
- List<Contact> _contacts = [];
-
- /// Set when the address book could not be read. Saving is refused while it
- /// holds: an empty in-memory list written over a store we simply failed to
- /// open would destroy the address book.
- bool _unreadable = false;
-
- List<Contact> get contacts => List.unmodifiable(_contacts);
-
- /// True when the stored address book couldn't be read, so what's in memory
- /// isn't the whole picture and edits aren't being saved.
- bool get isUnreadable => _unreadable;
-
- ContactModel() {
- load();
- }
-
- @visibleForTesting
- Future<void> load() async {
- try {
- final storedContacts = await readEncodedContacts();
-
- if (storedContacts == null) {
- _unreadable = true;
- log(LogLevel.error, 'Address book could not be read; not saving over it.');
- return;
- }
-
- _unreadable = false;
- _contacts = storedContacts
- .map((jsonString) => Contact.fromJson(json.decode(jsonString) as Map<String, dynamic>))
- .toList();
-
- notifyListeners();
- } catch (e) {
- _unreadable = true;
- log(LogLevel.error, 'Error loading contacts: $e');
- }
- }
-
- Future<void> _saveContacts() async {
- if (_unreadable) {
- log(LogLevel.error, 'Refusing to save contacts over an address book that failed to load.');
- return;
- }
-
- try {
- await writeEncodedContacts(
- _contacts.map((contact) => json.encode(contact.toJson())).toList(),
- );
- } catch (e) {
- log(LogLevel.error, 'Error saving contacts: $e');
- }
- }
-
- Future<void> addContact(String name, String address) async {
- final id = DateTime.now().millisecondsSinceEpoch.toString();
- final contact = Contact(id: id, name: name.trim(), address: address.trim());
-
- _contacts.add(contact);
- await _saveContacts();
- notifyListeners();
- }
-
- Future<void> updateContact(String id, String name, String address) async {
- final index = _contacts.indexWhere((contact) => contact.id == id);
- if (index != -1) {
- _contacts[index] = _contacts[index].copyWith(name: name.trim(), address: address.trim());
- await _saveContacts();
- notifyListeners();
- }
- }
-
- Future<void> deleteContact(String id) async {
- _contacts.removeWhere((contact) => contact.id == id);
- await _saveContacts();
- notifyListeners();
- }
-
- Contact? getContactById(String id) {
- try {
- return _contacts.firstWhere((contact) => contact.id == id);
- } catch (e) {
- return null;
- }
- }
-
- List<Contact> searchContacts(String query) {
- if (query.isEmpty) return _contacts;
-
- final lowercaseQuery = query.toLowerCase();
- return _contacts.where((contact) {
- return contact.name.toLowerCase().contains(lowercaseQuery) ||
- contact.address.toLowerCase().contains(lowercaseQuery);
- }).toList();
- }
-}
+// Contact and ContactModel live in wallet-core (wallet_domain), shared with
+// Spice; kept under this path so call sites are unchanged.
+//
+// The shared Contact holds one address per chain. Skylight shows the Monero one
+// and nothing else today; the map is what it needs once Serai swaps give it
+// other chains, and it means both apps read and write one stored format.
+export 'package:wallet_domain/wallet_domain.dart' show Contact, ContactModel;
### lib/models/monero_wallet_adapter.dart
@@ -10,7 +10,7 @@ import 'package:wallet_monero/wallet_monero.dart' show MoneroWallet;
const _moneroDecimals = 12;
-/// Whether a bare `host:port` [address] gets a secure transport (D30).
+/// Whether a bare `host:port` [address] gets a secure transport.
///
/// wallet-core no longer stores a `useSsl` flag; the scheme is derived from the
/// host — https for a routable one, plaintext for an onion or local one. This
@@ -73,6 +73,8 @@ class MoneroWalletAdapter extends ChangeNotifier implements AppWallet {
@override
double? get unlockedBalance => _wallet.unlockedBalance;
@override
+ BigInt? get unlockedBalanceBaseUnits => _wallet.unlockedBalanceBaseUnits;
+ @override
double? get totalBalance => _wallet.totalBalance;
@override
bool? get serverSupportsSubaddresses => _wallet.serverSupportsSubaddresses;
@@ -131,6 +133,18 @@ class MoneroWalletAdapter extends ChangeNotifier implements AppWallet {
);
}
+ @override
+ Future<LWSConnectionDetails> getPersistedConnectionForType(String type) async {
+ final c = await _wallet.getPersistedConnectionForType(type);
+ return LWSConnectionDetails(
+ address: c.address,
+ proxyPort: c.proxyPort,
+ useTor: c.useTor,
+ useSsl: _deriveSsl(c.address),
+ connectionType: c.connectionType,
+ );
+ }
+
@override
void setConnection({
required String address,
@@ -195,15 +209,10 @@ class MoneroWalletAdapter extends ChangeNotifier implements AppWallet {
bool isAddressValid(String address) => _wallet.isAddressValid(address);
@override
- Future<int?> estimateFee(
- String destinationAddress,
- double amount, {
- int priority = 0,
- String? amountText,
- }) async {
+ Future<int?> estimateFee(String destinationAddress, String amount, {int priority = 0}) async {
final fee = await _wallet.estimateFee(
destinationAddress,
- _toBaseUnits(amount, amountText),
+ domain.decimalToBaseUnits(amount, _moneroDecimals),
priority: priority,
);
return fee?.toInt();
@@ -212,14 +221,13 @@ class MoneroWalletAdapter extends ChangeNotifier implements AppWallet {
@override
Future<AppPendingTx> createTx(
String destinationAddress,
- double amount,
+ String amount,
bool isSweepAll, {
int priority = 0,
- String? amountText,
}) async {
final tx = await _wallet.createTx(
destinationAddress,
- _toBaseUnits(amount, amountText),
+ domain.decimalToBaseUnits(amount, _moneroDecimals),
isSweepAll,
priority: priority,
);
@@ -246,12 +254,6 @@ class MoneroWalletAdapter extends ChangeNotifier implements AppWallet {
return null;
}
}
-
- // Prefer the exact decimal string; a double loses precision (D4).
- static BigInt _toBaseUnits(double amount, String? amountText) => domain.decimalToBaseUnits(
- amountText ?? amount.toStringAsFixed(_moneroDecimals),
- _moneroDecimals,
- );
}
/// Wraps a wallet-core [domain.PendingTransaction] as the neutral [AppPendingTx].
### lib/screens/address_book.dart
@@ -19,6 +19,13 @@ class AddressBookScreen extends StatefulWidget {
State<AddressBookScreen> createState() => _AddressBookScreenState();
}
+/// The address this screen shows for [contact].
+///
+/// The shared address book holds one address per chain. Skylight is Monero-only
+/// today, so it renders that entry; when Serai swaps add other chains this is
+/// the single place that has to learn about them.
+String _xmrAddress(Contact contact) => contact.addressFor('XMR') ?? '';
+
class _AddressBookScreenState extends State<AddressBookScreen> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@@ -323,7 +330,7 @@ class _ContactTile extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
- shortenMiddle(contact.address, head: 8, tail: 8),
+ shortenMiddle(_xmrAddress(contact), head: 8, tail: 8),
style: TextStyle(
fontFamily: 'Ubuntu Mono',
fontSize: 11.5,
@@ -418,7 +425,7 @@ class _AddressRow extends StatelessWidget {
void _copy(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
// Treat as sensitive (auto-cleared) like other address/key copies.
- SecureClipboard.copy(contact.address);
+ SecureClipboard.copy(_xmrAddress(contact));
showCopyToast(context, i18n.addressCopied);
}
@@ -434,7 +441,7 @@ class _AddressRow extends StatelessWidget {
children: [
Expanded(
child: Text(
- contact.address,
+ _xmrAddress(contact),
style: TextStyle(
fontFamily: 'Ubuntu Mono',
fontSize: 12,
@@ -454,7 +461,7 @@ class _AddressRow extends StatelessWidget {
onPressed: () => Navigator.pushNamed(
context,
'/send',
- arguments: SendScreenArgs(destinationAddress: contact.address, contact: contact),
+ arguments: SendScreenArgs(destinationAddress: _xmrAddress(contact), contact: contact),
),
),
],
@@ -514,7 +521,7 @@ class _ContactSheetState extends State<_ContactSheet> {
super.initState();
if (widget.contact != null) {
_nameController.text = widget.contact!.name;
- _address = widget.contact!.address;
+ _address = _xmrAddress(widget.contact!);
}
}
@@ -571,9 +578,9 @@ class _ContactSheetState extends State<_ContactSheet> {
try {
final model = Provider.of<ContactModel>(context, listen: false);
if (widget.contact == null) {
- await model.addContact(name, address);
+ await model.addContact(name, {'XMR': address});
} else {
- await model.updateContact(widget.contact!.id, name, address);
+ await model.updateContact(widget.contact!.id, name, {'XMR': address});
}
if (mounted) Navigator.pop(context);
} catch (_) {
@@ -589,89 +596,88 @@ class _ContactSheetState extends State<_ContactSheet> {
final i18n = AppLocalizations.of(context)!;
final name = _nameController.text.trim();
- return Padding(
- padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
- child: SafeArea(
- top: false,
- child: ConstrainedBox(
- constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.88),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const Padding(padding: EdgeInsets.only(top: 8), child: SheetHandle()),
- Padding(
- padding: const EdgeInsets.fromLTRB(22, 0, 22, 16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- SheetIcon(
- icon: _isEditing ? Icons.edit_outlined : Icons.person_outline,
- bg: BrandColors.surfaceTinted,
- color: BrandColors.primaryDeep,
- ),
- const SizedBox(width: 11),
- Text(
- _isEditing ? i18n.addressBookEditContact : i18n.addressBookAddContact,
- style: BrandText.sheetTitle,
- ),
- ],
- ),
- const SizedBox(height: 7),
- Text(
- _isEditing ? i18n.addressBookEditDescription : i18n.addressBookAddDescription,
- style: BrandText.bodyMuted.copyWith(fontSize: 13, height: 1.5),
- ),
- ],
- ),
- ),
- Flexible(
- child: SingleChildScrollView(
- padding: const EdgeInsets.fromLTRB(22, 0, 22, 0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
+ // No keyboard padding here: showBrandSheet applies it once for the
+ // whole sheet, and a second one lifts this clear off the keyboard.
+ return SafeArea(
+ top: false,
+ child: ConstrainedBox(
+ constraints: BoxConstraints(maxHeight: maxSheetHeight(context)),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Padding(padding: EdgeInsets.only(top: 8), child: SheetHandle()),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(22, 0, 22, 16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
children: [
- SectionHeader(
- label: i18n.addressBookContactName,
- padding: const EdgeInsets.only(left: 4, bottom: 8),
+ SheetIcon(
+ icon: _isEditing ? Icons.edit_outlined : Icons.person_outline,
+ bg: BrandColors.surfaceTinted,
+ color: BrandColors.primaryDeep,
),
- _nameField(name),
- const SizedBox(height: 16),
- SectionHeader(
- label: _addressHeaderLabel(i18n),
- padding: const EdgeInsets.only(left: 4, bottom: 8),
+ const SizedBox(width: 11),
+ Text(
+ _isEditing ? i18n.addressBookEditContact : i18n.addressBookAddContact,
+ style: BrandText.sheetTitle,
),
- _addressEntry(),
- if (_error != null) ...[
- const SizedBox(height: 10),
- Text(_error!, style: BrandText.caption.copyWith(color: BrandColors.error)),
- ],
],
),
- ),
+ const SizedBox(height: 7),
+ Text(
+ _isEditing ? i18n.addressBookEditDescription : i18n.addressBookAddDescription,
+ style: BrandText.bodyMuted.copyWith(fontSize: 13, height: 1.5),
+ ),
+ ],
),
- Padding(
- padding: const EdgeInsets.fromLTRB(22, 18, 22, 8),
+ ),
+ Flexible(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(22, 0, 22, 0),
child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
- BrandButton(
- label: _isEditing ? i18n.addressBookUpdate : i18n.addressBookSave,
- loading: _saving,
- onPressed: (_saving || name.isEmpty || _address == null) ? null : _save,
+ SectionHeader(
+ label: i18n.addressBookContactName,
+ padding: const EdgeInsets.only(left: 4, bottom: 8),
),
- const SizedBox(height: 4),
- BrandButton.ghost(
- label: i18n.cancel,
- color: BrandColors.inkMuted,
- onPressed: () => Navigator.pop(context),
+ _nameField(name),
+ const SizedBox(height: 16),
+ SectionHeader(
+ label: _addressHeaderLabel(i18n),
+ padding: const EdgeInsets.only(left: 4, bottom: 8),
),
+ _addressEntry(),
+ if (_error != null) ...[
+ const SizedBox(height: 10),
+ Text(_error!, style: BrandText.caption.copyWith(color: BrandColors.error)),
+ ],
],
),
),
- ],
- ),
+ ),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(22, 18, 22, 8),
+ child: Column(
+ children: [
+ BrandButton(
+ label: _isEditing ? i18n.addressBookUpdate : i18n.addressBookSave,
+ loading: _saving,
+ onPressed: (_saving || name.isEmpty || _address == null) ? null : _save,
+ ),
+ const SizedBox(height: 4),
+ BrandButton.ghost(
+ label: i18n.cancel,
+ color: BrandColors.inkMuted,
+ onPressed: () => Navigator.pop(context),
+ ),
+ ],
+ ),
+ ),
+ ],
),
),
);
### lib/screens/lws_details.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/util/secure_clipboard.dart';
+import 'package:skylight_wallet/util/secure_screen.dart';
import 'package:skylight_wallet/wallet_core_glue.dart';
import 'package:skylight_wallet/widgets/ui/ui.dart';
@@ -12,7 +13,7 @@ class LwsDetailsScreen extends StatefulWidget {
State<LwsDetailsScreen> createState() => _LwsDetailsScreenState();
}
-class _LwsDetailsScreenState extends State<LwsDetailsScreen> {
+class _LwsDetailsScreenState extends State<LwsDetailsScreen> with SecureScreenMixin {
String _secretViewKey = '';
@override
### lib/screens/receive.dart
@@ -6,6 +6,7 @@ import 'package:screen_brightness/screen_brightness.dart';
import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/models/app_wallet.dart';
+import 'package:skylight_wallet/util/logging.dart';
import 'package:skylight_wallet/util/secure_clipboard.dart';
import 'package:skylight_wallet/wallet_core_glue.dart';
import 'package:skylight_wallet/widgets/ui/ui.dart';
@@ -50,6 +51,23 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
showCopyToast(context, i18n.addressCopied);
}
+ /// Opens the system share sheet on [address], anchored at [origin].
+ ///
+ /// [origin] is the share button's rect. iOS presents the sheet as a popover
+ /// pointing at it and share_plus rejects the call without one, so dropping it
+ /// left the button doing nothing at all. Awaited and caught for the same
+ /// reason: a fire-and-forget share turns every failure into silence.
+ Future<void> _share(String address, Rect? origin) async {
+ final i18n = AppLocalizations.of(context)!;
+ final toast = BrandToast.of(context);
+ try {
+ await SharePlus.instance.share(ShareParams(text: address, sharePositionOrigin: origin));
+ } catch (error) {
+ log(LogLevel.error, 'Address share failed (origin=${origin ?? 'none'}): $error');
+ toast.show(i18n.receiveShareError);
+ }
+ }
+
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
@@ -74,7 +92,7 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
return ReceiveView(
labels: ReceiveLabels(title: i18n.receiveTitle, copyAddress: i18n.receiveCopyAddress),
onBack: () => Navigator.of(context).pop(),
- onShare: _isMobile ? () => SharePlus.instance.share(ShareParams(text: address!)) : null,
+ onShare: _isMobile ? (origin) => _share(address!, origin) : null,
ready: ready,
// Monero-only app: no coin card (would just say "Monero" redundantly).
coinSymbol: 'XMR',
### lib/screens/send.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
+import 'package:wallet_domain/wallet_domain.dart' show baseUnitsToDecimalString, decimalToBaseUnits;
import 'package:skylight_wallet/consts.dart' as consts;
import 'package:skylight_wallet/l10n/app_localizations.dart';
@@ -16,7 +17,9 @@ import 'package:skylight_wallet/widgets/ui/ui.dart';
class SendScreenArgs {
String destinationAddress;
- double? amount;
+
+ /// Exact decimal text, never a double: this is a spend amount.
+ String? amount;
/// Set when the address came from a contact (e.g. Send in the address book),
/// so Send opens showing the contact card rather than a bare address.
@@ -109,7 +112,7 @@ class _SendScreenState extends State<SendScreen> {
if (args != null) {
_destinationAddressController.text = args.destinationAddress;
- _amountController.text = args.amount != null ? args.amount.toString() : '';
+ _amountController.text = args.amount ?? '';
// Same field the in-send picker sets, so the contact card renders here too.
_selectedContact = args.contact;
}
@@ -132,7 +135,7 @@ class _SendScreenState extends State<SendScreen> {
if (result == null || result is! String) return;
String address = '';
- double? amount;
+ String? amount;
final uri = Uri.tryParse(result);
if (uri != null && uri.scheme == 'monero') {
@@ -145,9 +148,7 @@ class _SendScreenState extends State<SendScreen> {
address = uri.path;
- if (uri.queryParameters.containsKey('tx_amount')) {
- amount = double.tryParse(uri.queryParameters['tx_amount']!);
- }
+ amount = uri.queryParameters['tx_amount'];
} else if (wallet.isAddressValid(result)) {
address = result;
} else {
@@ -159,7 +160,24 @@ class _SendScreenState extends State<SendScreen> {
_destinationAddressController.text = address;
if (amount != null) {
- _amountController.text = amount.toString();
+ _amountController.text = _asExactAmount(amount);
+ }
+ }
+
+ /// A scanned amount as Monero can actually express it.
+ ///
+ /// Text the whole way. Parsing to a double and back is what used to change
+ /// the value; base units are exact, so this round trip can only drop digits
+ /// finer than one piconero — and it shows the user the amount that will
+ /// really be spent instead of one that gets truncated later.
+ ///
+ /// Unparseable input goes in the field verbatim, so the form's own validation
+ /// rejects it and the user can see what was scanned.
+ String _asExactAmount(String raw) {
+ try {
+ return baseUnitsToDecimalString(decimalToBaseUnits(raw, _xmrDecimals), _xmrDecimals);
+ } on FormatException {
+ return raw;
}
}
@@ -188,15 +206,15 @@ class _SendScreenState extends State<SendScreen> {
ContactPickerEntry<Contact>(
value: c,
name: c.name,
- addressShort: _shortenMiddle(c.address, head: 8, tail: 10),
+ addressShort: _shortenMiddle(c.addressFor('XMR') ?? '', head: 8, tail: 10),
),
],
);
if (contact == null || !mounted) return;
setState(() {
_selectedContact = contact;
- _destinationAddressController.text = contact.address;
+ _destinationAddressController.text = contact.addressFor('XMR') ?? '';
});
}
@@ -297,7 +315,7 @@ class _SendScreenState extends State<SendScreen> {
return false;
}
- if (amount > (wallet.unlockedBalance ?? 0)) {
+ if (_amountUnits() > (wallet.unlockedBalanceBaseUnits ?? BigInt.zero)) {
if (setErrors) {
setState(() {
_amountError = i18n.sendInsufficientBalanceError;
@@ -332,14 +350,13 @@ class _SendScreenState extends State<SendScreen> {
final destinationAddress = await _resolveDestinationAddress();
final amountText = _amountController.text;
- final amount = double.parse(amountText);
try {
// Estimate the fee per priority natively (no full tx build).
final fees = await Future.wait([
- wallet.estimateFee(destinationAddress, amount, priority: 1, amountText: amountText),
- wallet.estimateFee(destinationAddress, amount, priority: 2, amountText: amountText),
- wallet.estimateFee(destinationAddress, amount, priority: 3, amountText: amountText),
+ wallet.estimateFee(destinationAddress, amountText, priority: 1),
+ wallet.estimateFee(destinationAddress, amountText, priority: 2),
+ wallet.estimateFee(destinationAddress, amountText, priority: 3),
]);
// Only update state if this is still the latest request
@@ -398,7 +415,6 @@ class _SendScreenState extends State<SendScreen> {
}
final destinationAddressUnresolved = _destinationAddressController.text;
- final amount = double.parse(_amountController.text);
String destinationAddress = '';
String? destinationOpenAlias;
String? destinationOpenAliasName;
@@ -428,10 +444,9 @@ class _SendScreenState extends State<SendScreen> {
// screen are estimates, not tx objects, so always construct here).
final tx = await wallet.createTx(
destinationAddress,
- amount,
+ _amountController.text,
_isSweepAll,
priority: _selectedPriority + 1,
- amountText: _amountController.text,
);
setState(() {
@@ -448,7 +463,10 @@ class _SendScreenState extends State<SendScreen> {
}
} catch (error) {
if (error.toString().contains('Unlocked funds too low')) {
- if (wallet.unlockedBalance! > amount) {
+ // Display-only: picks which of two error messages to show, so the
+ // imprecision of a double cannot reach an amount anyone spends.
+ final approxAmount = double.tryParse(_amountController.text) ?? 0;
+ if (wallet.unlockedBalance! > approxAmount) {
setState(() {
_amountError = i18n.sendInsufficientBalanceToCoverFeeError;
});
@@ -550,7 +568,12 @@ class _SendScreenState extends State<SendScreen> {
void _setBalanceAsSendAmount() {
final wallet = appWalletOf(context);
- _amountController.text = _plainAmount(wallet.unlockedBalance ?? 0);
+ // From base units, not from the display double: Max fills a field that is
+ // about to be spent, and a balance over ~9007 XMR does not survive a double
+ // intact. Sweep-all normally makes the amount moot, but editing the field
+ // clears that flag and the number becomes the real amount.
+ final units = wallet.unlockedBalanceBaseUnits;
+ _amountController.text = units == null ? '' : baseUnitsToDecimalString(units, _xmrDecimals);
setState(() {
_isSweepAll = true;
@@ -579,17 +602,28 @@ class _SendScreenState extends State<SendScreen> {
await _revalidate();
}
+ /// The typed amount in piconero, or zero when the field is not a number.
+ BigInt _amountUnits() {
+ try {
+ return decimalToBaseUnits(_amountController.text, _xmrDecimals);
+ } on FormatException {
+ return BigInt.zero;
+ }
+ }
+
Future<void> _onAmountChanged() async {
final wallet = appWalletOf(context);
- final amount = double.tryParse(_amountController.text) ?? 0;
+ // Base units for the same reason as in `_validateForm`: `==` between two
+ // differently-rounded doubles decided whether this is a sweep.
+ final isFullBalance = _amountUnits() == wallet.unlockedBalanceBaseUnits;
- if (amount == wallet.unlockedBalance! && !_isSweepAll) {
+ if (isFullBalance && !_isSweepAll) {
setState(() {
_isSweepAll = true;
});
}
- if (amount != wallet.unlockedBalance! && _isSweepAll) {
+ if (!isFullBalance && _isSweepAll) {
setState(() {
_isSweepAll = false;
});
@@ -701,20 +735,6 @@ String _amountText(double amount) => amount.toStringAsFixed(5);
/// this file does not reach into the adapter's privates.
const _xmrDecimals = 12;
-/// [amount] as a plain decimal, never exponential.
-///
-/// `double.toString()` switches to exponential notation below 1e-6 -- a
-/// 500000-piconero balance renders as "5e-7". `decimalToBaseUnits` splits on
-/// '.' and hands the rest to `BigInt.parse`, which throws on an exponent, so
-/// the fee estimate fails and the field cannot be sent. Only the Max button
-/// fills this field from a raw double, which is why it only broke there.
-String _plainAmount(double amount) {
- final fixed = amount.toStringAsFixed(_xmrDecimals);
- if (!fixed.contains('.')) return fixed;
- final trimmed = fixed.replaceFirst(RegExp(r'0+$'), '');
- return trimmed.endsWith('.') ? trimmed.substring(0, trimmed.length - 1) : trimmed;
-}
-
/// `abcd…wxyz`: keeps [head] leading and [tail] trailing chars of a long
/// address, eliding the middle. Returns the string unchanged when short.
String _shortenMiddle(String value, {required int head, required int tail}) {
### lib/screens/settings.dart
@@ -155,13 +155,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
ExportLogsLabels(
title: i18n.settingsExportLogsLabel,
cancel: i18n.cancel,
- exportError: i18n.settingsExportLogsError,
+ exportError: i18n.settingsExportLogsFailed,
),
);
}
} catch (e) {
+ // Not "no logs found": the listing itself failed, which is a different
+ // thing to tell the user than an empty list.
if (mounted) {
- showBrandToast(context, i18n.settingsExportLogsError);
+ showBrandToast(context, i18n.settingsExportLogsFailed);
}
}
}
@@ -201,11 +203,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// unlocked. Reaching this screen hands over the seed and secret spend key,
/// which is total, irreversible control of the funds; the warning sheet alone
/// stops nobody holding the phone.
+ ///
+ /// Only when app lock is on, though -- turning it off is the user saying this
+ /// app does not ask the device who is holding the phone, and a prompt that
+ /// appears anyway reads as the setting being ignored.
Future<void> _showViewSecretKeysDialog() async {
final i18n = AppLocalizations.of(context)!;
if (Platform.isAndroid || Platform.isIOS) {
- final result = await BiometricAuth.authenticate(reason: i18n.revealSeedAuthReason);
+ final result = await BiometricAuth.authenticateIfAppLockEnabled(
+ reason: i18n.revealSeedAuthReason,
+ );
if (result != BiometricAuthResult.authenticated) {
if (mounted) {
showBrandToast(context, i18n.settingsAppLockUnableToAuthError);
### lib/services/foreground_sync_service.dart
@@ -6,7 +6,12 @@ import 'package:wallet_background/wallet_background.dart';
// The handler + service control live in wallet-core (`wallet_background`); the
// app keeps only the isolate entry point (see periodic_tasks.dart).
export 'package:wallet_background/wallet_background.dart'
- show startForegroundSync, stopForegroundSync, startForegroundSyncIfEnabled, isWalletFullySynced;
+ show
+ startForegroundSync,
+ stopForegroundSync,
+ startForegroundSyncIfEnabled,
+ isWalletFullySynced,
+ stopSyncAndDeleteWallets;
/// Foreground-service isolate entry: bootstrap this isolate, then hand off to
/// the shared handler. Top-level `@pragma` so it survives tree-shaking.
### lib/services/tor_service.dart
@@ -1,138 +1,11 @@
-import 'dart:async';
-import 'dart:io';
-
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:skylight_wallet/util/dirs.dart';
-import 'package:skylight_wallet/util/logging.dart';
-import 'package:tor_ffi_plugin/tor_ffi_plugin.dart';
-
-final pTorService = Provider((_) => TorService.sharedInstance);
-
-enum TorConnectionStatus { disconnected, connecting, connected }
-
-class TorService {
- Tor? _tor;
- String? _torDataDirPath;
- Future<void>? _startInFlight;
-
- /// Current status. Same as that fired on the event bus.
- TorConnectionStatus get status => _status;
- TorConnectionStatus _status = TorConnectionStatus.disconnected;
-
- /// Singleton instance of the TorService.
- ///
- /// Use this to access the TorService and its properties.
- static final sharedInstance = TorService._();
-
- // private constructor for singleton
- TorService._();
-
- /// Getter for the proxyInfo.
- ///
- /// Throws if Tor is not connected.
- ({InternetAddress host, int port}) getProxyInfo() {
- if (status == TorConnectionStatus.connected) {
- return (host: InternetAddress.loopbackIPv4, port: _tor!.port);
- } else {
- throw Exception("Tor proxy info fetched while not connected!");
- }
- }
-
- /// Start the Tor service.
- ///
- /// This will start the Tor service and establish a Tor circuit.
- ///
- /// Throws an exception if the Tor library was not inited or if the Tor
- /// service fails to start.
- ///
- /// Returns a Future that completes when the Tor service has started.
- Future<void> start() async {
- if (_status == TorConnectionStatus.connected) return;
-
- // Concurrent callers join the attempt already running rather than starting
- // a second Tor.
- final inFlight = _startInFlight;
- if (inFlight != null) return inFlight;
-
- final attempt = _start();
- _startInFlight = attempt;
-
- try {
- await attempt;
- } finally {
- if (identical(_startInFlight, attempt)) _startInFlight = null;
- }
- }
+import 'package:wallet_infra/wallet_infra.dart';
- Future<void> _start() async {
- _tor ??= Tor.instance;
- _torDataDirPath ??= (await getAppDir()).path;
+// TorService lives in wallet-core (wallet_infra); kept under this path so call
+// sites are unchanged. The copy that used to live here was the same logic with
+// a duplicated log line and a non-null-asserted `_tor!` in `disable()`.
+export 'package:wallet_infra/wallet_infra.dart' show TorService, TorConnectionStatus;
- // Start the Tor service.
- try {
- _status = TorConnectionStatus.connecting;
- await _tor!.start(torDataDirPath: _torDataDirPath!);
- _status = TorConnectionStatus.connected;
- return;
- } catch (e, s) {
- log(LogLevel.error, 'TorService.start failed');
- log(LogLevel.error, s.toString());
- log(LogLevel.error, s.toString());
-
- _status = TorConnectionStatus.disconnected;
-
- rethrow;
- }
- }
-
- Future<void> disable() async {
- if (_status == TorConnectionStatus.disconnected) {
- return;
- }
-
- _tor!.disable();
- await _tor?.stop();
- _status = TorConnectionStatus.disconnected;
-
- return;
- }
-
- /// Waits for Tor to come up, returning whether it did.
- ///
- /// Bounded, and it always cancels its poll timer. The previous version did
- /// neither: a Tor that never connected left a 50ms timer polling for the life
- /// of the isolate — one per call — and the future never completed at all, so
- /// a caller without its own timeout waited forever.
- ///
- /// A start attempt that failed earlier is retried here, since nothing else
- /// retries it and the app would otherwise stay wedged until a restart.
- Future<bool> waitUntilConnected({Duration timeout = const Duration(seconds: 60)}) async {
- if (status == TorConnectionStatus.connected) return true;
-
- if (_status == TorConnectionStatus.disconnected && _startInFlight == null) {
- log(LogLevel.info, 'Tor is not running; retrying start.');
- unawaited(start().catchError((Object e) => log(LogLevel.warn, 'Tor start retry failed: $e')));
- }
-
- final completer = Completer<bool>();
- Timer? poll;
- Timer? deadline;
-
- void finish(bool connected) {
- poll?.cancel();
- deadline?.cancel();
- if (!completer.isCompleted) completer.complete(connected);
- }
-
- poll = Timer.periodic(const Duration(milliseconds: 50), (_) {
- if (status == TorConnectionStatus.connected) finish(true);
- });
-
- deadline = Timer(timeout, () {
- log(LogLevel.warn, 'Gave up waiting for Tor after ${timeout.inSeconds}s.');
- finish(false);
- });
-
- return completer.future;
- }
-}
+/// Riverpod handle for the shared service. Stays app-side: wallet-core does not
+/// depend on riverpod, so apps wrap the singleton themselves.
+final pTorService = Provider((_) => TorService.sharedInstance);
### lib/services/tor_settings_service.dart
@@ -1,99 +1,3 @@
-import 'dart:io';
-
-import 'package:skylight_wallet/services/shared_preferences_service.dart';
-import 'package:skylight_wallet/services/tor_service.dart';
-
-enum TorMode { builtIn, external, disabled }
-
-class TorSettingsService {
- static final TorSettingsService sharedInstance = TorSettingsService._();
-
- TorMode _torMode = TorMode.builtIn;
- String _socksPort = '9050';
- bool _useOrbot = false;
-
- TorMode get torMode => _torMode;
- String get socksPort => _socksPort;
- bool get useOrbot => _useOrbot;
-
- TorSettingsService._();
-
- Future<void> loadSettings() async {
- final String? torModeString = await SharedPreferencesService.get<String>(
- SharedPreferencesKeys.torMode,
- );
- final String? socksPortString = await SharedPreferencesService.get<String>(
- SharedPreferencesKeys.torSocksPort,
- );
- final bool? useOrbotValue = await SharedPreferencesService.get<bool>(
- SharedPreferencesKeys.torUseOrbot,
- );
-
- if (torModeString != null) {
- _torMode = _torModeFromString(torModeString);
- }
-
- if (socksPortString != null) {
- _socksPort = socksPortString;
- }
-
- if (useOrbotValue != null) {
- _useOrbot = useOrbotValue;
- }
- }
-
- Future<void> save({required TorMode torMode, String? socksPort, bool? useOrbot}) async {
- _torMode = torMode;
- await SharedPreferencesService.set<String>(
- SharedPreferencesKeys.torMode,
- _torModeToString(torMode),
- );
-
- if (socksPort != null) {
- _socksPort = socksPort;
- await SharedPreferencesService.set<String>(SharedPreferencesKeys.torSocksPort, socksPort);
- }
-
- if (useOrbot != null) {
- _useOrbot = useOrbot;
- await SharedPreferencesService.set<bool>(SharedPreferencesKeys.torUseOrbot, useOrbot);
- }
- }
-
- Future<({InternetAddress host, int port})?> getProxy() async {
- if (_torMode == TorMode.builtIn) {
- // No proxy rather than a hang: callers treat null as "Tor unavailable"
- // and refuse to connect, instead of waiting forever.
- if (!await TorService.sharedInstance.waitUntilConnected()) return null;
- return TorService.sharedInstance.getProxyInfo();
- } else if (_torMode == TorMode.external) {
- return (host: InternetAddress.loopbackIPv4, port: int.parse(_socksPort));
- } else {
- return null;
- }
- }
-
- String _torModeToString(TorMode mode) {
- switch (mode) {
- case TorMode.builtIn:
- return 'builtIn';
- case TorMode.external:
- return 'external';
- case TorMode.disabled:
- return 'disabled';
- }
- }
-
- TorMode _torModeFromString(String modeString) {
- switch (modeString) {
- case 'builtIn':
- return TorMode.builtIn;
- case 'external':
- return TorMode.external;
- case 'disabled':
- return TorMode.disabled;
- default:
- return TorMode.builtIn;
- }
- }
-}
+// TorSettingsService and TorMode live in wallet-core (wallet_infra); kept under
+// this path so call sites are unchanged.
+export 'package:wallet_infra/wallet_infra.dart' show TorSettingsService, TorMode;
### lib/util/contacts_store.dart
@@ -1,84 +1,5 @@
-import 'dart:convert';
-
-import 'package:shared_preferences/shared_preferences.dart';
-
-import 'package:skylight_wallet/services/shared_preferences_service.dart';
-import 'package:skylight_wallet/util/logging.dart';
-import 'package:skylight_wallet/util/secure_storage.dart';
-
-/// Storage for the address book.
-///
-/// Contacts are names attached to Monero addresses — the user's counterparties
-/// — so they live in secure storage rather than the plaintext preferences file.
-/// Earlier builds kept them in preferences; [readEncodedContacts] moves those
-/// across on first read and deletes the plaintext copy.
-///
-/// Entries are the JSON strings the contact model already encodes, held as one
-/// JSON array, because secure storage stores strings and not lists.
-const _storageKey = 'contacts';
-
-/// Reads the address book, or null if it could not be read.
-///
-/// Null is not the same as empty, and callers must not treat it as such: an
-/// unreadable store that reads as "no contacts" would be overwritten with an
-/// empty list by the next save, losing the address book for good.
-Future<List<String>?> readEncodedContacts() async {
- String? stored;
-
- try {
- stored = await secureStorage.read(key: _storageKey);
- } catch (e) {
- log(LogLevel.error, 'Could not read contacts: $e');
- return null;
- }
-
- if (stored == null) return _migrateFromPreferences();
-
- if (stored.isEmpty) return [];
-
- try {
- return (json.decode(stored) as List<dynamic>).cast<String>();
- } catch (e) {
- log(LogLevel.error, 'Contacts are unreadable: $e');
- return null;
- }
-}
-
-Future<void> writeEncodedContacts(List<String> contacts) async {
- await secureStorage.write(key: _storageKey, value: json.encode(contacts));
-}
-
-Future<void> clearContacts() async {
- try {
- await secureStorage.delete(key: _storageKey);
- } catch (e) {
- log(LogLevel.error, 'Could not clear contacts: $e');
- }
-
- // Also drop anything a build that predates the move left behind.
- await SharedPreferencesService.remove(SharedPreferencesKeys.contacts);
-}
-
-/// Moves an address book written by an earlier build out of shared preferences.
-///
-/// The plaintext copy is only deleted once the secure copy is safely written —
-/// if that fails the contacts are still returned and still in preferences, and
-/// the next launch tries again.
-Future<List<String>?> _migrateFromPreferences() async {
- final prefs = await SharedPreferences.getInstance();
- final legacy = prefs.getStringList(SharedPreferencesKeys.contacts);
-
- if (legacy == null) return [];
-
- try {
- await writeEncodedContacts(legacy);
- } catch (e) {
- log(LogLevel.error, 'Could not move contacts to secure storage: $e');
- return legacy;
- }
-
- await prefs.remove(SharedPreferencesKeys.contacts);
- log(LogLevel.info, 'Moved ${legacy.length} contacts out of shared preferences');
-
- return legacy;
-}
+// The address book store lives in wallet-core (wallet_domain), shared with
+// Spice; kept under this path so call sites are unchanged. It carries the
+// shared-preferences migration this app needed.
+export 'package:wallet_domain/wallet_domain.dart'
+ show readEncodedContacts, writeEncodedContacts, clearContacts;
### lib/util/get_height_by_date.dart
@@ -1,185 +1,3 @@
-// From https://github.com/cake-tech/cake_wallet/blob/main/cw_core/lib/get_height_by_date.dart
-
-import 'package:intl/intl.dart';
-import 'package:skylight_wallet/util/logging.dart';
-
-final dateFormat = DateFormat('yyyy-MM');
-
-final dates = {
- "2014-4": 0,
- "2014-5": 18844,
- "2014-6": 65406,
- "2014-7": 108882,
- "2014-8": 153594,
- "2014-9": 198072,
- "2014-10": 241088,
- "2014-11": 285305,
- "2014-12": 328069,
- "2015-1": 372369,
- "2015-2": 416505,
- "2015-3": 456631,
- "2015-4": 501084,
- "2015-5": 543973,
- "2015-6": 588326,
- "2015-7": 631187,
- "2015-8": 675484,
- "2015-9": 719725,
- "2015-10": 762463,
- "2015-11": 806528,
- "2015-12": 849041,
- "2016-1": 892866,
- "2016-2": 936736,
- "2016-3": 977691,
- "2016-4": 1015848,
- "2016-5": 1037417,
- "2016-6": 1059651,
- "2016-7": 1081269,
- "2016-8": 1103630,
- "2016-9": 1125983,
- "2016-10": 1147617,
- "2016-11": 1169779,
- "2016-12": 1191402,
- "2017-1": 1213861,
- "2017-2": 1236197,
- "2017-3": 1256358,
- "2017-4": 1278622,
- "2017-5": 1300239,
- "2017-6": 1322564,
- "2017-7": 1344225,
- "2017-8": 1366664,
- "2017-9": 1389113,
- "2017-10": 1410738,
- "2017-11": 1433039,
- "2017-12": 1454639,
- "2018-1": 1477201,
- "2018-2": 1499599,
- "2018-3": 1519796,
- "2018-4": 1542067,
- "2018-5": 1562861,
- "2018-6": 1585135,
- "2018-7": 1606715,
- "2018-8": 1629017,
- "2018-9": 1651347,
- "2018-10": 1673031,
- "2018-11": 1695128,
- "2018-12": 1716687,
- "2019-1": 1738923,
- "2019-2": 1761435,
- "2019-3": 1781681,
- "2019-4": 1803081,
- "2019-5": 1824671,
- "2019-6": 1847005,
- "2019-7": 1868590,
- "2019-8": 1890552,
- "2019-9": 1912212,
- "2019-10": 1932200,
- "2019-11": 1957040,
- "2019-12": 1978090,
- "2020-1": 2001290,
- "2020-2": 2022688,
- "2020-3": 2043987,
- "2020-4": 2066536,
- "2020-5": 2090797,
- "2020-6": 2111633,
- "2020-7": 2131433,
- "2020-8": 2153983,
- "2020-9": 2176466,
- "2020-10": 2198453,
- "2020-11": 2220000,
- "2020-12": 2242240,
- "2021-1": 2264584,
- "2021-2": 2286892,
- "2021-3": 2307079,
- "2021-4": 2329385,
- "2021-5": 2351004,
- "2021-6": 2373306,
- "2021-7": 2394882,
- "2021-8": 2417162,
- "2021-9": 2439490,
- "2021-10": 2461020,
- "2021-11": 2483377,
- "2021-12": 2504932,
- "2022-1": 2527316,
- "2022-2": 2549605,
- "2022-3": 2569711,
- "2022-4": 2591995,
- "2022-5": 2613603,
- "2022-6": 2635840,
- "2022-7": 2657395,
- "2022-8": 2679705,
- "2022-9": 2701991,
- "2022-10": 2723607,
- "2022-11": 2745899,
- "2022-12": 2767427,
- "2023-1": 2789763,
- "2023-2": 2811996,
- "2023-3": 2832118,
- "2023-4": 2854365,
- "2023-5": 2875972,
- "2023-6": 2898234,
- "2023-7": 2919771,
- "2023-8": 2942045,
- "2023-9": 2964280,
- "2023-10": 2985937,
- "2023-11": 3008178,
- "2023-12": 3029759,
- "2024-1": 3051991,
- "2024-2": 3074316,
- "2024-3": 3095123,
- "2024-4": 3117427,
- "2024-5": 3139022,
- "2024-6": 3161279,
- "2024-7": 3182945,
- "2024-8": 3205207,
- "2024-9": 3227566,
- "2024-10": 3249124,
- "2024-11": 3271454,
- "2024-12": 3293087,
- "2025-1": 3315383,
- "2025-2": 3337734,
- "2025-3": 3357843,
- "2025-4": 3380178,
- "2025-5": 3401714,
- "2025-6": 3424052,
- "2025-7": 3445678,
- "2025-8": 3467960,
- "2025-9": 3490175,
- "2025-10": 3511714,
- "2025-11": 3533988,
- "2025-12": 3555615,
-};
-
-int getHeightByDate({required DateTime date}) {
- final raw = '${date.year}-${date.month}';
- final lastHeight = dates.values.last;
- int startHeight;
- int endHeight;
- int height = 0;
-
- try {
- if ((dates[raw] == null) || (dates[raw] == lastHeight)) {
- startHeight = dates.values.toList()[dates.length - 2];
- endHeight = dates.values.toList()[dates.length - 1];
- final heightPerDay = (endHeight - startHeight) / 31;
- final endDateRaw = dates.keys.toList()[dates.length - 1].split('-');
- final endYear = int.parse(endDateRaw[0]);
- final endMonth = int.parse(endDateRaw[1]);
- final endDate = DateTime(endYear, endMonth);
- final differenceInDays = date.difference(endDate).inDays;
- final daysHeight = (differenceInDays * heightPerDay).round();
- height = endHeight + daysHeight;
- } else {
- startHeight = dates[raw]!;
- final index = dates.values.toList().indexOf(startHeight);
- endHeight = dates.values.toList()[index + 1];
- final heightPerDay = ((endHeight - startHeight) / 31).round();
- final daysHeight = (date.day - 1) * heightPerDay;
- height = startHeight + daysHeight - heightPerDay;
- }
- } catch (e) {
- log(LogLevel.error, e.toString());
- rethrow;
- }
-
- return height;
-}
+// getHeightByDate lives in wallet-core (wallet_monero); kept under this path so
+// the call sites in restore_wallet.dart are unchanged.
+export 'package:wallet_monero/wallet_monero.dart' show getHeightByDate;
### lib/util/logging.dart
@@ -1,4 +1,4 @@
-// The whole logger lives in wallet-core (wallet_infra, D25): the log() API +
+// The whole logger lives in wallet-core (wallet_infra): 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.
### lib/util/secure_clipboard.dart
@@ -1,5 +1,5 @@
// SecureClipboard lives in wallet-core (`wallet_infra`) with a fixed, app-neutral
-// MethodChannel name (`org.magicgrants.wallet/secure_clipboard`, per D10). Kept
+// MethodChannel name (`org.magicgrants.wallet/secure_clipboard`). Kept
// under the same import path so call sites are unchanged; the native handler is
// registered under that name in MainActivity.kt / AppDelegate.swift.
export 'package:wallet_infra/wallet_infra.dart' show SecureClipboard;
### lib/wallet_core_glue.dart
@@ -9,7 +9,8 @@ import 'package:skylight_wallet/models/fiat_rate_model.dart';
import 'package:skylight_wallet/models/monero_wallet_adapter.dart';
import 'package:skylight_wallet/widgets/tx_details.dart' show TxDetailsDialog;
import 'package:skylight_wallet/periodic_tasks.dart' show backgroundDispatcher;
-import 'package:skylight_wallet/services/foreground_sync_service.dart' show foregroundSyncCallback;
+import 'package:skylight_wallet/services/foreground_sync_service.dart'
+ show foregroundSyncCallback, stopSyncAndDeleteWallets;
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';
@@ -61,7 +62,7 @@ void installWalletCore() {
FiatRates.install(getTorProxy: TorSettingsService.sharedInstance.getProxy);
- // The whole logger lives in wallet-core now (D25): console + file sinks fan out
+ // The whole logger lives in wallet-core now: console + file sinks fan out
// from one installed sink; the file sink is verbose-gated internally.
wcore.WalletLog.sink = wcore.CompositeLogSink([
const wcore.DebugPrintLogSink(),
@@ -98,6 +99,14 @@ void installWalletCore() {
/// wallet connects through. (Background open + the node/Tor gate now live inside
/// `wallet_background`.)
Future<bool> _ensureTorConnected() async {
+ final settings = TorSettingsService.sharedInstance;
+ await settings.ensureLoaded();
+ if (settings.torMode != TorMode.builtIn) {
+ // External: the proxy is the user's, and `getProxy` fails closed if it is
+ // not usable. Disabled: there is no Tor to report.
+ return settings.torMode == TorMode.external;
+ }
+
await TorService.sharedInstance.start();
return TorService.sharedInstance.waitUntilConnected(timeout: const Duration(minutes: 2));
}
@@ -155,7 +164,7 @@ AppWallet appWalletOf(BuildContext context, {bool listen = false}) {
CryptoWallet? xmrWallet(BuildContext context) =>
Provider.of<WalletManager>(context, listen: false).getWallet('XMR');
-/// Shows the shared tx-details sheet (`wallet_ui`, D24) for [tx] from the tx
+/// Shows the shared tx-details sheet (`wallet_ui`) for [tx] from the tx
/// list. The activity list now renders the engine's wallet_domain TxDetails
/// directly, so no neutral-to-engine bridge is needed.
void showTxDetailsDialog(BuildContext context, TxDetails tx) {
@@ -230,10 +239,15 @@ Future<void> unlockWithPassword(BuildContext context, String password) async {
}
/// Deletes the wallet and everything derived from it.
+///
+/// Through wallet-core's teardown rather than a bare `deleteAll`: the
+/// foreground service holds its own wallet2 instance open on these files in its
+/// own isolate, and deleting them while it runs leaves it syncing -- and
+/// rewriting -- a wallet the user just deleted.
Future<void> deleteWallet(BuildContext context) async {
// TODO(wallet-core): pass skylight's own pref keys (contacts, pending tx,
// notification state) once the delete path is validated on device.
- await Provider.of<WalletManager>(context, listen: false).deleteAll();
+ await stopSyncAndDeleteWallets(Provider.of<WalletManager>(context, listen: false));
}
/// Rebuilds the wallet if the server kind (LWS↔node) changed, then resyncs.
### lib/widgets/connection_settings_form.dart
@@ -256,6 +256,34 @@ class _ConnectionSettingsFormState extends State<ConnectionSettingsForm> {
_errorMessage = null;
});
if (value == 'node' && Platform.isAndroid) _loadSyncPrefs();
+ _loadConnectionForType(value);
+ }
+
+ /// Swaps the fields to the server saved for [type].
+ ///
+ /// Servers are stored per connection type, so selecting a mode selects that
+ /// mode's server. Leaving the previous mode's address in the field is how an
+ /// LWS connection could be saved pointing at a Monero node, which would hand
+ /// the node the private view key.
+ ///
+ /// A mode with nothing saved clears the field rather than inheriting the other
+ /// one's address. `_initial*` is deliberately not updated: switching mode is a
+ /// change, so Save stays gated on a passing test.
+ Future<void> _loadConnectionForType(String type) async {
+ final wallet = appWalletOf(context);
+ final conn = await wallet.getPersistedConnectionForType(type);
+ // The user may have tapped another type while this was in flight.
+ if (!mounted || _connectionType != type) return;
+
+ final useTor = conn.useTor && TorSettingsService.sharedInstance.torMode != TorMode.disabled;
+ setState(() {
+ _addressController.text = conn.address;
+ _customProxyPortController.text = conn.proxyPort;
+ _useTor = useTor;
+ _hasTested = false;
+ _connectionSuccess = false;
+ _errorMessage = null;
+ });
}
String _connectionTypeLabel(AppLocalizations i18n, String type) {
### lib/widgets/language_sheet.dart
@@ -12,15 +12,21 @@ const languageNames = {'en': ('English', 'English'), 'pt': ('Português', 'Portu
/// (welcome has no route to Settings, so a reader who can't read it can switch
/// here before anything else).
void showLanguageSheet(BuildContext context) {
- final i18n = AppLocalizations.of(context)!;
final language = context.read<LanguageModel>();
showLanguagePickerSheet(
context,
- labels: SettingsPickerLabels(
- title: i18n.settingsLanguageLabel,
- subtitle: i18n.settingsLanguageSheetSubtitle,
- done: i18n.done,
- ),
+ // Resolved per build from the sheet's own context rather than captured
+ // here: picking a language re-localizes the app while this sheet is still
+ // the thing on screen, and a snapshot would leave it in the old language --
+ // looking for all the world like the setting did not take.
+ labels: (context) {
+ final i18n = AppLocalizations.of(context)!;
+ return SettingsPickerLabels(
+ title: i18n.settingsLanguageLabel,
+ subtitle: i18n.settingsLanguageSheetSubtitle,
+ done: i18n.done,
+ );
+ },
options: [
for (final locale in AppLocalizations.supportedLocales)
LanguagePickerOption(
### lib/widgets/tx_details.dart
@@ -5,7 +5,7 @@ import 'package:wallet_domain/wallet_domain.dart' show CryptoWallet, TxDetails;
import 'package:wallet_ui/wallet_ui.dart' show TxDetailsSheetLabels, showTxDetailsSheet;
/// The tx-details popup is the shared brand bottom sheet in wallet-core
-/// (`wallet_ui`, D24), localization-agnostic. This adapter keeps the app's call
+/// (`wallet_ui`), localization-agnostic. This adapter keeps the app's call
/// site unchanged and supplies Skylight's translated strings (incl. the status
/// banner).
class TxDetailsDialog {
### lib/widgets/ui/ui.dart
@@ -1,5 +1,5 @@
/// Skylight's brand widget set — the shared design tokens + primitives from the
-/// `wallet_ui` package (D24). This barrel re-exports them so screens import one
+/// `wallet_ui` package. This barrel re-exports them so screens import one
/// path.
library;
### pubspec.lock
@@ -1233,62 +1233,62 @@ packages:
dependency: "direct main"
description:
path: "packages/wallet_background"
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
- resolved-ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
+ resolved-ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
url: "https://github.com/MAGICGrants/wallet-core"
source: git
version: "0.1.0"
wallet_domain:
dependency: "direct main"
description:
path: "packages/wallet_domain"
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
- resolved-ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
+ resolved-ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
url: "https://github.com/MAGICGrants/wallet-core"
source: git
version: "0.1.0"
wallet_fiat:
dependency: "direct main"
description:
path: "packages/wallet_fiat"
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
- resolved-ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
+ resolved-ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
url: "https://github.com/MAGICGrants/wallet-core"
source: git
version: "0.1.0"
wallet_infra:
dependency: "direct main"
description:
path: "packages/wallet_infra"
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
- resolved-ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
+ resolved-ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
url: "https://github.com/MAGICGrants/wallet-core"
source: git
version: "0.1.0"
wallet_monero:
dependency: "direct main"
description:
path: "packages/wallet_monero"
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
- resolved-ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
+ resolved-ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
url: "https://github.com/MAGICGrants/wallet-core"
source: git
version: "0.1.0"
wallet_openalias:
dependency: "direct main"
description:
path: "packages/wallet_openalias"
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
- resolved-ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
+ resolved-ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
url: "https://github.com/MAGICGrants/wallet-core"
source: git
version: "0.1.0"
wallet_ui:
dependency: "direct main"
description:
path: "packages/wallet_ui"
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
- resolved-ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
+ resolved-ref: "6860cbd8eaca4c2e1f9b1487ec8292592fdc205f"
url: "https://github.com/MAGICGrants/wallet-core"
source: git
version: "0.1.0"
### pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
-version: 2.0.0+406
+version: 2.0.0+407
environment:
sdk: 3.11.5
@@ -65,37 +65,37 @@ dependencies:
wallet_infra:
git:
url: https://github.com/MAGICGrants/wallet-core
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: 6860cbd8eaca4c2e1f9b1487ec8292592fdc205f
path: packages/wallet_infra
wallet_domain:
git:
url: https://github.com/MAGICGrants/wallet-core
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: 6860cbd8eaca4c2e1f9b1487ec8292592fdc205f
path: packages/wallet_domain
wallet_monero:
git:
url: https://github.com/MAGICGrants/wallet-core
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: 6860cbd8eaca4c2e1f9b1487ec8292592fdc205f
path: packages/wallet_monero
wallet_openalias:
git:
url: https://github.com/MAGICGrants/wallet-core
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: 6860cbd8eaca4c2e1f9b1487ec8292592fdc205f
path: packages/wallet_openalias
wallet_background:
git:
url: https://github.com/MAGICGrants/wallet-core
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: 6860cbd8eaca4c2e1f9b1487ec8292592fdc205f
path: packages/wallet_background
wallet_fiat:
git:
url: https://github.com/MAGICGrants/wallet-core
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: 6860cbd8eaca4c2e1f9b1487ec8292592fdc205f
path: packages/wallet_fiat
wallet_ui:
git:
url: https://github.com/MAGICGrants/wallet-core
- ref: e3705b00945e83eec3ab144b8cd659c906e305e0
+ ref: 6860cbd8eaca4c2e1f9b1487ec8292592fdc205f
path: packages/wallet_ui
bip39:
### test/contacts_store_test.dart
@@ -1,116 +0,0 @@
-import 'dart:convert';
-
-import 'package:flutter_secure_storage/flutter_secure_storage.dart';
-import 'package:flutter_test/flutter_test.dart';
-import 'package:shared_preferences/shared_preferences.dart';
-
-import 'package:skylight_wallet/models/contact_model.dart';
-import 'package:skylight_wallet/util/contacts_store.dart';
-
-/// The address book holds the user's counterparties, so it belongs in secure
-/// storage — and moving it there must not lose anyone's contacts.
-void main() {
- TestWidgetsFlutterBinding.ensureInitialized();
-
- String encoded(String id, String name, String address) =>
- json.encode({'id': id, 'name': name, 'address': address});
-
- Future<List<String>?> secureContacts() async {
- final raw = await const FlutterSecureStorage().read(key: 'contacts');
- return raw == null ? null : (json.decode(raw) as List<dynamic>).cast<String>();
- }
-
- Future<List<String>?> plaintextContacts() async {
- final prefs = await SharedPreferences.getInstance();
- return prefs.getStringList('contacts');
- }
-
- setUp(() {
- SharedPreferences.setMockInitialValues({});
- FlutterSecureStorage.setMockInitialValues({});
- });
-
- test('a new contact is written to secure storage, not preferences', () async {
- final model = ContactModel();
- await model.load();
-
- await model.addContact('Alice', '4AliceAddress');
-
- expect(await secureContacts(), hasLength(1));
- expect(await plaintextContacts(), isNull);
-
- final prefs = await SharedPreferences.getInstance();
- expect(prefs.getKeys(), isEmpty, reason: 'nothing about contacts in plaintext');
- });
-
- test('contacts written by an older build are migrated and the plaintext copy removed', () async {
- SharedPreferences.setMockInitialValues({
- 'contacts': [encoded('1', 'Alice', '4Alice'), encoded('2', 'Bob', '4Bob')],
- });
-
- final model = ContactModel();
- await model.load();
-
- expect(model.contacts.map((c) => c.name), ['Alice', 'Bob']);
- expect(await secureContacts(), hasLength(2));
- expect(await plaintextContacts(), isNull, reason: 'the plaintext copy must be deleted');
- });
-
- test('migration runs once and the secure copy wins afterwards', () async {
- SharedPreferences.setMockInitialValues({
- 'contacts': [encoded('1', 'Alice', '4Alice')],
- });
-
- await (ContactModel()..load()).load();
-
- // A stale plaintext entry reappearing must not override what is already in
- // secure storage.
- final prefs = await SharedPreferences.getInstance();
- await prefs.setStringList('contacts', [encoded('9', 'Impostor', '4Impostor')]);
-
- final second = ContactModel();
- await second.load();
-
- expect(second.contacts.map((c) => c.name), ['Alice']);
- });
-
- test('an unreadable address book is not overwritten by an empty one', () async {
- FlutterSecureStorage.setMockInitialValues({'contacts': 'not json'});
-
- final model = ContactModel();
- await model.load();
-
- expect(model.isUnreadable, isTrue);
- expect(model.contacts, isEmpty);
-
- // A save triggered while in that state would otherwise replace the stored
- // address book with the empty in-memory one.
- await model.addContact('Alice', '4Alice');
-
- expect(await const FlutterSecureStorage().read(key: 'contacts'), 'not json');
- });
-
- test('clearContacts removes both copies', () async {
- SharedPreferences.setMockInitialValues({
- 'contacts': [encoded('1', 'Alice', '4Alice')],
- });
- FlutterSecureStorage.setMockInitialValues({
- 'contacts': json.encode([encoded('1', 'Alice', '4Alice')]),
- });
-
- await clearContacts();
-
- expect(await secureContacts(), isNull);
- expect(await plaintextContacts(), isNull);
- });
-
- test('an empty address book reads as empty, not as unreadable', () async {
- FlutterSecureStorage.setMockInitialValues({'contacts': json.encode(<String>[])});
-
- final model = ContactModel();
- await model.load();
-
- expect(model.isUnreadable, isFalse);
- expect(model.contacts, isEmpty);
- });
-}
### test/language_sheet_test.dart
@@ -0,0 +1,76 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:provider/provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'package:skylight_wallet/l10n/app_localizations.dart';
+import 'package:skylight_wallet/models/language_model.dart';
+import 'package:skylight_wallet/widgets/language_sheet.dart';
+
+/// Picking a language re-localizes the app while the picker is still the thing
+/// on screen. The sheet used to be handed strings snapshotted before it opened,
+/// so it stayed in the old language and the change looked like it had not taken
+/// -- the screen behind it had already updated, but the sheet was covering it.
+class _Root extends StatelessWidget {
+ const _Root();
+
+ @override
+ Widget build(BuildContext context) {
+ final language = context.watch<LanguageModel>();
+ return MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ locale: Locale.fromSubtags(languageCode: language.language),
+ home: Builder(
+ builder: (context) => Scaffold(
+ body: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text('behind:${AppLocalizations.of(context)!.settingsLanguageLabel}'),
+ ElevatedButton(
+ onPressed: () => showLanguageSheet(context),
+ child: const Text('open'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+void main() {
+ testWidgets('the picker re-localizes itself when a language is chosen', (tester) async {
+ SharedPreferences.setMockInitialValues({});
+
+ await tester.pumpWidget(
+ ChangeNotifierProvider<LanguageModel>(create: (_) => LanguageModel(), child: const _Root()),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('open'));
+ await tester.pumpAndSettle();
+
+ // English chrome: title, subtitle and the Done button.
+ expect(find.text('Language'), findsOneWidget);
+ expect(find.text('Pick your language and localization.'), findsOneWidget);
+ expect(find.text('Done'), findsOneWidget);
+
+ await tester.tap(find.text('Português'));
+ await tester.pumpAndSettle();
+
+ // The sheet is still open, and now speaks Portuguese.
+ expect(find.text('Idioma'), findsOneWidget);
+ expect(find.text('Language'), findsNothing);
+ expect(find.text('Done'), findsNothing);
+
+ // The locale names stay as they are -- each listed in its own language.
+ expect(find.text('English'), findsWidgets);
+ expect(find.text('Português'), findsOneWidget);
+
+ // And the screen underneath followed too.
+ expect(find.text('behind:Idioma'), findsOneWidget);
+ });
+}Why this scored 61/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.