diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml index d7dc00138..325978180 100644 --- a/electrum/gui/qml/components/RequestDialog.qml +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -5,6 +5,8 @@ import QtQuick.Controls.Material 2.0 import org.electrum 1.0 +import "controls" + Dialog { id: dialog title: qsTr('Payment Request') @@ -12,6 +14,7 @@ Dialog { property var modelItem property string _bip21uri + property string _bolt11 parent: Overlay.overlay modal: true @@ -44,55 +47,96 @@ Dialog { clip:true interactive: height < contentHeight - GridLayout { + ColumnLayout { id: rootLayout width: parent.width - rowSpacing: constants.paddingMedium - columns: 5 + spacing: constants.paddingMedium + + states: [ + State { + name: 'bolt11' + PropertyChanges { target: qrloader; sourceComponent: qri_bolt11 } + PropertyChanges { target: bolt11label; font.bold: true } + }, + State { + name: 'bip21uri' + PropertyChanges { target: qrloader; sourceComponent: qri_bip21uri } + PropertyChanges { target: bip21label; font.bold: true } + } + ] Rectangle { height: 1 Layout.fillWidth: true - Layout.columnSpan: 5 color: Material.accentColor } - Image { - id: qr - Layout.columnSpan: 5 + Item { Layout.alignment: Qt.AlignHCenter Layout.topMargin: constants.paddingSmall Layout.bottomMargin: constants.paddingSmall - Rectangle { - property int size: 57 // should be qr pixel multiple - color: 'white' - x: (parent.width - size) / 2 - y: (parent.height - size) / 2 - width: size - height: size - - Image { - - source: '../../icons/electrum.png' - x: 1 - y: 1 - width: parent.width - 2 - height: parent.height - 2 - scale: 0.9 + Layout.preferredWidth: qrloader.width + Layout.preferredHeight: qrloader.height + + Loader { + id: qrloader + Component { + id: qri_bip21uri + QRImage { + qrdata: _bip21uri + } + } + Component { + id: qri_bolt11 + QRImage { + qrdata: _bolt11 + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + if (rootLayout.state == 'bolt11') { + if (_bip21uri != '') + rootLayout.state = 'bip21uri' + } else if (rootLayout.state == 'bip21uri') { + if (_bolt11 != '') + rootLayout.state = 'bolt11' + } } } } + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: constants.paddingLarge + Label { + id: bolt11label + text: qsTr('BOLT11') + color: _bolt11 ? Material.foreground : constants.mutedForeground + } + Rectangle { + Layout.preferredWidth: constants.paddingXXSmall + Layout.preferredHeight: constants.paddingXXSmall + radius: constants.paddingXXSmall / 2 + color: Material.accentColor + } + Label { + id: bip21label + text: qsTr('BIP21 URI') + color: _bip21uri ? Material.foreground : constants.mutedForeground + } + } + Rectangle { height: 1 Layout.fillWidth: true - Layout.columnSpan: 5 color: Material.accentColor } RowLayout { - Layout.columnSpan: 5 Layout.alignment: Qt.AlignHCenter Button { icon.source: '../../icons/delete.png' @@ -127,80 +171,84 @@ Dialog { } } } - Label { - visible: modelItem.message != '' - text: qsTr('Description') - } - Label { - visible: modelItem.message != '' - Layout.columnSpan: 4 - Layout.fillWidth: true - wrapMode: Text.Wrap - text: modelItem.message - font.pixelSize: constants.fontSizeLarge - } - Label { - visible: modelItem.amount.satsInt != 0 - text: qsTr('Amount') - } - Label { - visible: modelItem.amount.satsInt != 0 - text: Config.formatSats(modelItem.amount) - font.family: FixedFont - font.pixelSize: constants.fontSizeLarge - font.bold: true - } - Label { - visible: modelItem.amount.satsInt != 0 - text: Config.baseUnit - color: Material.accentColor - font.pixelSize: constants.fontSizeLarge - } + GridLayout { + columns: 2 - Label { - id: fiatValue - visible: modelItem.amount.satsInt != 0 - Layout.fillWidth: true - Layout.columnSpan: 2 - text: Daemon.fx.enabled - ? '(' + Daemon.fx.fiatValue(modelItem.amount, false) + ' ' + Daemon.fx.fiatCurrency + ')' - : '' - font.pixelSize: constants.fontSizeMedium - wrapMode: Text.Wrap - } + Label { + visible: modelItem.message != '' + text: qsTr('Description') + } + Label { + visible: modelItem.message != '' + Layout.fillWidth: true + wrapMode: Text.Wrap + text: modelItem.message + font.pixelSize: constants.fontSizeLarge + } - Label { - text: qsTr('Address') - visible: !modelItem.is_lightning - } - Label { - Layout.fillWidth: true - Layout.columnSpan: 3 - visible: !modelItem.is_lightning - font.family: FixedFont - font.pixelSize: constants.fontSizeLarge - wrapMode: Text.WrapAnywhere - text: modelItem.address - } - ToolButton { - icon.source: '../../icons/copy_bw.png' - visible: !modelItem.is_lightning - onClicked: { - AppController.textToClipboard(modelItem.address) + Label { + visible: modelItem.amount.satsInt != 0 + text: qsTr('Amount') } - } + RowLayout { + Label { + visible: modelItem.amount.satsInt != 0 + text: Config.formatSats(modelItem.amount) + font.family: FixedFont + font.pixelSize: constants.fontSizeLarge + font.bold: true + } + Label { + visible: modelItem.amount.satsInt != 0 + text: Config.baseUnit + color: Material.accentColor + font.pixelSize: constants.fontSizeLarge + } - Label { - text: qsTr('Status') - } - Label { - Layout.columnSpan: 4 - Layout.fillWidth: true - font.pixelSize: constants.fontSizeLarge - text: modelItem.status_str - } + Label { + id: fiatValue + visible: modelItem.amount.satsInt != 0 + Layout.fillWidth: true + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(modelItem.amount, false) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + font.pixelSize: constants.fontSizeMedium + wrapMode: Text.Wrap + } + } + + Label { + text: qsTr('Address') + visible: !modelItem.is_lightning + } + + RowLayout { + visible: !modelItem.is_lightning + Label { + Layout.fillWidth: true + font.family: FixedFont + font.pixelSize: constants.fontSizeLarge + wrapMode: Text.WrapAnywhere + text: modelItem.address + } + ToolButton { + icon.source: '../../icons/copy_bw.png' + onClicked: { + AppController.textToClipboard(modelItem.address) + } + } + } + Label { + text: qsTr('Status') + } + Label { + Layout.fillWidth: true + font.pixelSize: constants.fontSizeLarge + text: modelItem.status_str + } + } } } @@ -216,9 +264,14 @@ Dialog { Component.onCompleted: { if (!modelItem.is_lightning) { _bip21uri = bitcoin.create_bip21_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.expiration - modelItem.timestamp) - qr.source = 'image://qrgen/' + _bip21uri + rootLayout.state = 'bip21uri' } else { - qr.source = 'image://qrgen/' + modelItem.lightning_invoice + _bolt11 = modelItem.lightning_invoice + rootLayout.state = 'bolt11' + if (modelItem.address != '') { + _bip21uri = bitcoin.create_bip21_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.expiration - modelItem.timestamp) + console.log('BIP21:' + _bip21uri) + } } } diff --git a/electrum/gui/qml/components/controls/GenericShareDialog.qml b/electrum/gui/qml/components/controls/GenericShareDialog.qml index 1e008b7c6..39aeb4886 100644 --- a/electrum/gui/qml/components/controls/GenericShareDialog.qml +++ b/electrum/gui/qml/components/controls/GenericShareDialog.qml @@ -51,29 +51,11 @@ Dialog { color: Material.accentColor } - Image { + QRImage { id: qr Layout.alignment: Qt.AlignHCenter Layout.topMargin: constants.paddingSmall Layout.bottomMargin: constants.paddingSmall - - Rectangle { - property int size: 57 // should be qr pixel multiple - color: 'white' - x: (parent.width - size) / 2 - y: (parent.height - size) / 2 - width: size - height: size - - Image { - source: '../../../icons/electrum.png' - x: 1 - y: 1 - width: parent.width - 2 - height: parent.height - 2 - scale: 0.9 - } - } } Rectangle { @@ -114,6 +96,6 @@ Dialog { } Component.onCompleted: { - qr.source = 'image://qrgen/' + dialog.text + qr.qrdata = dialog.text } } diff --git a/electrum/gui/qml/components/controls/QRImage.qml b/electrum/gui/qml/components/controls/QRImage.qml new file mode 100644 index 000000000..3318a4a19 --- /dev/null +++ b/electrum/gui/qml/components/controls/QRImage.qml @@ -0,0 +1,25 @@ +import QtQuick 2.6 + +Image { + property string qrdata + + source: qrdata ? 'image://qrgen/' + qrdata : '' + + Rectangle { + property var qrprops: QRIP.getDimensions(qrdata) + color: 'white' + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + width: qrprops.icon_modules * qrprops.box_size + height: qrprops.icon_modules * qrprops.box_size + + Image { + source: '../../../icons/electrum.png' + x: 1 + y: 1 + width: parent.width - 2 + height: parent.height - 2 + scale: 0.9 + } + } +} diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index e3e064903..fff96d058 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -14,7 +14,7 @@ from .qeconfig import QEConfig from .qedaemon import QEDaemon, QEWalletListModel from .qenetwork import QENetwork from .qewallet import QEWallet -from .qeqr import QEQRParser, QEQRImageProvider +from .qeqr import QEQRParser, QEQRImageProvider, QEQRImageProviderHelper from .qewalletdb import QEWalletDB from .qebitcoin import QEBitcoin from .qefx import QEFX @@ -166,6 +166,7 @@ class ElectrumQmlApplication(QGuiApplication): self.qr_ip = QEQRImageProvider((7/8)*min(screensize.width(), screensize.height())) self.engine.addImageProvider('qrgen', self.qr_ip) + self.qr_ip_h = QEQRImageProviderHelper((7/8)*min(screensize.width(), screensize.height())) # add a monospace font as we can't rely on device having one self.fixedFont = 'PT Mono' @@ -187,6 +188,7 @@ class ElectrumQmlApplication(QGuiApplication): self.context.setContextProperty('Daemon', self._qedaemon) self.context.setContextProperty('FixedFont', self.fixedFont) self.context.setContextProperty('MAX', self._maxAmount) + self.context.setContextProperty('QRIP', self.qr_ip_h) self.context.setContextProperty('BUILD', { 'electrum_version': version.ELECTRUM_VERSION, 'apk_version': version.APK_VERSION, diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index 0d1626850..43e4a19ae 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -1,13 +1,14 @@ -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QRect, QPoint -from PyQt5.QtGui import QImage,QColor -from PyQt5.QtQuick import QQuickImageProvider - import asyncio import qrcode import math +import urllib from PIL import Image, ImageQt +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRect, QPoint +from PyQt5.QtGui import QImage,QColor +from PyQt5.QtQuick import QQuickImageProvider + from electrum.logging import get_logger from electrum.qrreader import get_qr_reader from electrum.i18n import _ @@ -126,17 +127,53 @@ class QEQRImageProvider(QQuickImageProvider): @profiler def requestImage(self, qstr, size): + # Qt does a urldecode before passing the string here + # but BIP21 (and likely other uri based specs) requires urlencoding, + # so we re-encode percent-quoted if a 'scheme' is found in the string + uri = urllib.parse.urlparse(qstr) + if uri.scheme: + # urlencode request parameters + query = urllib.parse.parse_qs(uri.query) + query = urllib.parse.urlencode(query, doseq=True, quote_via=urllib.parse.quote) + uri = uri._replace(query=query) + qstr = urllib.parse.urlunparse(uri) + self._logger.debug('QR requested for %s' % qstr) qr = qrcode.QRCode(version=1, border=2) qr.add_data(qstr) # calculate best box_size pixelsize = min(self._max_size, 400) - modules = 17 + 4 * qr.best_fit() - qr.box_size = math.floor(pixelsize/(modules+2*2)) + modules = 17 + 4 * qr.best_fit() + qr.border * 2 + qr.box_size = math.floor(pixelsize/modules) qr.make(fit=True) pimg = qr.make_image(fill_color='black', back_color='white') self.qimg = ImageQt.ImageQt(pimg) return self.qimg, self.qimg.size() + +# helper for placing icon exactly where it should go on the QR code +# pyqt5 is unwilling to accept slots on QEQRImageProvider, so we need to define +# a separate class (sigh) +class QEQRImageProviderHelper(QObject): + def __init__(self, max_size, parent=None): + super().__init__(parent) + self._max_size = max_size + + @pyqtSlot(str, result='QVariantMap') + def getDimensions(self, qstr): + qr = qrcode.QRCode(version=1, border=2) + qr.add_data(qstr) + + # calculate best box_size + pixelsize = min(self._max_size, 400) + modules = 17 + 4 * qr.best_fit() + qr.border * 2 + qr.box_size = math.floor(pixelsize/modules) + + # calculate icon width in modules + icon_modules = int(modules / 5) + icon_modules += (icon_modules+1)%2 # force odd + + return { 'modules': modules, 'box_size': qr.box_size, 'icon_modules': icon_modules } +