From 92767b7d781404d9b211150adfe2216e3c9110ef Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 20 Aug 2026 16:26:29 -0500 Subject: [PATCH 1/2] feat: notify on incoming transactions --- .../global/crypto_notification_event.dart | 35 +++++ .../transaction_notification_service.dart | 72 ++++++++++ .../transaction_notification_tracker.dart | 119 +++++++++++----- lib/wallets/wallet/wallet.dart | 36 +++++ lib/widgets/crypto_notifications.dart | 49 +------ ...transaction_notification_service_test.dart | 134 ++++++++++++++++++ 6 files changed, 368 insertions(+), 77 deletions(-) create mode 100644 lib/services/event_bus/events/global/crypto_notification_event.dart create mode 100644 lib/services/transaction_notification_service.dart create mode 100644 test/services/transaction_notification_service_test.dart 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..70cc119964 --- /dev/null +++ b/lib/services/event_bus/events/global/crypto_notification_event.dart @@ -0,0 +1,35 @@ +import 'package:event_bus/event_bus.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, + }); +} diff --git a/lib/services/transaction_notification_service.dart b/lib/services/transaction_notification_service.dart new file mode 100644 index 0000000000..05eba25449 --- /dev/null +++ b/lib/services/transaction_notification_service.dart @@ -0,0 +1,72 @@ +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 = void Function(CryptoNotificationEvent event); + +class TransactionNotificationService { + TransactionNotificationService({ + required this.store, + CryptoNotificationSink? notificationSink, + }) : _notificationSink = + notificationSink ?? + ((event) => CryptoNotificationsEventBus.instance.fire(event)); + + final TransactionNotificationStore store; + final CryptoNotificationSink _notificationSink; + + Future notifyNewIncomingTransactions({ + required Set knownTxids, + required Iterable transactions, + required CryptoCurrency coin, + required String walletId, + required String walletName, + required int chainHeight, + }) async { + final pendingTxids = store.pendings.toSet(); + final incoming = transactions + .where((tx) => tx.type == TransactionType.incoming) + .where((tx) => !knownTxids.contains(tx.txid)) + .where((tx) => !pendingTxids.contains(tx.txid)) + .toList(); + + if (!store.isInitialized && knownTxids.isEmpty) { + await store.addNotifiedPendingAll(incoming.map((tx) => tx.txid)); + 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}"; + + _notificationSink( + CryptoNotificationEvent( + title: "Incoming ${coin.prettyName} transaction", + walletId: walletId, + walletName: walletName, + date: DateTime.fromMillisecondsSinceEpoch(tx.timestamp * 1000), + shouldWatchForUpdates: tx.height == null || tx.height! <= 0, + coin: coin, + txid: tx.txid, + confirmations: tx.getConfirmations(chainHeight), + requiredConfirmations: coin.minConfirms, + payload: payload, + ), + ); + + await store.addNotifiedPendingAll([tx.txid]); + } + } +} diff --git a/lib/services/transaction_notification_tracker.dart b/lib/services/transaction_notification_tracker.dart index a100b476e8..bd9d380f38 100644 --- a/lib/services/transaction_notification_tracker.dart +++ b/lib/services/transaction_notification_tracker.dart @@ -10,36 +10,68 @@ import '../db/hive/db.dart'; -class TransactionNotificationTracker { +abstract interface class TransactionNotificationStore { + bool get isInitialized; + + List get pendings; + + Future markInitialized(); + + Future addNotifiedPendingAll(Iterable txids); +} + +class TransactionNotificationTracker implements TransactionNotificationStore { + static const _initializedKey = "incomingTransactionNotificationsInitialized"; + final String walletId; TransactionNotificationTracker({required this.walletId}); + @override + bool get isInitialized => + DB.instance.get(boxName: walletId, key: _initializedKey) + as bool? ?? + false; + + @override List get pendings { - final notifiedPendingTransactions = DB.instance.get( - boxName: walletId, - key: "notifiedPendingTransactions", - ) as Map? ?? + final notifiedPendingTransactions = + DB.instance.get( + boxName: walletId, + key: "notifiedPendingTransactions", + ) + as Map? ?? {}; return List.from(notifiedPendingTransactions.keys); } bool wasNotifiedPending(String txid) { - final notifiedPendingTransactions = DB.instance.get( - boxName: walletId, - key: "notifiedPendingTransactions", - ) as Map? ?? + final notifiedPendingTransactions = + DB.instance.get( + boxName: walletId, + key: "notifiedPendingTransactions", + ) + as Map? ?? {}; return notifiedPendingTransactions[txid] as bool? ?? false; } Future addNotifiedPending(String txid) async { - final notifiedPendingTransactions = DB.instance.get( - boxName: walletId, - key: "notifiedPendingTransactions", - ) as Map? ?? + await addNotifiedPendingAll([txid]); + } + + @override + Future addNotifiedPendingAll(Iterable txids) async { + final notifiedPendingTransactions = + DB.instance.get( + boxName: walletId, + key: "notifiedPendingTransactions", + ) + as Map? ?? {}; - notifiedPendingTransactions[txid] = true; + for (final txid in txids) { + notifiedPendingTransactions[txid] = true; + } await DB.instance.put( boxName: walletId, key: "notifiedPendingTransactions", @@ -47,29 +79,44 @@ class TransactionNotificationTracker { ); } + @override + Future markInitialized() async { + await DB.instance.put( + boxName: walletId, + key: _initializedKey, + value: true, + ); + } + List get confirmeds { - final notifiedConfirmedTransactions = DB.instance.get( - boxName: walletId, - key: "notifiedConfirmedTransactions", - ) as Map? ?? + final notifiedConfirmedTransactions = + DB.instance.get( + boxName: walletId, + key: "notifiedConfirmedTransactions", + ) + as Map? ?? {}; return List.from(notifiedConfirmedTransactions.keys); } bool wasNotifiedConfirmed(String txid) { - final notifiedConfirmedTransactions = DB.instance.get( - boxName: walletId, - key: "notifiedConfirmedTransactions", - ) as Map? ?? + final notifiedConfirmedTransactions = + DB.instance.get( + boxName: walletId, + key: "notifiedConfirmedTransactions", + ) + as Map? ?? {}; return notifiedConfirmedTransactions[txid] as bool? ?? false; } Future addNotifiedConfirmed(String txid) async { - final notifiedConfirmedTransactions = DB.instance.get( - boxName: walletId, - key: "notifiedConfirmedTransactions", - ) as Map? ?? + final notifiedConfirmedTransactions = + DB.instance.get( + boxName: walletId, + key: "notifiedConfirmedTransactions", + ) + as Map? ?? {}; notifiedConfirmedTransactions[txid] = true; await DB.instance.put( @@ -80,15 +127,19 @@ class TransactionNotificationTracker { } Future deleteTransaction(String txid) async { - final notifiedPendingTransactions = DB.instance.get( - boxName: walletId, - key: "notifiedPendingTransactions", - ) as Map? ?? + final notifiedPendingTransactions = + DB.instance.get( + boxName: walletId, + key: "notifiedPendingTransactions", + ) + as Map? ?? {}; - final notifiedConfirmedTransactions = DB.instance.get( - boxName: walletId, - key: "notifiedConfirmedTransactions", - ) as Map? ?? + final notifiedConfirmedTransactions = + DB.instance.get( + boxName: walletId, + key: "notifiedConfirmedTransactions", + ) + as Map? ?? {}; notifiedPendingTransactions.remove(txid); diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 1aa40ef6a7..3144908f70 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -6,6 +6,7 @@ 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/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 +17,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 +700,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 +724,31 @@ abstract class Wallet { await fetchFuture; } + // Check for new incoming transactions and fire notification events. + try { + final allTxs = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .findAll(); + final service = TransactionNotificationService( + store: TransactionNotificationTracker(walletId: walletId), + ); + await service.notifyNewIncomingTransactions( + knownTxids: knownTxidSet, + transactions: allTxs, + coin: cryptoCurrency, + walletId: walletId, + walletName: info.name, + chainHeight: info.cachedChainHeight, + ); + } 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..7906f69dc4 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,11 +54,9 @@ class _CryptoNotificationsState extends ConsumerState { NotificationApi.notificationsService = ref.read(notificationsProvider); _streamSubscription = CryptoNotificationsEventBus.instance .on() - .listen( - (event) async { - unawaited(_showNotification(event)); - }, - ); + .listen((event) async { + unawaited(_showNotification(event)); + }); super.initState(); } diff --git a/test/services/transaction_notification_service_test.dart b/test/services/transaction_notification_service_test.dart new file mode 100644 index 0000000000..54290a4c28 --- /dev/null +++ b/test/services/transaction_notification_service_test.dart @@ -0,0 +1,134 @@ +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 pending = {}; + + @override + List get pendings => pending.toList(); + + @override + Future addNotifiedPendingAll(Iterable txids) async { + pending.addAll(txids); + } + + @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 = []; + }); + + TransactionNotificationService serviceFor( + TransactionNotificationStore store, + ) => TransactionNotificationService( + store: store, + notificationSink: notifications.add, + ); + + test("notifies only new incoming transactions", () async { + final store = _MemoryNotificationStore()..pending.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, + ); + + expect(notifications.map((event) => event.txid), ["new"]); + expect(notifications.single.payload, "1.00000000 BTC"); + expect(store.pending, 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, + ); + + expect(notifications, isEmpty); + expect(store.isInitialized, isTrue); + expect(store.pending, {"historical-1", "historical-2"}); + }); + + 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, + ); + + expect(notifications.map((event) => event.txid), ["new"]); + expect(store.isInitialized, isTrue); + }, + ); +} From be49563151eb086b14b0f81c122e6cc11b378573 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 22 Aug 2026 07:13:20 -0500 Subject: [PATCH 2/2] fix(notifications): harden transaction delivery --- .../new/steps/frost_create_step_5.dart | 4 + ...w_wallet_recovery_phrase_warning_view.dart | 1 + .../restore_wallet_view.dart | 5 - .../global/crypto_notification_event.dart | 29 +++ lib/services/notifications_api.dart | 68 +++++-- lib/services/notifications_service.dart | 4 +- .../transaction_notification_service.dart | 52 +++--- .../transaction_notification_tracker.dart | 169 ++++++++++-------- lib/services/wallets.dart | 5 + lib/wallets/wallet/wallet.dart | 8 +- lib/widgets/crypto_notifications.dart | 9 +- .../android/app/src/main/AndroidManifest.xml | 4 +- ...transaction_notification_service_test.dart | 126 +++++++++++-- ...transaction_notification_tracker_test.dart | 58 ++++++ 14 files changed, 411 insertions(+), 131 deletions(-) create mode 100644 test/services/transaction_notification_tracker_test.dart 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 index 70cc119964..46831cb3cf 100644 --- a/lib/services/event_bus/events/global/crypto_notification_event.dart +++ b/lib/services/event_bus/events/global/crypto_notification_event.dart @@ -1,9 +1,23 @@ +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 { @@ -18,6 +32,9 @@ class CryptoNotificationEvent { final int? requiredConfirmations; final String? changeNowId; final String? payload; + final Completer _deliveryCompleter = Completer(); + + Future get delivered => _deliveryCompleter.future; CryptoNotificationEvent({ required this.title, @@ -32,4 +49,16 @@ class CryptoNotificationEvent { 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 index 05eba25449..41e889f3bb 100644 --- a/lib/services/transaction_notification_service.dart +++ b/lib/services/transaction_notification_service.dart @@ -4,19 +4,28 @@ import '../wallets/crypto_currency/crypto_currency.dart'; import 'event_bus/events/global/crypto_notification_event.dart'; import 'transaction_notification_tracker.dart'; -typedef CryptoNotificationSink = void Function(CryptoNotificationEvent event); +typedef CryptoNotificationSink = + Future Function(CryptoNotificationEvent event); class TransactionNotificationService { TransactionNotificationService({ required this.store, CryptoNotificationSink? notificationSink, - }) : _notificationSink = - notificationSink ?? - ((event) => CryptoNotificationsEventBus.instance.fire(event)); + }) : _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, @@ -24,16 +33,16 @@ class TransactionNotificationService { required String walletId, required String walletName, required int chainHeight, + required bool supportsConfirmationUpdates, }) async { - final pendingTxids = store.pendings.toSet(); + final deliveredTxids = store.deliveredTxids; final incoming = transactions .where((tx) => tx.type == TransactionType.incoming) .where((tx) => !knownTxids.contains(tx.txid)) - .where((tx) => !pendingTxids.contains(tx.txid)) + .where((tx) => !deliveredTxids.contains(tx.txid)) .toList(); if (!store.isInitialized && knownTxids.isEmpty) { - await store.addNotifiedPendingAll(incoming.map((tx) => tx.txid)); await store.markInitialized(); return; } @@ -51,22 +60,23 @@ class TransactionNotificationService { ); final payload = "$formattedAmount ${coin.ticker}"; - _notificationSink( - CryptoNotificationEvent( - title: "Incoming ${coin.prettyName} transaction", - walletId: walletId, - walletName: walletName, - date: DateTime.fromMillisecondsSinceEpoch(tx.timestamp * 1000), - shouldWatchForUpdates: tx.height == null || tx.height! <= 0, - coin: coin, - txid: tx.txid, - confirmations: tx.getConfirmations(chainHeight), - requiredConfirmations: coin.minConfirms, - payload: payload, - ), + 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 store.addNotifiedPendingAll([tx.txid]); + 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 bd9d380f38..ce8ce9c3a3 100644 --- a/lib/services/transaction_notification_tracker.dart +++ b/lib/services/transaction_notification_tracker.dart @@ -8,70 +8,100 @@ * */ +import 'package:meta/meta.dart'; + import '../db/hive/db.dart'; abstract interface class TransactionNotificationStore { bool get isInitialized; - - List get pendings; - + Set get deliveredTxids; Future markInitialized(); - - Future addNotifiedPendingAll(Iterable txids); + 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? ?? + bool get isInitialized => DB.instance.get( + boxName: walletId, + key: _initializedKey, + ) as bool? ?? false; - @override List get pendings { - final notifiedPendingTransactions = - DB.instance.get( - boxName: walletId, - key: "notifiedPendingTransactions", - ) - as Map? ?? + 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, - key: "notifiedPendingTransactions", - ) - as Map? ?? + final notifiedPendingTransactions = DB.instance.get( + boxName: walletId, + key: "notifiedPendingTransactions", + ) as Map? ?? {}; return notifiedPendingTransactions[txid] as bool? ?? false; } Future addNotifiedPending(String txid) async { - await addNotifiedPendingAll([txid]); - } - - @override - Future addNotifiedPendingAll(Iterable txids) async { - final notifiedPendingTransactions = - DB.instance.get( - boxName: walletId, - key: "notifiedPendingTransactions", - ) - as Map? ?? - {}; - for (final txid in txids) { - 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", @@ -80,43 +110,38 @@ class TransactionNotificationTracker implements TransactionNotificationStore { } @override - Future markInitialized() async { - await DB.instance.put( - boxName: walletId, - key: _initializedKey, - value: true, - ); - } + 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, - key: "notifiedConfirmedTransactions", - ) - as Map? ?? + final notifiedConfirmedTransactions = DB.instance.get( + boxName: walletId, + key: "notifiedConfirmedTransactions", + ) as Map? ?? {}; return List.from(notifiedConfirmedTransactions.keys); } bool wasNotifiedConfirmed(String txid) { - final notifiedConfirmedTransactions = - DB.instance.get( - boxName: walletId, - key: "notifiedConfirmedTransactions", - ) - as Map? ?? + final notifiedConfirmedTransactions = DB.instance.get( + boxName: walletId, + key: "notifiedConfirmedTransactions", + ) as Map? ?? {}; return notifiedConfirmedTransactions[txid] as bool? ?? false; } Future addNotifiedConfirmed(String txid) async { - final notifiedConfirmedTransactions = - DB.instance.get( - boxName: walletId, - key: "notifiedConfirmedTransactions", - ) - as Map? ?? + final notifiedConfirmedTransactions = DB.instance.get( + boxName: walletId, + key: "notifiedConfirmedTransactions", + ) as Map? ?? {}; notifiedConfirmedTransactions[txid] = true; await DB.instance.put( @@ -127,19 +152,15 @@ class TransactionNotificationTracker implements TransactionNotificationStore { } Future deleteTransaction(String txid) async { - final notifiedPendingTransactions = - DB.instance.get( - boxName: walletId, - key: "notifiedPendingTransactions", - ) - as Map? ?? + final notifiedPendingTransactions = DB.instance.get( + boxName: walletId, + key: "notifiedPendingTransactions", + ) as Map? ?? {}; - final notifiedConfirmedTransactions = - DB.instance.get( - boxName: walletId, - key: "notifiedConfirmedTransactions", - ) - as Map? ?? + final notifiedConfirmedTransactions = DB.instance.get( + boxName: walletId, + key: "notifiedConfirmedTransactions", + ) as Map? ?? {}; notifiedPendingTransactions.remove(txid); 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 3144908f70..54a53ee8cf 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -6,6 +6,7 @@ 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'; @@ -726,20 +727,23 @@ abstract class Wallet { // Check for new incoming transactions and fire notification events. try { - final allTxs = await mainDB.isar.transactionV2s + 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: allTxs, + transactions: incomingTransactions, coin: cryptoCurrency, walletId: walletId, walletName: info.name, chainHeight: info.cachedChainHeight, + supportsConfirmationUpdates: this is ElectrumXInterface, ); } catch (e, s) { Logging.instance.w( diff --git a/lib/widgets/crypto_notifications.dart b/lib/widgets/crypto_notifications.dart index 7906f69dc4..c3746092ec 100644 --- a/lib/widgets/crypto_notifications.dart +++ b/lib/widgets/crypto_notifications.dart @@ -55,14 +55,21 @@ class _CryptoNotificationsState extends ConsumerState { _streamSubscription = CryptoNotificationsEventBus.instance .on() .listen((event) async { - unawaited(_showNotification(event)); + 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 index 54290a4c28..04551ae19c 100644 --- a/test/services/transaction_notification_service_test.dart +++ b/test/services/transaction_notification_service_test.dart @@ -1,3 +1,5 @@ +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'; @@ -13,14 +15,14 @@ class _MemoryNotificationStore implements TransactionNotificationStore { @override bool isInitialized; - final Set pending = {}; + final Set delivered = {}; @override - List get pendings => pending.toList(); + Set get deliveredTxids => delivered; @override - Future addNotifiedPendingAll(Iterable txids) async { - pending.addAll(txids); + Future recordDelivered(String txid) async { + delivered.add(txid); } @override @@ -64,15 +66,30 @@ void 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, - ) => TransactionNotificationService( + TransactionNotificationStore store, { + CryptoNotificationSink? notificationSink, + }) => TransactionNotificationService( store: store, - notificationSink: notifications.add, + notificationSink: + notificationSink ?? + (event) async { + notifications.add(event); + }, ); test("notifies only new incoming transactions", () async { - final store = _MemoryNotificationStore()..pending.add("notified"); + final store = _MemoryNotificationStore()..delivered.add("notified"); await serviceFor(store).notifyNewIncomingTransactions( knownTxids: {"known"}, @@ -86,11 +103,12 @@ void main() { 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.pending, contains("new")); + expect(store.delivered, contains("new")); }); test("first empty-database scan establishes a silent baseline", () async { @@ -106,11 +124,12 @@ void main() { walletId: "wallet-id", walletName: "Restored wallet", chainHeight: 10, + supportsConfirmationUpdates: true, ); expect(notifications, isEmpty); expect(store.isInitialized, isTrue); - expect(store.pending, {"historical-1", "historical-2"}); + expect(store.delivered, isEmpty); }); test( @@ -125,10 +144,97 @@ void main() { 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")); + }); +}