diff --git a/lib/pages_desktop_specific/address_book_view/desktop_address_book.dart b/lib/pages_desktop_specific/address_book_view/desktop_address_book.dart index a4e767048b..944aed6be7 100644 --- a/lib/pages_desktop_specific/address_book_view/desktop_address_book.dart +++ b/lib/pages_desktop_specific/address_book_view/desktop_address_book.dart @@ -13,8 +13,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; -import '../../models/isar/models/blockchain_data/address.dart'; -import '../../models/isar/models/contact_entry.dart'; import '../../pages/address_book_views/subviews/add_address_book_entry_view.dart'; import '../../pages/address_book_views/subviews/address_book_filter_view.dart'; import '../../providers/global/address_book_service_provider.dart'; @@ -26,7 +24,6 @@ import '../../utilities/constants.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; -import '../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../widgets/address_book_card.dart'; import '../../widgets/desktop/desktop_app_bar.dart'; import '../../widgets/desktop/desktop_dialog.dart'; @@ -119,35 +116,9 @@ class _DesktopAddressBook extends ConsumerState { // } WidgetsBinding.instance.addPostFrameCallback((_) async { - final List addresses = []; - final wallets = ref.read(pWallets).wallets; - for (final wallet in wallets) { - final String addressString; - if (wallet is SparkInterface && - !(wallet.isViewOnly && wallet.viewOnlyType != .spark)) { - Address? address = await wallet.getCurrentReceivingSparkAddress(); - address ??= await wallet.generateNextSparkAddress(saveToDB: true); - addressString = address.value; - } else { - final address = await wallet.getCurrentReceivingAddress(); - addressString = address?.value ?? wallet.info.cachedReceivingAddress; - } - - addresses.add( - ContactAddressEntry() - ..coinName = wallet.info.coin.identifier - ..address = addressString - ..label = "Current Receiving" - ..other = wallet.info.name, - ); - } - final self = ContactEntry( - name: "My ${AppConfig.prefix}", - addresses: addresses, - isFavorite: true, - customId: "default", - ); - await ref.read(addressBookServiceProvider).editContact(self); + await ref + .read(addressBookServiceProvider) + .updateMyStackContact(ref.read(pWallets).wallets); }); super.initState(); @@ -286,9 +257,9 @@ class _DesktopAddressBook extends ConsumerState { buttonHeight: ButtonHeight.l, icon: SvgPicture.asset( Assets.svg.filter, - color: Theme.of( - context, - ).extension()!.buttonTextSecondary, + color: Theme.of(context) + .extension()! + .buttonTextSecondary, ), onPressed: selectCryptocurrency, ), @@ -299,9 +270,9 @@ class _DesktopAddressBook extends ConsumerState { buttonHeight: ButtonHeight.l, icon: SvgPicture.asset( Assets.svg.circlePlus, - color: Theme.of( - context, - ).extension()!.buttonTextPrimary, + color: Theme.of(context) + .extension()! + .buttonTextPrimary, ), onPressed: newContact, ), @@ -343,9 +314,9 @@ class _DesktopAddressBook extends ConsumerState { children: [ if (i > 0) Container( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, height: 1, ), Padding( @@ -414,9 +385,9 @@ class _DesktopAddressBook extends ConsumerState { children: [ if (i > 0) Container( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, height: 1, ), Padding( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/address_book_address_chooser/address_book_address_chooser.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/address_book_address_chooser/address_book_address_chooser.dart index 5e1252be4c..11fb09e7c4 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/address_book_address_chooser/address_book_address_chooser.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/address_book_address_chooser/address_book_address_chooser.dart @@ -14,6 +14,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../../../../../models/isar/models/contact_entry.dart'; import '../../../../../providers/global/address_book_service_provider.dart'; +import '../../../../../providers/providers.dart'; import '../../../../../themes/stack_colors.dart'; import '../../../../../utilities/assets.dart'; import '../../../../../utilities/constants.dart'; @@ -25,20 +26,18 @@ import '../../../../../widgets/stack_text_field.dart'; import '../../../../../widgets/textfield_icon_button.dart'; import 'sub_widgets/contact_list_item.dart'; -class AddressBookAddressChooser extends StatefulWidget { - const AddressBookAddressChooser({ - super.key, - this.coin, - }); +class AddressBookAddressChooser extends ConsumerStatefulWidget { + const AddressBookAddressChooser({super.key, this.coin}); final CryptoCurrency? coin; @override - State createState() => + ConsumerState createState() => _AddressBookAddressChooserState(); } -class _AddressBookAddressChooserState extends State { +class _AddressBookAddressChooserState + extends ConsumerState { late final bool isDesktop; late final TextEditingController _searchController; late final FocusNode searchFieldFocusNode; @@ -109,6 +108,16 @@ class _AddressBookAddressChooserState extends State { isDesktop = Util.isDesktop; searchFieldFocusNode = FocusNode(); _searchController = TextEditingController(); + + // the "My Stack" contact is otherwise only created by the address book + // view, so make sure it exists and is current before choosing from it + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await ref + .read(addressBookServiceProvider) + .updateMyStackContact(ref.read(pWallets).wallets); + }); + super.initState(); } @@ -125,9 +134,7 @@ class _AddressBookAddressChooserState extends State { children: [ // search field Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - ), + padding: const EdgeInsets.symmetric(horizontal: 32), child: ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -150,63 +157,59 @@ class _AddressBookAddressChooserState extends State { height: 1.8, ) : STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - searchFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: EdgeInsets.symmetric( - horizontal: isDesktop ? 12 : 10, - vertical: isDesktop ? 18 : 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: isDesktop ? 20 : 16, - height: isDesktop ? 20 : 16, - ), - ), - suffixIcon: _searchController.text.isNotEmpty - ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchController.text = ""; - _searchTerm = ""; - }); - }, + decoration: + standardInputDecoration( + "Search", + searchFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 12 : 10, + vertical: isDesktop ? 18 : 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: isDesktop ? 20 : 16, + height: isDesktop ? 20 : 16, + ), + ), + suffixIcon: _searchController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchController.text = ""; + _searchTerm = ""; + }); + }, + ), + ], ), - ], - ), - ), - ) - : null, - ), + ), + ) + : null, + ), ), ), ), - const SizedBox( - height: 16, - ), + const SizedBox(height: 16), Expanded( child: Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, - ), + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), child: Consumer( builder: (context, ref, _) { List contacts = ref .watch( - addressBookServiceProvider - .select((value) => value.contacts), + addressBookServiceProvider.select( + (value) => value.contacts, + ), ) .toList(); @@ -214,7 +217,8 @@ class _AddressBookAddressChooserState extends State { final favorites = pullOutFavorites(contacts); - final totalLength = favorites.length + + final totalLength = + favorites.length + contacts.length + 2; // +2 for "fav" and "all" headers @@ -223,9 +227,7 @@ class _AddressBookAddressChooserState extends State { shrinkWrap: true, itemCount: totalLength, separatorBuilder: (context, index) { - return const SizedBox( - height: 10, - ); + return const SizedBox(height: 10); }, itemBuilder: (context, index) { if (index == 0) { @@ -233,13 +235,12 @@ class _AddressBookAddressChooserState extends State { key: const Key( "addressBookCAddressChooserFavoritesHeaderItemKey", ), - padding: const EdgeInsets.only( - bottom: 10, - ), + padding: const EdgeInsets.only(bottom: 10), child: Text( "Favorites", - style: - STextStyles.desktopTextExtraExtraSmall(context), + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), ), ); } else if (index < favorites.length + 1) { @@ -254,13 +255,12 @@ class _AddressBookAddressChooserState extends State { key: const Key( "addressBookCAddressChooserAllContactsHeaderItemKey", ), - padding: const EdgeInsets.symmetric( - vertical: 10, - ), + padding: const EdgeInsets.symmetric(vertical: 10), child: Text( "All contacts", - style: - STextStyles.desktopTextExtraExtraSmall(context), + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), ), ); } else { diff --git a/lib/services/address_book_service.dart b/lib/services/address_book_service.dart index f49d9e6649..cdf794af78 100644 --- a/lib/services/address_book_service.dart +++ b/lib/services/address_book_service.dart @@ -10,10 +10,19 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; +import 'package:mutex/mutex.dart'; + +import '../app_config.dart'; import '../db/isar/main_db.dart'; +import '../models/isar/models/blockchain_data/address.dart'; import '../models/isar/models/contact_entry.dart'; +import '../utilities/logger.dart'; +import '../wallets/wallet/wallet.dart'; +import '../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; class AddressBookService extends ChangeNotifier { + final Mutex _myStackContactLock = Mutex(); + ContactEntry getContactById(String id) { final ContactEntry? contactEntry = MainDB.instance.getContactEntry(id: id); if (contactEntry == null) { @@ -78,6 +87,65 @@ class AddressBookService extends ChangeNotifier { return true; } + /// Rebuilds the "My Stack" contact from the current receiving address of + /// each of [wallets]. + Future updateMyStackContact(Iterable wallets) => + _myStackContactLock.protect(() => _updateMyStackContact(wallets)); + + Future _updateMyStackContact(Iterable wallets) async { + final List addresses = []; + for (final wallet in wallets) { + final SparkInterface? sparkWallet = + wallet is SparkInterface && + !(wallet.isViewOnly && wallet.viewOnlyType != .spark) + ? wallet + : null; + String addressString; + try { + if (sparkWallet != null) { + Address? address = await sparkWallet + .getCurrentReceivingSparkAddress(); + address ??= await sparkWallet.generateNextSparkAddress( + saveToDB: true, + ); + addressString = address.value; + } else { + final address = await wallet.getCurrentReceivingAddress(); + addressString = address?.value ?? wallet.info.cachedReceivingAddress; + } + } catch (e, s) { + // A Spark wallet's cached address is its transparent one, so leave it + // out rather than offer a public address. Other wallets fall back to + // the cached address so one failure doesn't drop them or stop the rest. + Logging.instance.e( + "Failed to get a wallet address for My Stack", + error: e, + stackTrace: s, + ); + if (sparkWallet != null) continue; + addressString = wallet.info.cachedReceivingAddress; + } + if (addressString.isEmpty) continue; + + addresses.add( + ContactAddressEntry() + ..coinName = wallet.info.coin.identifier + ..address = addressString + ..label = "Current Receiving" + ..other = wallet.info.name, + ); + } + + await editContact( + ContactEntry( + name: "My ${AppConfig.prefix}", + addresses: addresses, + isFavorite: true, + customId: "default", + ), + ); + } + /// Remove address book contact entry from db if it exists Future removeContact(String id) async { await MainDB.instance.deleteContactEntry(id: id); diff --git a/test/services/address_book_service_test.dart b/test/services/address_book_service_test.dart new file mode 100644 index 0000000000..bdd39cabfd --- /dev/null +++ b/test/services/address_book_service_test.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/models/contact_entry.dart'; +import 'package:stackwallet/services/address_book_service.dart'; +import 'package:stackwallet/wallets/crypto_currency/coins/firo.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; + +class _TestAddressBookService extends AddressBookService { + final writes = []; + + @override + Future editContact(ContactEntry editedContact) async { + writes.add(editedContact); + return true; + } +} + +class _TestWallet extends Mock implements Wallet { + _TestWallet({required this.info, required this.loadAddress}); + + @override + final WalletInfo info; + + final Future Function() loadAddress; + + @override + Future getCurrentReceivingAddress() => loadAddress(); +} + +class _TestSparkWallet extends Mock + implements Wallet, SparkInterface { + _TestSparkWallet({required this.info}); + + @override + final WalletInfo info; + + @override + bool get isViewOnly => false; + + @override + Future getCurrentReceivingSparkAddress() async => null; + + @override + Future
generateNextSparkAddress({required bool saveToDB}) => + throw Exception("spark address generation failed"); +} + +WalletInfo _walletInfo(String id) => WalletInfo( + walletId: id, + name: id, + mainAddressType: AddressType.p2wpkh, + coinName: "bitcoin", + cachedReceivingAddress: "cached-$id", +); + +Address _address(String walletId) => Address( + walletId: walletId, + value: "address-$walletId", + publicKey: const [], + derivationIndex: 0, + derivationPath: null, + type: AddressType.p2wpkh, + subType: AddressSubType.receiving, +); + +void main() { + test("uses the cached address for a wallet that fails", () async { + final service = _TestAddressBookService(); + final goodWallet = _TestWallet( + info: _walletInfo("good"), + loadAddress: () async => _address("good"), + ); + final failingWallet = _TestWallet( + info: _walletInfo("failing"), + loadAddress: () async => throw Exception("temporary failure"), + ); + + await service.updateMyStackContact([goodWallet, failingWallet]); + + expect(service.writes, hasLength(1)); + expect(service.writes.single.addresses.map((e) => e.address), [ + "address-good", + "cached-failing", + ]); + }); + + test("leaves out a Spark wallet whose Spark address fails", () async { + final service = _TestAddressBookService(); + final goodWallet = _TestWallet( + info: _walletInfo("good"), + loadAddress: () async => _address("good"), + ); + final sparkWallet = _TestSparkWallet( + info: WalletInfo( + walletId: "firo", + name: "firo", + mainAddressType: AddressType.p2pkh, + coinName: "firo", + cachedReceivingAddress: "transparent-firo", + ), + ); + + await service.updateMyStackContact([goodWallet, sparkWallet]); + + expect(service.writes, hasLength(1)); + expect(service.writes.single.addresses.map((e) => e.address), [ + "address-good", + ]); + }); + + test("serializes My Stack updates in call order", () async { + final service = _TestAddressBookService(); + final firstStarted = Completer(); + final firstAddress = Completer(); + final firstWallet = _TestWallet( + info: _walletInfo("first"), + loadAddress: () { + firstStarted.complete(); + return firstAddress.future; + }, + ); + final secondWallet = _TestWallet( + info: _walletInfo("second"), + loadAddress: () async => _address("second"), + ); + + final firstUpdate = service.updateMyStackContact([firstWallet]); + await firstStarted.future; + final secondUpdate = service.updateMyStackContact([secondWallet]); + await Future.delayed(Duration.zero); + + expect(service.writes, isEmpty); + + firstAddress.complete(_address("first")); + await Future.wait([firstUpdate, secondUpdate]); + + expect(service.writes, hasLength(2)); + expect(service.writes.last.addresses.single.address, "address-second"); + }); +}