From 6cf4fc9e1ebebf1984126e61823aac650e894502 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 30 Mar 2022 19:31:14 +0200 Subject: [PATCH] implement user notifications for new_transaction events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As the QML app can have multiple active wallets managed from a single window (unlike the desktop Qt version), we let each wallet manage its own user notification queue (as there are some rules here specific to each wallet, e.g. not emitting user notifications for each tx while the wallet is still syncing), including collating and rate limiting. The app then consumes the userNotify events from all active wallets, and adds these to its own queue, which get displayed (eventually, again implementing rate limiting) to the user. It also uses timers efficiently, only enabling them if there are actual userNotify events waiting. If at any point the QML app wants to use multiple windows, it can forego on the app user notification queue and instead attach each window to the associated wallet userNotify signal. app ▲ │ │ timer -> userNotify(msg) signal │ ┌──┬───┴───────┐ │ │ │ app user notification queue └──┴───▲───────┘ │ │ timer -> userNotify(wallet, msg) signal │ ┌──┬───┴───────┐ │ │ │ wallet user notification queue └──┴───▲───────┘ │ │ new_transaction │ wallet --- electrum/gui/qml/qeapp.py | 84 ++++++++++++++++++----- electrum/gui/qml/qewallet.py | 127 +++++++++++++++++++++++++++++------ 2 files changed, 174 insertions(+), 37 deletions(-) diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 6645ea7e7..83dccbfe2 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -1,6 +1,8 @@ import re +import queue +import time -from PyQt5.QtCore import pyqtSlot, QObject, QUrl, QLocale, qInstallMessageHandler +from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QUrl, QLocale, qInstallMessageHandler, QTimer from PyQt5.QtGui import QGuiApplication, QFontDatabase from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine #, QQmlComponent @@ -14,11 +16,66 @@ from .qeqr import QEQRParser, QEQRImageProvider from .qewalletdb import QEWalletDB from .qebitcoin import QEBitcoin +class QEAppController(QObject): + userNotify = pyqtSignal(str) + + def __init__(self, qedaemon): + super().__init__() + self.logger = get_logger(__name__) + + self._qedaemon = qedaemon + + # set up notification queue and notification_timer + self.user_notification_queue = queue.Queue() + self.user_notification_last_time = 0 + + self.notification_timer = QTimer(self) + self.notification_timer.setSingleShot(False) + self.notification_timer.setInterval(500) # msec + self.notification_timer.timeout.connect(self.on_notification_timer) + + self._qedaemon.walletLoaded.connect(self.on_wallet_loaded) + + def on_wallet_loaded(self): + qewallet = self._qedaemon.currentWallet + # attach to the wallet user notification events + # connect only once + try: + qewallet.userNotify.disconnect(self.on_wallet_usernotify) + except: + pass + qewallet.userNotify.connect(self.on_wallet_usernotify) + + def on_wallet_usernotify(self, wallet, message): + self.logger.debug(message) + self.user_notification_queue.put(message) + if not self.notification_timer.isActive(): + self.logger.debug('starting app notification timer') + self.notification_timer.start() + + def on_notification_timer(self): + if self.user_notification_queue.qsize() == 0: + self.logger.debug('queue empty, stopping app notification timer') + self.notification_timer.stop() + return + now = time.time() + rate_limit = 20 # seconds + if self.user_notification_last_time + rate_limit > now: + return + self.user_notification_last_time = now + self.logger.info("Notifying GUI about new user notifications") + try: + self.userNotify.emit(self.user_notification_queue.get_nowait()) + except queue.Empty: + pass + + @pyqtSlot('QString') + def textToClipboard(self, text): + QGuiApplication.clipboard().setText(text) + class ElectrumQmlApplication(QGuiApplication): - _config = None - _daemon = None - _singletons = {} + _valid = True def __init__(self, args, config, daemon): super().__init__(args) @@ -48,12 +105,14 @@ class ElectrumQmlApplication(QGuiApplication): self.fixedFont = 'Monospace' # hope for the best self.context = self.engine.rootContext() - self._singletons['config'] = QEConfig(config) - self._singletons['network'] = QENetwork(daemon.network) - self._singletons['daemon'] = QEDaemon(daemon) - self.context.setContextProperty('Config', self._singletons['config']) - self.context.setContextProperty('Network', self._singletons['network']) - self.context.setContextProperty('Daemon', self._singletons['daemon']) + self._qeconfig = QEConfig(config) + self._qenetwork = QENetwork(daemon.network) + self._qedaemon = QEDaemon(daemon) + self._appController = QEAppController(self._qedaemon) + self.context.setContextProperty('AppController', self._appController) + self.context.setContextProperty('Config', self._qeconfig) + self.context.setContextProperty('Network', self._qenetwork) + self.context.setContextProperty('Daemon', self._qedaemon) self.context.setContextProperty('FixedFont', self.fixedFont) qInstallMessageHandler(self.message_handler) @@ -61,9 +120,6 @@ class ElectrumQmlApplication(QGuiApplication): # get notified whether root QML document loads or not self.engine.objectCreated.connect(self.objectCreated) - - _valid = True - # slot is called after loading root QML. If object is None, it has failed. @pyqtSlot('QObject*', 'QUrl') def objectCreated(self, object, url): @@ -76,5 +132,3 @@ class ElectrumQmlApplication(QGuiApplication): if re.search('file:///.*TypeError: Cannot read property.*null$', file): return self.logger.warning(file) - - diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 59a134ad3..9ec0511c8 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -1,6 +1,8 @@ -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl - from typing import Optional, TYPE_CHECKING, Sequence, List, Union +import queue +import time + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QTimer from electrum.i18n import _ from electrum.util import register_callback, Satoshis, format_time @@ -16,6 +18,22 @@ from .qetransactionlistmodel import QETransactionListModel from .qeaddresslistmodel import QEAddressListModel class QEWallet(QObject): + _logger = get_logger(__name__) + + # emitted when wallet wants to display a user notification + # actual presentation should be handled on app or window level + userNotify = pyqtSignal(object, object) + + # shared signal for many static wallet properties + dataChanged = pyqtSignal() + + isUptodateChanged = pyqtSignal() + requestStatus = pyqtSignal() + requestCreateSuccess = pyqtSignal() + requestCreateError = pyqtSignal([str,str], arguments=['code','error']) + + _network_signal = pyqtSignal(str, object) + def __init__(self, wallet, parent=None): super().__init__(parent) self.wallet = wallet @@ -26,20 +44,95 @@ class QEWallet(QObject): self._historyModel.init_model() self._requestModel.init_model() - register_callback(self.on_request_status, ['request_status']) - register_callback(self.on_status, ['status']) + self.tx_notification_queue = queue.Queue() + self.tx_notification_last_time = 0 + + self.notification_timer = QTimer(self) + self.notification_timer.setSingleShot(False) + self.notification_timer.setInterval(500) # msec + self.notification_timer.timeout.connect(self.notify_transactions) + + self._network_signal.connect(self.on_network_qt) + interests = ['wallet_updated', 'network_updated', 'blockchain_updated', + 'new_transaction', 'status', 'verified', 'on_history', + 'channel', 'channels_updated', 'payment_failed', + 'payment_succeeded', 'invoice_status', 'request_status'] + # To avoid leaking references to "self" that prevent the + # window from being GC-ed when closed, callbacks should be + # methods of this class only, and specifically not be + # partials, lambdas or methods of subobjects. Hence... + register_callback(self.on_network, interests) - _logger = get_logger(__name__) + @pyqtProperty(bool, notify=isUptodateChanged) + def isUptodate(self): + return self.wallet.is_up_to_date() - dataChanged = pyqtSignal() # dummy to silence warnings + def on_network(self, event, *args): + # Handle in GUI thread (_network_signal -> on_network_qt) + self._network_signal.emit(event, args) + + def on_network_qt(self, event, args=None): + # note: we get events from all wallets! args are heterogenous so we can't + # shortcut here + if event == 'status': + self.isUptodateChanged.emit() + elif event == 'request_status': + self._logger.info(str(args)) + self.requestStatus.emit() + elif event == 'new_transaction': + wallet, tx = args + if wallet == self.wallet: + self.add_tx_notification(tx) + self._historyModel.init_model() + else: + self._logger.debug('unhandled event: %s %s' % (event, str(args))) - requestCreateSuccess = pyqtSignal() - requestCreateError = pyqtSignal([str,str], arguments=['code','error']) - requestStatus = pyqtSignal() - def on_request_status(self, event, *args): - self._logger.debug(str(event)) - self.requestStatus.emit() + def add_tx_notification(self, tx): + self._logger.debug('new transaction event') + self.tx_notification_queue.put(tx) + if not self.notification_timer.isActive(): + self._logger.debug('starting wallet notification timer') + self.notification_timer.start() + + def notify_transactions(self): + if self.tx_notification_queue.qsize() == 0: + self._logger.debug('queue empty, stopping wallet notification timer') + self.notification_timer.stop() + return + if not self.wallet.up_to_date: + return # no notifications while syncing + now = time.time() + rate_limit = 20 # seconds + if self.tx_notification_last_time + rate_limit > now: + return + self.tx_notification_last_time = now + self._logger.info("Notifying app about new transactions") + txns = [] + while True: + try: + txns.append(self.tx_notification_queue.get_nowait()) + except queue.Empty: + break + + from .qeapp import ElectrumQmlApplication + config = ElectrumQmlApplication._config + # Combine the transactions if there are at least three + if len(txns) >= 3: + total_amount = 0 + for tx in txns: + tx_wallet_delta = self.wallet.get_wallet_delta(tx) + if not tx_wallet_delta.is_relevant: + continue + total_amount += tx_wallet_delta.delta + self.userNotify.emit(self.wallet, _("{} new transactions: Total amount received in the new transactions {}").format(len(txns), config.format_amount_and_units(total_amount))) + else: + for tx in txns: + tx_wallet_delta = self.wallet.get_wallet_delta(tx) + if not tx_wallet_delta.is_relevant: + continue + self.userNotify.emit(self.wallet, + _("New transaction: {}").format(config.format_amount_and_units(tx_wallet_delta.delta))) historyModelChanged = pyqtSignal() @pyqtProperty(QETransactionListModel, notify=historyModelChanged) @@ -105,16 +198,6 @@ class QEWallet(QObject): return c+x - def on_status(self, status): - self._logger.info('wallet: status update: ' + str(status)) - self.isUptodateChanged.emit() - - # lightning feature? - isUptodateChanged = pyqtSignal() - @pyqtProperty(bool, notify=isUptodateChanged) - def isUptodate(self): - return self.wallet.is_up_to_date() - @pyqtSlot('QString', int, int, bool) def send_onchain(self, address, amount, fee=None, rbf=False): self._logger.info('send_onchain: ' + address + ' ' + str(amount))