diff --git a/lib/pages/add_wallet_views/frost_ms/new/steps/frost_create_step_5.dart b/lib/pages/add_wallet_views/frost_ms/new/steps/frost_create_step_5.dart index 6516690760..62848663e7 100644 --- a/lib/pages/add_wallet_views/frost_ms/new/steps/frost_create_step_5.dart +++ b/lib/pages/add_wallet_views/frost_ms/new/steps/frost_create_step_5.dart @@ -11,6 +11,7 @@ import '../../../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../../../providers/frost_wallet/frost_wallet_providers.dart'; import '../../../../../providers/global/secure_store_provider.dart'; import '../../../../../providers/providers.dart'; +import '../../../../../services/transaction_notification_tracker.dart'; import '../../../../../themes/stack_colors.dart'; import '../../../../../utilities/assets.dart'; import '../../../../../utilities/logger.dart'; @@ -141,6 +142,9 @@ class _FrostCreateStep5State extends ConsumerState { coin: data.info.frostCurrency, name: data.info.walletName, ); + await TransactionNotificationTracker( + walletId: info.walletId, + ).markInitialized(); final wallet = await Wallet.create( walletInfo: info, diff --git a/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart b/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart index 52a78a975f..cbe8086f2a 100644 --- a/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart +++ b/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart @@ -170,6 +170,7 @@ class _NewWalletRecoveryPhraseWarningViewState } final txTracker = TransactionNotificationTracker(walletId: info.walletId); + await txTracker.markInitialized(); String? mnemonicPassphrase; String? mnemonic; diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart index a1ea19c405..b9c43bfb31 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart @@ -27,7 +27,6 @@ import '../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; import '../../../providers/global/secure_store_provider.dart'; import '../../../providers/providers.dart'; -import '../../../services/transaction_notification_tracker.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/address_utils.dart'; import '../../../utilities/assets.dart'; @@ -327,10 +326,6 @@ class _RestoreWalletViewState extends ConsumerState { .save(node, null, false); } - final txTracker = TransactionNotificationTracker( - walletId: info.walletId, - ); - try { final wallet = await Wallet.create( walletInfo: info, diff --git a/lib/services/event_bus/events/global/crypto_notification_event.dart b/lib/services/event_bus/events/global/crypto_notification_event.dart new file mode 100644 index 0000000000..46831cb3cf --- /dev/null +++ b/lib/services/event_bus/events/global/crypto_notification_event.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import 'package:event_bus/event_bus.dart'; + +import '../../../../wallets/crypto_currency/crypto_currency.dart'; + +abstract class CryptoNotificationsEventBus { + static final instance = EventBus(); + static int _listenerCount = 0; + + static bool get hasListeners => _listenerCount > 0; + + static void registerListener() => _listenerCount++; + + static void unregisterListener() { + assert(_listenerCount > 0); + if (_listenerCount > 0) { + _listenerCount--; + } + } +} + +class CryptoNotificationEvent { + final String title; + final String walletId; + final String walletName; + final DateTime date; + final bool shouldWatchForUpdates; + final CryptoCurrency coin; + final String? txid; + final int? confirmations; + final int? requiredConfirmations; + final String? changeNowId; + final String? payload; + final Completer _deliveryCompleter = Completer(); + + Future get delivered => _deliveryCompleter.future; + + CryptoNotificationEvent({ + required this.title, + required this.walletId, + required this.walletName, + required this.date, + required this.shouldWatchForUpdates, + required this.coin, + this.txid, + this.confirmations, + this.requiredConfirmations, + this.changeNowId, + this.payload, + }); + + void completeDelivery() { + if (!_deliveryCompleter.isCompleted) { + _deliveryCompleter.complete(); + } + } + + void failDelivery(Object error, StackTrace stackTrace) { + if (!_deliveryCompleter.isCompleted) { + _deliveryCompleter.completeError(error, stackTrace); + } + } +} diff --git a/lib/services/notifications_api.dart b/lib/services/notifications_api.dart index 4263c951f1..02c0400737 100644 --- a/lib/services/notifications_api.dart +++ b/lib/services/notifications_api.dart @@ -8,7 +8,7 @@ * */ -import 'dart:async'; +import 'dart:io'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -18,7 +18,8 @@ import '../utilities/prefs.dart'; import 'notifications_service.dart'; abstract final class NotificationApi { - static Completer? _initCalledCompleter; + static Future? _initializationFuture; + static Future? _permissionRequestFuture; static final _notifications = FlutterLocalNotificationsPlugin(); // static final onNotifications = BehaviorSubject(); @@ -38,16 +39,24 @@ abstract final class NotificationApi { } static Future init({bool initScheduled = false}) async { - if (_initCalledCompleter == null) { - _initCalledCompleter = Completer(); - } else { - if (_initCalledCompleter!.isCompleted) { - return; - } else { - return await _initCalledCompleter!.future; + final existing = _initializationFuture; + if (existing != null) { + return existing; + } + + final initialization = _initialize(); + _initializationFuture = initialization; + try { + await initialization; + } catch (_) { + if (identical(_initializationFuture, initialization)) { + _initializationFuture = null; } + rethrow; } + } + static Future _initialize() async { const android = AndroidInitializationSettings('app_icon_alpha'); const iOS = DarwinInitializationSettings(); const linux = LinuxInitializationSettings( @@ -69,7 +78,21 @@ abstract final class NotificationApi { // onNotifications.add(payload.payload); // }, ); - _initCalledCompleter!.complete(); + } + + static Future _requestPermissionIfNeeded() async { + if (!Platform.isAndroid) { + return; + } + _permissionRequestFuture ??= _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission(); + final granted = await _permissionRequestFuture; + if (granted == false) { + Logging.instance.i("System notification permission was denied"); + } } static Future clearNotifications() async { @@ -92,6 +115,7 @@ abstract final class NotificationApi { String? payload, }) async { await init(); + await _requestPermissionIfNeeded(); final id = await prefs.incrementCurrentNotificationIndex(); await _notifications.show( id, @@ -117,11 +141,9 @@ abstract final class NotificationApi { String? changeNowId, String? payload, }) async { - final id = await _showOsNotification( - title: title, - body: body, - payload: payload, - ); + await init(); + await _requestPermissionIfNeeded(); + final id = await prefs.incrementCurrentNotificationIndex(); String confirms = ""; if (txid != null && @@ -145,6 +167,22 @@ abstract final class NotificationApi { ); await notificationsService.add(model, true); + + try { + await _notifications.show( + id, + title, + body, + await _notificationDetails(), + payload: payload, + ); + } catch (error, stackTrace) { + Logging.instance.w( + "System notification delivery failed", + error: error, + stackTrace: stackTrace, + ); + } } static Future showLocalOnly({ diff --git a/lib/services/notifications_service.dart b/lib/services/notifications_service.dart index 4eca542e06..6314232265 100644 --- a/lib/services/notifications_service.dart +++ b/lib/services/notifications_service.dart @@ -342,10 +342,10 @@ class NotificationsService extends ChangeNotifier { ); if (notification.shouldWatchForUpdates) { if (notification.txid != null) { - _addWatchedTxNotification(notification); + await _addWatchedTxNotification(notification); } if (notification.changeNowId != null) { - _addWatchedTradeNotification(notification); + await _addWatchedTradeNotification(notification); } } if (shouldNotifyListeners) { diff --git a/lib/services/transaction_notification_service.dart b/lib/services/transaction_notification_service.dart new file mode 100644 index 0000000000..41e889f3bb --- /dev/null +++ b/lib/services/transaction_notification_service.dart @@ -0,0 +1,82 @@ +import '../models/isar/models/blockchain_data/transaction.dart'; +import '../models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import '../wallets/crypto_currency/crypto_currency.dart'; +import 'event_bus/events/global/crypto_notification_event.dart'; +import 'transaction_notification_tracker.dart'; + +typedef CryptoNotificationSink = + Future Function(CryptoNotificationEvent event); + +class TransactionNotificationService { + TransactionNotificationService({ + required this.store, + CryptoNotificationSink? notificationSink, + }) : _notificationSink = notificationSink ?? _deliverThroughEventBus; + + final TransactionNotificationStore store; + final CryptoNotificationSink _notificationSink; + + static Future _deliverThroughEventBus( + CryptoNotificationEvent event, + ) async { + if (!CryptoNotificationsEventBus.hasListeners) { + throw StateError("No crypto notification listener is registered"); + } + CryptoNotificationsEventBus.instance.fire(event); + await event.delivered.timeout(const Duration(seconds: 30)); + } + + Future notifyNewIncomingTransactions({ + required Set knownTxids, + required Iterable transactions, + required CryptoCurrency coin, + required String walletId, + required String walletName, + required int chainHeight, + required bool supportsConfirmationUpdates, + }) async { + final deliveredTxids = store.deliveredTxids; + final incoming = transactions + .where((tx) => tx.type == TransactionType.incoming) + .where((tx) => !knownTxids.contains(tx.txid)) + .where((tx) => !deliveredTxids.contains(tx.txid)) + .toList(); + + if (!store.isInitialized && knownTxids.isEmpty) { + await store.markInitialized(); + return; + } + + if (!store.isInitialized) { + await store.markInitialized(); + } + + for (final tx in incoming) { + final amount = tx.getAmountReceivedInThisWallet( + fractionDigits: coin.fractionDigits, + ); + final formattedAmount = amount.decimal.toStringAsFixed( + coin.fractionDigits, + ); + final payload = "$formattedAmount ${coin.ticker}"; + + final event = CryptoNotificationEvent( + title: "Incoming ${coin.prettyName} transaction", + walletId: walletId, + walletName: walletName, + date: DateTime.fromMillisecondsSinceEpoch(tx.timestamp * 1000), + shouldWatchForUpdates: + supportsConfirmationUpdates && + (tx.height == null || tx.height! <= 0), + coin: coin, + txid: tx.txid, + confirmations: tx.getConfirmations(chainHeight), + requiredConfirmations: coin.minConfirms, + payload: payload, + ); + + await _notificationSink(event); + await store.recordDelivered(tx.txid); + } + } +} diff --git a/lib/services/transaction_notification_tracker.dart b/lib/services/transaction_notification_tracker.dart index a100b476e8..ce8ce9c3a3 100644 --- a/lib/services/transaction_notification_tracker.dart +++ b/lib/services/transaction_notification_tracker.dart @@ -8,22 +8,85 @@ * */ +import 'package:meta/meta.dart'; + import '../db/hive/db.dart'; -class TransactionNotificationTracker { +abstract interface class TransactionNotificationStore { + bool get isInitialized; + Set get deliveredTxids; + Future markInitialized(); + Future recordDelivered(String txid); +} + +class TransactionNotificationTracker implements TransactionNotificationStore { + static const _initializedKey = "incomingTransactionNotificationsInitialized"; + static const _existingWalletsMigratedKey = + "incomingTransactionNotificationsExistingWalletsMigrated"; + static const _maxDeliveredTxids = 256; + + @visibleForTesting + static Map mergeDeliveredTxids( + Map? stored, + Iterable txids, { + int maxEntries = _maxDeliveredTxids, + }) { + assert(maxEntries > 0); + final result = Map.from(stored ?? const {}); + for (final txid in txids) { + result[txid] = true; + } + while (result.length > maxEntries) { + result.remove(result.keys.first); + } + return result; + } + final String walletId; TransactionNotificationTracker({required this.walletId}); + static Future initializeExistingWallets( + Iterable walletIds, + ) async { + final migrated = DB.instance.get( + boxName: DB.boxNamePrefs, + key: _existingWalletsMigratedKey, + ) as bool? ?? + false; + if (migrated) return; + + for (final walletId in walletIds) { + await TransactionNotificationTracker( + walletId: walletId, + ).markInitialized(); + } + await DB.instance.put( + boxName: DB.boxNamePrefs, + key: _existingWalletsMigratedKey, + value: true, + ); + } + + @override + bool get isInitialized => DB.instance.get( + boxName: walletId, + key: _initializedKey, + ) as bool? ?? + false; + List get pendings { final notifiedPendingTransactions = DB.instance.get( boxName: walletId, key: "notifiedPendingTransactions", ) as Map? ?? {}; - return List.from(notifiedPendingTransactions.keys); + return notifiedPendingTransactions.keys.whereType().toList(); } + @override + Set get deliveredTxids => pendings.toSet(); + bool wasNotifiedPending(String txid) { final notifiedPendingTransactions = DB.instance.get( boxName: walletId, @@ -34,12 +97,11 @@ class TransactionNotificationTracker { } Future addNotifiedPending(String txid) async { - final notifiedPendingTransactions = DB.instance.get( - boxName: walletId, - key: "notifiedPendingTransactions", - ) as Map? ?? - {}; - notifiedPendingTransactions[txid] = true; + final stored = DB.instance.get( + boxName: walletId, + key: "notifiedPendingTransactions", + ) as Map?; + final notifiedPendingTransactions = mergeDeliveredTxids(stored, [txid]); await DB.instance.put( boxName: walletId, key: "notifiedPendingTransactions", @@ -47,6 +109,16 @@ class TransactionNotificationTracker { ); } + @override + Future recordDelivered(String txid) => addNotifiedPending(txid); + + @override + Future markInitialized() => DB.instance.put( + boxName: walletId, + key: _initializedKey, + value: true, + ); + List get confirmeds { final notifiedConfirmedTransactions = DB.instance.get( boxName: walletId, diff --git a/lib/services/wallets.dart b/lib/services/wallets.dart index e1d38149b8..b2dd817244 100644 --- a/lib/services/wallets.dart +++ b/lib/services/wallets.dart @@ -35,6 +35,7 @@ import 'event_bus/global_event_bus.dart'; import 'node_service.dart'; import 'notifications_service.dart'; import 'trade_sent_from_stack_service.dart'; +import 'transaction_notification_tracker.dart'; class Wallets { Wallets._private(); @@ -459,6 +460,10 @@ class Wallets { ) .findAll(); + await TransactionNotificationTracker.initializeExistingWallets( + walletInfoList.map((walletInfo) => walletInfo.walletId), + ); + if (isDuress) { walletInfoList.retainWhere((e) => e.isDuressVisible); } diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 1aa40ef6a7..54a53ee8cf 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -6,6 +6,8 @@ import 'package:mutex/mutex.dart'; import '../../db/isar/main_db.dart'; import '../../models/isar/models/blockchain_data/address.dart'; +import '../../models/isar/models/blockchain_data/transaction.dart'; +import '../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../models/isar/models/solana/sol_contract.dart'; import '../../models/keys/view_only_wallet_data.dart'; @@ -16,6 +18,8 @@ import '../../services/event_bus/events/global/refresh_percent_changed_event.dar import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../services/event_bus/global_event_bus.dart'; import '../../services/node_service.dart'; +import '../../services/transaction_notification_service.dart'; +import '../../services/transaction_notification_tracker.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/constants.dart'; import '../../utilities/enums/sync_type_enum.dart'; @@ -697,6 +701,14 @@ abstract class Wallet { await (this as SparkInterface).refreshSparkData((0.3, 0.6)); } + // Capture known transaction IDs before updating so we can detect new ones. + final knownTxids = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .txidProperty() + .findAll(); + final knownTxidSet = knownTxids.toSet(); + if (this is NamecoinWallet) { await updateUTXOs(); _fireRefreshPercentChange(0.6); @@ -713,6 +725,34 @@ abstract class Wallet { await fetchFuture; } + // Check for new incoming transactions and fire notification events. + try { + final incomingTransactions = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .typeEqualTo(TransactionType.incoming) + .findAll(); + final service = TransactionNotificationService( + store: TransactionNotificationTracker(walletId: walletId), + ); + await service.notifyNewIncomingTransactions( + knownTxids: knownTxidSet, + transactions: incomingTransactions, + coin: cryptoCurrency, + walletId: walletId, + walletName: info.name, + chainHeight: info.cachedChainHeight, + supportsConfirmationUpdates: this is ElectrumXInterface, + ); + } catch (e, s) { + Logging.instance.w( + "Transaction notification check failed: $e", + error: e, + stackTrace: s, + ); + } + // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided. if (!viewOnly && this is PaynymInterface && codesToCheck.isNotEmpty) { await (this as PaynymInterface).checkForNotificationTransactionsTo( diff --git a/lib/widgets/crypto_notifications.dart b/lib/widgets/crypto_notifications.dart index ba6eb67923..c3746092ec 100644 --- a/lib/widgets/crypto_notifications.dart +++ b/lib/widgets/crypto_notifications.dart @@ -10,51 +10,16 @@ import 'dart:async'; -import 'package:event_bus/event_bus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../providers/providers.dart'; +import '../services/event_bus/events/global/crypto_notification_event.dart'; import '../services/notifications_api.dart'; import '../themes/coin_icon_provider.dart'; -import '../wallets/crypto_currency/crypto_currency.dart'; - -abstract class CryptoNotificationsEventBus { - static final instance = EventBus(); -} - -class CryptoNotificationEvent { - final String title; - final String walletId; - final String walletName; - final DateTime date; - final bool shouldWatchForUpdates; - final CryptoCurrency coin; - final String? txid; - final int? confirmations; - final int? requiredConfirmations; - final String? changeNowId; - final String? payload; - - CryptoNotificationEvent({ - required this.title, - required this.walletId, - required this.walletName, - required this.date, - required this.shouldWatchForUpdates, - required this.coin, - this.txid, - this.confirmations, - this.requiredConfirmations, - this.changeNowId, - this.payload, - }); -} class CryptoNotifications extends ConsumerStatefulWidget { - const CryptoNotifications({ - super.key, - required this.child, - }); + const CryptoNotifications({super.key, required this.child}); final Widget child; @@ -89,17 +54,22 @@ class _CryptoNotificationsState extends ConsumerState { NotificationApi.notificationsService = ref.read(notificationsProvider); _streamSubscription = CryptoNotificationsEventBus.instance .on() - .listen( - (event) async { - unawaited(_showNotification(event)); - }, - ); + .listen((event) async { + try { + await _showNotification(event); + event.completeDelivery(); + } catch (error, stackTrace) { + event.failDelivery(error, stackTrace); + } + }); + CryptoNotificationsEventBus.registerListener(); super.initState(); } @override void dispose() { + CryptoNotificationsEventBus.unregisterListener(); _streamSubscription?.cancel(); super.dispose(); } diff --git a/scripts/app_config/templates/android/app/src/main/AndroidManifest.xml b/scripts/app_config/templates/android/app/src/main/AndroidManifest.xml index d10488cc9e..3e35c52fa5 100644 --- a/scripts/app_config/templates/android/app/src/main/AndroidManifest.xml +++ b/scripts/app_config/templates/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ android:name="android.permission.INTERNET"/> + - \ No newline at end of file + diff --git a/test/services/transaction_notification_service_test.dart b/test/services/transaction_notification_service_test.dart new file mode 100644 index 0000000000..04551ae19c --- /dev/null +++ b/test/services/transaction_notification_service_test.dart @@ -0,0 +1,240 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/transaction.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/output_v2.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import 'package:stackwallet/services/event_bus/events/global/crypto_notification_event.dart'; +import 'package:stackwallet/services/transaction_notification_service.dart'; +import 'package:stackwallet/services/transaction_notification_tracker.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +class _MemoryNotificationStore implements TransactionNotificationStore { + _MemoryNotificationStore({this.isInitialized = true}); + + @override + bool isInitialized; + + final Set delivered = {}; + + @override + Set get deliveredTxids => delivered; + + @override + Future recordDelivered(String txid) async { + delivered.add(txid); + } + + @override + Future markInitialized() async { + isInitialized = true; + } +} + +TransactionV2 _transaction( + String txid, { + TransactionType type = TransactionType.incoming, + int? height, +}) => TransactionV2( + walletId: "wallet-id", + blockHash: null, + hash: txid, + txid: txid, + timestamp: 1, + height: height, + inputs: const [], + outputs: [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "", + valueStringSats: "100000000", + addresses: const ["address"], + walletOwns: true, + ), + ], + version: 2, + type: type, + subType: TransactionSubType.none, + otherData: null, +); + +void main() { + late Bitcoin bitcoin; + late List notifications; + + setUp(() { + bitcoin = Bitcoin(CryptoCurrencyNetwork.main); + notifications = []; + }); + + test("keeps only the newest delivered transaction ids", () { + final result = TransactionNotificationTracker.mergeDeliveredTxids( + {"oldest": true, "middle": true, "newest": true}, + ["latest"], + maxEntries: 3, + ); + + expect(result.keys, ["middle", "newest", "latest"]); + }); + + TransactionNotificationService serviceFor( + TransactionNotificationStore store, { + CryptoNotificationSink? notificationSink, + }) => TransactionNotificationService( + store: store, + notificationSink: + notificationSink ?? + (event) async { + notifications.add(event); + }, + ); + + test("notifies only new incoming transactions", () async { + final store = _MemoryNotificationStore()..delivered.add("notified"); + + await serviceFor(store).notifyNewIncomingTransactions( + knownTxids: {"known"}, + transactions: [ + _transaction("known"), + _transaction("outgoing", type: TransactionType.outgoing), + _transaction("notified"), + _transaction("new"), + ], + coin: bitcoin, + walletId: "wallet-id", + walletName: "Bitcoin wallet", + chainHeight: 10, + supportsConfirmationUpdates: true, + ); + + expect(notifications.map((event) => event.txid), ["new"]); + expect(notifications.single.payload, "1.00000000 BTC"); + expect(store.delivered, contains("new")); + }); + + test("first empty-database scan establishes a silent baseline", () async { + final store = _MemoryNotificationStore(isInitialized: false); + + await serviceFor(store).notifyNewIncomingTransactions( + knownTxids: {}, + transactions: [ + _transaction("historical-1"), + _transaction("historical-2"), + ], + coin: bitcoin, + walletId: "wallet-id", + walletName: "Restored wallet", + chainHeight: 10, + supportsConfirmationUpdates: true, + ); + + expect(notifications, isEmpty); + expect(store.isInitialized, isTrue); + expect(store.delivered, isEmpty); + }); + + test( + "upgrade scan still notifies for transactions absent before refresh", + () async { + final store = _MemoryNotificationStore(isInitialized: false); + + await serviceFor(store).notifyNewIncomingTransactions( + knownTxids: {"known"}, + transactions: [_transaction("known"), _transaction("new")], + coin: bitcoin, + walletId: "wallet-id", + walletName: "Existing wallet", + chainHeight: 10, + supportsConfirmationUpdates: true, + ); + + expect(notifications.map((event) => event.txid), ["new"]); + expect(store.isInitialized, isTrue); + }, + ); + + test("records delivery only after the notification succeeds", () async { + final store = _MemoryNotificationStore(); + final completer = Completer(); + final future = + serviceFor( + store, + notificationSink: (event) { + notifications.add(event); + return completer.future; + }, + ).notifyNewIncomingTransactions( + knownTxids: const {}, + transactions: [_transaction("new")], + coin: bitcoin, + walletId: "wallet-id", + walletName: "Bitcoin wallet", + chainHeight: 10, + supportsConfirmationUpdates: true, + ); + + await Future.delayed(Duration.zero); + expect(notifications, hasLength(1)); + expect(store.delivered, isEmpty); + + completer.complete(); + await future; + expect(store.delivered, {"new"}); + }); + + test("retries after notification delivery fails", () async { + final store = _MemoryNotificationStore(); + final service = serviceFor( + store, + notificationSink: (_) => Future.error(Exception("delivery")), + ); + + Future notify() => service.notifyNewIncomingTransactions( + knownTxids: const {}, + transactions: [_transaction("new")], + coin: bitcoin, + walletId: "wallet-id", + walletName: "Bitcoin wallet", + chainHeight: 10, + supportsConfirmationUpdates: true, + ); + + await expectLater(notify(), throwsException); + expect(store.delivered, isEmpty); + await expectLater(notify(), throwsException); + expect(store.delivered, isEmpty); + }); + + test("does not record delivery without a registered listener", () async { + final store = _MemoryNotificationStore(); + final service = TransactionNotificationService(store: store); + + final future = service.notifyNewIncomingTransactions( + knownTxids: const {}, + transactions: [_transaction("new")], + coin: bitcoin, + walletId: "wallet-id", + walletName: "Bitcoin wallet", + chainHeight: 10, + supportsConfirmationUpdates: true, + ); + + await expectLater(future, throwsStateError); + expect(store.delivered, isEmpty); + }); + + test("watches confirmations only for supported wallets", () async { + final store = _MemoryNotificationStore(); + + await serviceFor(store).notifyNewIncomingTransactions( + knownTxids: const {}, + transactions: [_transaction("new")], + coin: bitcoin, + walletId: "wallet-id", + walletName: "Bitcoin wallet", + chainHeight: 10, + supportsConfirmationUpdates: false, + ); + + expect(notifications.single.shouldWatchForUpdates, isFalse); + }); +} diff --git a/test/services/transaction_notification_tracker_test.dart b/test/services/transaction_notification_tracker_test.dart new file mode 100644 index 0000000000..5ec628f7b5 --- /dev/null +++ b/test/services/transaction_notification_tracker_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/services/transaction_notification_tracker.dart'; + +import '../hive/hive_ce_test_utils.dart'; + +void main() { + setUp(() async { + await setUpHiveCeTest(); + await DB.instance.hive.openBox(DB.boxNamePrefs); + await DB.instance.hive.openBox("existing-1"); + await DB.instance.hive.openBox("existing-2"); + await DB.instance.hive.openBox("restored-later"); + }); + + tearDown(tearDownHiveCeTest); + + test( + "initializes only wallets present during the one-time migration", + () async { + await TransactionNotificationTracker.initializeExistingWallets([ + "existing-1", + "existing-2", + ]); + + expect( + TransactionNotificationTracker(walletId: "existing-1").isInitialized, + isTrue, + ); + expect( + TransactionNotificationTracker(walletId: "existing-2").isInitialized, + isTrue, + ); + + await TransactionNotificationTracker.initializeExistingWallets([ + "restored-later", + ]); + expect( + TransactionNotificationTracker( + walletId: "restored-later", + ).isInitialized, + isFalse, + ); + }, + ); + + test("bounds the delivered transaction ledger", () async { + final tracker = TransactionNotificationTracker(walletId: "existing-1"); + + for (var i = 0; i < 300; i++) { + await tracker.recordDelivered("tx-$i"); + } + + expect(tracker.deliveredTxids, hasLength(256)); + expect(tracker.deliveredTxids, isNot(contains("tx-0"))); + expect(tracker.deliveredTxids, contains("tx-299")); + }); +}