diff --git a/.gitmodules b/.gitmodules index 9f1c76c1d..2b2c88706 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,6 @@ [submodule "contrib/deterministic-build/electrum-locale"] path = contrib/deterministic-build/electrum-locale url = https://github.com/spesmilo/electrum-locale -[submodule "contrib/CalinsQRReader"] - path = contrib/osx/CalinsQRReader - url = https://github.com/spesmilo/CalinsQRReader [submodule "electrum/www"] path = electrum/www url = https://github.com/spesmilo/electrum-http.git diff --git a/contrib/build-wine/deterministic.spec b/contrib/build-wine/deterministic.spec index 9d09b11e2..34917c6fc 100644 --- a/contrib/build-wine/deterministic.spec +++ b/contrib/build-wine/deterministic.spec @@ -53,6 +53,7 @@ datas += collect_data_files('bitbox02') # We don't put these files in to actually include them in the script but to make the Analysis method scan them for imports a = Analysis([home+'run_electrum', home+'electrum/gui/qt/main_window.py', + home+'electrum/gui/qt/qrreader/camera_dialog.py', home+'electrum/gui/text.py', home+'electrum/util.py', home+'electrum/wallet.py', diff --git a/contrib/osx/CalinsQRReader b/contrib/osx/CalinsQRReader deleted file mode 160000 index 59dfc0327..000000000 --- a/contrib/osx/CalinsQRReader +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 59dfc03272751cd29ee311456fa34c40f7ebb7c0 diff --git a/contrib/osx/README.md b/contrib/osx/README.md index dfcf9bb66..9288e6570 100644 --- a/contrib/osx/README.md +++ b/contrib/osx/README.md @@ -31,34 +31,7 @@ We currently build the release binaries on macOS 10.14.6, and these seem to run Before starting, make sure that the Xcode command line tools are installed (e.g. you have `git`). -#### 1.a Get Xcode - -Building the QR scanner (CalinsQRReader) requires full Xcode (not just command line tools). - -Get it from [here](https://developer.apple.com/download/more/). -Unfortunately, you need an "Apple ID" account. - -(note: the last Xcode that runs on macOS 10.14.6 is Xcode 11.3.1) - -After downloading, uncompress it. - -Make sure it is the "selected" xcode (e.g.): - - sudo xcode-select -s $HOME/Downloads/Xcode.app/Contents/Developer/ - -#### 1.b Build QR scanner separately on another Mac - -Alternatively, you can try building just the QR scanner on another Mac. - -On newer Mac, run: - - pushd contrib/osx/CalinsQRReader; xcodebuild; popd - cp -r contrib/osx/CalinsQRReader/build prebuilt_qr - -Move `prebuilt_qr` to El Capitan: `contrib/osx/CalinsQRReader/prebuilt_qr`. - - -#### 2. Build Electrum +#### Build Electrum cd electrum ./contrib/osx/make_osx diff --git a/contrib/osx/make_osx b/contrib/osx/make_osx index ce09d9177..251ece9f2 100755 --- a/contrib/osx/make_osx +++ b/contrib/osx/make_osx @@ -122,21 +122,16 @@ cp "$BUILDDIR/libusb/1.0.23/lib/libusb-1.0.dylib" contrib/osx echo "caea266f3fc3982adc55d6cb8d9bad10f6e61f0c24ce5901aa1804618e08e14d contrib/osx/libusb-1.0.dylib" | \ shasum -a 256 -c || fail "libusb checksum mismatched" -# install some build-time deps for compilation -brew install autoconf automake libtool gettext +info "Installing some build-time deps for compilation..." +brew install autoconf automake libtool gettext coreutils pkgconfig -info "Preparing for building libsecp256k1" +info "Building libsecp256k1 dylib..." "$CONTRIB"/make_libsecp256k1.sh || fail "Could not build libsecp" -cp "$ROOT_FOLDER"/electrum/libsecp256k1.0.dylib contrib/osx - -info "Building CalinsQRReader..." -d=contrib/osx/CalinsQRReader -pushd "$d" -rm -fr build -# prefer building using xcode ourselves. otherwise fallback to prebuilt binary -xcodebuild || cp -r prebuilt_qr build || fail "Could not build CalinsQRReader" -popd -DoCodeSignMaybe "CalinsQRReader.app" "${d}/build/Release/CalinsQRReader.app" +cp "$ROOT_FOLDER"/electrum/libsecp256k1.0.dylib "$CONTRIB"/osx + +info "Building ZBar dylib..." +"$CONTRIB"/make_zbar.sh || fail "Could not build ZBar dylib" +cp "$ROOT_FOLDER"/electrum/libzbar.0.dylib "$CONTRIB"/osx info "Installing requirements..." diff --git a/contrib/osx/osx.spec b/contrib/osx/osx.spec index 019f0a0c3..65d66182b 100644 --- a/contrib/osx/osx.spec +++ b/contrib/osx/osx.spec @@ -85,12 +85,10 @@ datas += collect_data_files('keepkeylib') datas += collect_data_files('ckcc') datas += collect_data_files('bitbox02') -# Add the QR Scanner helper app -datas += [(electrum + "contrib/osx/CalinsQRReader/build/Release/CalinsQRReader.app", "./contrib/osx/CalinsQRReader/build/Release/CalinsQRReader.app")] - # Add libusb so Trezor and Safe-T mini will work binaries = [(electrum + "contrib/osx/libusb-1.0.dylib", ".")] binaries += [(electrum + "contrib/osx/libsecp256k1.0.dylib", ".")] +binaries += [(electrum + "contrib/osx/libzbar.0.dylib", ".")] # Workaround for "Retro Look": binaries += [b for b in collect_dynamic_libs('PyQt5') if 'macstyle' in b[0]] @@ -98,6 +96,7 @@ binaries += [b for b in collect_dynamic_libs('PyQt5') if 'macstyle' in b[0]] # We don't put these files in to actually include them in the script but to make the Analysis method scan them for imports a = Analysis([electrum+ MAIN_SCRIPT, electrum+'electrum/gui/qt/main_window.py', + electrum+'electrum/gui/qt/qrreader/camera_dialog.py', electrum+'electrum/gui/text.py', electrum+'electrum/util.py', electrum+'electrum/wallet.py', diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index af4c3ae53..c28dda984 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -39,7 +39,7 @@ except Exception: from PyQt5.QtGui import QGuiApplication from PyQt5.QtWidgets import (QApplication, QSystemTrayIcon, QWidget, QMenu, QMessageBox) -from PyQt5.QtCore import QObject, pyqtSignal, QTimer +from PyQt5.QtCore import QObject, pyqtSignal, QTimer, Qt import PyQt5.QtCore as QtCore from electrum.i18n import _, set_language @@ -52,7 +52,7 @@ from electrum.wallet_db import WalletDB from electrum.logging import Logger from .installwizard import InstallWizard, WalletAlreadyOpenInMemory -from .util import get_default_language, read_QIcon, ColorScheme, custom_message_box +from .util import get_default_language, read_QIcon, ColorScheme, custom_message_box, MessageBoxMixin from .main_window import ElectrumWindow from .network_dialog import NetworkDialog from .stylesheet_patcher import patch_qt_stylesheet @@ -275,6 +275,28 @@ class ElectrumGui(Logger): network_updated_signal_obj=self.network_updated_signal_obj) self.network_dialog.show() + @staticmethod + def warn_if_cant_import_qrreader(parent, *, show_warning=True) -> bool: + """Checks it QR reading from camera is possible. It can fail on a + system lacking QtMultimedia. This can be removed in the future when + we are unlikely to encounter Qt5 installations that are missing + QtMultimedia + """ + try: + from .qrreader import QrReaderCameraDialog + except ImportError as e: + if show_warning: + icon = QMessageBox.Warning + title = _("QR Reader Error") + message = _("QR reader failed to load. This may happen if " + "you are using an older version of PyQt5.") + "\n\n" + str(e) + if isinstance(parent, MessageBoxMixin): + parent.msg_box(title=title, text=message, icon=icon, parent=None) + else: + custom_message_box(title=title, text=message, icon=icon, parent=parent) + return True + return False + def _create_window_for_wallet(self, wallet): w = ElectrumWindow(self, wallet) self.windows.append(w) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index f234f0ab2..4371b1a9c 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -2820,31 +2820,54 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.show_error("failed to import backup" + '\n' + str(e)) return + # Due to the asynchronous nature of the qr reader we need to keep the + # dialog instance as member variable to prevent reentrancy/multiple ones + # from being presented at once. + _qr_dialog = None + def read_tx_from_qrcode(self): - from electrum import qrscanner - try: - data = qrscanner.scan_barcode(self.config.get_video_device()) - except UserFacingException as e: - self.show_error(e) + if self._qr_dialog: + self.logger.warning("QR dialog is already presented, ignoring.") return - except BaseException as e: + if self.gui_object.warn_if_cant_import_qrreader(self): + return + from .qrreader import QrReaderCameraDialog, CameraError, MissingQrDetectionLib + self._qr_dialog = None + try: + self._qr_dialog = QrReaderCameraDialog(parent=self.top_level_window(), config=self.config) + + def _on_qr_reader_finished(success: bool, error: str, data): + if self._qr_dialog: + self._qr_dialog.deleteLater() + self._qr_dialog = None + if not success: + if error: + self.show_error(error) + return + if not data: + return + # if the user scanned a bitcoin URI + if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): + self.pay_to_URI(data) + return + if data.lower().startswith('channel_backup:'): + self.import_channel_backup(data) + return + # else if the user scanned an offline signed tx + tx = self.tx_from_text(data) + if not tx: + return + self.show_transaction(tx) + + self._qr_dialog.qr_finished.connect(_on_qr_reader_finished) + self._qr_dialog.start_scan(self.config.get_video_device()) + except (MissingQrDetectionLib, CameraError) as e: + self._qr_dialog = None + self.show_error(str(e)) + except Exception as e: self.logger.exception('camera error') + self._qr_dialog = None self.show_error(repr(e)) - return - if not data: - return - # if the user scanned a bitcoin URI - if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): - self.pay_to_URI(data) - return - if data.lower().startswith('channel_backup:'): - self.import_channel_backup(data) - return - # else if the user scanned an offline signed tx - tx = self.tx_from_text(data) - if not tx: - return - self.show_transaction(tx) def read_tx_from_file(self) -> Optional[Transaction]: fileName = getOpenFileName( diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 8183ebdf1..b3b60bdf6 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -260,11 +260,12 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.setMaximumHeight(h) self.verticalScrollBar().hide() - def qr_input(self): - data = super(PayToEdit,self).qr_input() - if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): - self.win.pay_to_URI(data) - # TODO: update fee + def qr_input(self, *, callback=None): + def _on_qr_success(data): + if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): + self.win.pay_to_URI(data) + # TODO: update fee + super(PayToEdit, self).qr_input(callback=_on_qr_success) def resolve(self): self.is_alias = False diff --git a/electrum/gui/qt/qrreader/__init__.py b/electrum/gui/qt/qrreader/__init__.py new file mode 100644 index 000000000..2fbdf34f6 --- /dev/null +++ b/electrum/gui/qt/qrreader/__init__.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# +# Electron Cash - lightweight Bitcoin client +# Copyright (C) 2019 Axel Gembe +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from .camera_dialog import (QrReaderCameraDialog, CameraError, NoCamerasFound, + NoCameraResolutionsFound, MissingQrDetectionLib) +from .validator import (QrReaderValidatorResult, AbstractQrReaderValidator, + QrReaderValidatorCounting, QrReaderValidatorColorizing, + QrReaderValidatorStrong, QrReaderValidatorCounted) diff --git a/electrum/gui/qt/qrreader/camera_dialog.py b/electrum/gui/qt/qrreader/camera_dialog.py new file mode 100644 index 000000000..e3506a8af --- /dev/null +++ b/electrum/gui/qt/qrreader/camera_dialog.py @@ -0,0 +1,461 @@ +#!/usr/bin/env python3 +# +# Electron Cash - lightweight Bitcoin client +# Copyright (C) 2019 Axel Gembe +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import time +import math +import sys +import os +from typing import List + +from PyQt5.QtMultimedia import QCameraInfo, QCamera, QCameraViewfinderSettings +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QCheckBox, QPushButton, QLabel +from PyQt5.QtGui import QImage, QPixmap +from PyQt5.QtCore import QSize, QRect, Qt, pyqtSignal, PYQT_VERSION + +from electrum.simple_config import SimpleConfig +from electrum.i18n import _ +from electrum.qrreader import get_qr_reader, QrCodeResult +from electrum.logging import Logger + +from .video_widget import QrReaderVideoWidget +from .video_overlay import QrReaderVideoOverlay +from .video_surface import QrReaderVideoSurface +from .crop_blur_effect import QrReaderCropBlurEffect +from .validator import AbstractQrReaderValidator, QrReaderValidatorCounted, QrReaderValidatorResult +from ..util import MessageBoxMixin, FixedAspectRatioLayout, ImageGraphicsEffect + +class CameraError(RuntimeError): + ''' Base class of the camera-related error conditions. ''' + +class NoCamerasFound(CameraError): + ''' Raised by start_scan if no usable cameras were found. Interested + code can catch this specific exception.''' + +class NoCameraResolutionsFound(CameraError): + ''' Raised internally if no usable camera resolutions were found. ''' + +class MissingQrDetectionLib(RuntimeError): + ''' Raised if we can't find zbar or whatever other platform lib + we require to detect QR in image frames. ''' + +class QrReaderCameraDialog(Logger, MessageBoxMixin, QDialog): + """ + Dialog for reading QR codes from a camera + """ + + # Try to crop so we have minimum 512 dimensions + SCAN_SIZE: int = 512 + + qr_finished = pyqtSignal(bool, str, object) + + def __init__(self, parent, *, config: SimpleConfig): + ''' Note: make sure parent is a "top_level_window()" as per + MessageBoxMixin API else bad things can happen on macOS. ''' + QDialog.__init__(self, parent=parent) + Logger.__init__(self) + + self.validator: AbstractQrReaderValidator = None + self.frame_id: int = 0 + self.qr_crop: QRect = None + self.qrreader_res: List[QrCodeResult] = [] + self.validator_res: QrReaderValidatorResult = None + self.last_stats_time: float = 0.0 + self.frame_counter: int = 0 + self.qr_frame_counter: int = 0 + self.last_qr_scan_ts: float = 0.0 + self.camera: QCamera = None + self._error_message: str = None + self._ok_done: bool = False + self.camera_sc_conn = None + self.resolution: QSize = None + + self.config = config + + # Try to get the QR reader for this system + self.qrreader = get_qr_reader() + if not self.qrreader: + raise MissingQrDetectionLib(_("The platform QR detection library is not available.")) + + # Set up the window, add the maximize button + flags = self.windowFlags() + flags = flags | Qt.WindowMaximizeButtonHint + self.setWindowFlags(flags) + self.setWindowTitle(_("Scan QR Code")) + self.setWindowModality(Qt.WindowModal if parent else Qt.ApplicationModal) + + # Create video widget and fixed aspect ratio layout to contain it + self.video_widget = QrReaderVideoWidget() + self.video_overlay = QrReaderVideoOverlay() + self.video_layout = FixedAspectRatioLayout() + self.video_layout.addWidget(self.video_widget) + self.video_layout.addWidget(self.video_overlay) + + # Create root layout and add the video widget layout to it + vbox = QVBoxLayout() + self.setLayout(vbox) + vbox.setContentsMargins(0, 0, 0, 0) + vbox.addLayout(self.video_layout) + + self.lowres_label = QLabel(_("Note: This camera generates frames of relatively low resolution; QR scanning accuracy may be affected")) + self.lowres_label.setWordWrap(True) + self.lowres_label.setAlignment(Qt.AlignVCenter | Qt.AlignHCenter) + vbox.addWidget(self.lowres_label) + self.lowres_label.setHidden(True) + + # Create a layout for the controls + controls_layout = QHBoxLayout() + controls_layout.addStretch(2) + controls_layout.setContentsMargins(10, 10, 10, 10) + controls_layout.setSpacing(10) + vbox.addLayout(controls_layout) + + # Flip horizontally checkbox with default coming from global config + self.flip_x = QCheckBox() + self.flip_x.setText(_("&Flip horizontally")) + self.flip_x.setChecked(bool(self.config.get('qrreader_flip_x', True))) + self.flip_x.stateChanged.connect(self._on_flip_x_changed) + controls_layout.addWidget(self.flip_x) + + close_but = QPushButton(_("&Close")) + close_but.clicked.connect(self.reject) + controls_layout.addWidget(close_but) + + # Create the video surface and receive events when new frames arrive + self.video_surface = QrReaderVideoSurface(self) + self.video_surface.frame_available.connect(self._on_frame_available) + + # Create the crop blur effect + self.crop_blur_effect = QrReaderCropBlurEffect(self) + self.image_effect = ImageGraphicsEffect(self, self.crop_blur_effect) + + + # Note these should stay as queued connections becasue we use the idiom + # self.reject() and self.accept() in this class to kill the scan -- + # and we do it from within callback functions. If you don't use + # queued connections here, bad things can happen. + self.finished.connect(self._boilerplate_cleanup, Qt.QueuedConnection) + self.finished.connect(self._on_finished, Qt.QueuedConnection) + + def _on_flip_x_changed(self, _state: int): + self.config.set_key('qrreader_flip_x', self.flip_x.isChecked()) + + def _get_resolution(self, resolutions: List[QSize], min_size: int) -> QSize: + """ + Given a list of resolutions that the camera supports this function picks the + lowest resolution that is at least min_size in both width and height. + If no resolution is found, NoCameraResolutionsFound is raised. + """ + def res_list_to_str(res_list: List[QSize]) -> str: + return ', '.join(['{}x{}'.format(r.width(), r.height()) for r in res_list]) + + def check_res(res: QSize): + return res.width() >= min_size and res.height() >= min_size + + self.logger.info('searching for at least {0}x{0}'.format(min_size)) + + # Query and display all resolutions the camera supports + format_str = 'camera resolutions: {}' + self.logger.info(format_str.format(res_list_to_str(resolutions))) + + # Filter to those that are at least min_size in both width and height + candidate_resolutions = [] + ideal_resolutions = [r for r in resolutions if check_res(r)] + less_than_ideal_resolutions = [r for r in resolutions if r not in ideal_resolutions] + format_str = 'ideal resolutions: {}, less-than-ideal resolutions: {}' + self.logger.info(format_str.format(res_list_to_str(ideal_resolutions), res_list_to_str(less_than_ideal_resolutions))) + + # Raise an error if we have no usable resolutions + if not ideal_resolutions and not less_than_ideal_resolutions: + raise NoCameraResolutionsFound(_("Cannot start QR scanner, no usable camera resolution found.") + self._linux_pyqt5bug_msg()) + + if not ideal_resolutions: + self.logger.warning('No ideal resolutions found, falling back to less-than-ideal resolutions -- QR recognition may fail!') + candidate_resolutions = less_than_ideal_resolutions + is_ideal = False + else: + candidate_resolutions = ideal_resolutions + is_ideal = True + + + # Sort the usable resolutions, least number of pixels first, get the first element + resolution = sorted(candidate_resolutions, key=lambda r: r.width() * r.height(), reverse=not is_ideal)[0] + format_str = 'chosen resolution is {}x{}' + self.logger.info(format_str.format(resolution.width(), resolution.height())) + + return resolution, is_ideal + + @staticmethod + def _get_crop(resolution: QSize, scan_size: int) -> QRect: + """ + Returns a QRect that is scan_size x scan_size in the middle of the resolution + """ + scan_pos_x = (resolution.width() - scan_size) / 2 + scan_pos_y = (resolution.height() - scan_size) / 2 + return QRect(scan_pos_x, scan_pos_y, scan_size, scan_size) + + @staticmethod + def _linux_pyqt5bug_msg(): + ''' Returns a string that may be appended to an exception error message + only if on Linux and PyQt5 < 5.12.2, otherwise returns an empty string. ''' + if (sys.platform == 'linux' and PYQT_VERSION < 0x050c02 # Check if PyQt5 < 5.12.2 on linux + # Also: this warning is not relevant to APPIMAGE; so make sure + # we are not running from APPIMAGE. + and not os.environ.get('APPIMAGE')): + # In this case it's possible we couldn't detect a camera because + # of that missing libQt5MultimediaGstTools.so problem. + return ("\n\n" + _('If you indeed do have a usable camera connected, then this error may be caused by bugs in previous PyQt5 versions on Linux. Try installing the latest PyQt5:') + + "\n\n" + "python3 -m pip install --user -I pyqt5") + return '' + + def start_scan(self, device: str = ''): + """ + Scans a QR code from the given camera device. + If no QR code is found the returned string will be empty. + If the camera is not found or can't be opened NoCamerasFound will be raised. + """ + + self.validator = QrReaderValidatorCounted() + self.validator.strong_count = 5 # FIXME: make this time based rather than framect based + + device_info = None + + for camera in QCameraInfo.availableCameras(): + if camera.deviceName() == device: + device_info = camera + break + + if not device_info: + self.logger.info('Failed to open selected camera, trying to use default camera') + device_info = QCameraInfo.defaultCamera() + + if not device_info or device_info.isNull(): + raise NoCamerasFound(_("Cannot start QR scanner, no usable camera found.") + self._linux_pyqt5bug_msg()) + + self._init_stats() + self.qrreader_res = [] + self.validator_res = None + self._ok_done = False + self._error_message = None + + if self.camera: + self.logger.info("Warning: start_scan already called for this instance.") + + self.camera = QCamera(device_info) + self.camera.setViewfinder(self.video_surface) + self.camera.setCaptureMode(QCamera.CaptureViewfinder) + + # this operates on camera from within the signal handler, so should be a queued connection + self.camera_sc_conn = self.camera.statusChanged.connect(self._on_camera_status_changed, Qt.QueuedConnection) + self.camera.error.connect(self._on_camera_error) # log the errors we get, if any, for debugging + # Camera needs to be loaded to query resolutions, this tries to open the camera + self.camera.load() + + _camera_status_names = { + QCamera.UnavailableStatus: _('unavailable'), + QCamera.UnloadedStatus: _('unloaded'), + QCamera.UnloadingStatus: _('unloading'), + QCamera.LoadingStatus: _('loading'), + QCamera.LoadedStatus: _('loaded'), + QCamera.StandbyStatus: _('standby'), + QCamera.StartingStatus: _('starting'), + QCamera.StoppingStatus: _('stopping'), + QCamera.ActiveStatus: _('active') + } + + def _get_camera_status_name(self, status: QCamera.Status): + return self._camera_status_names.get(status, _('unknown')) + + def _set_resolution(self, resolution: QSize): + self.resolution = resolution + self.qr_crop = self._get_crop(resolution, self.SCAN_SIZE) + + # Initialize the video widget + #self.video_widget.setMinimumSize(resolution) # <-- on macOS this makes it fixed size for some reason. + self.resize(720, 540) + self.video_overlay.set_crop(self.qr_crop) + self.video_overlay.set_resolution(resolution) + self.video_layout.set_aspect_ratio(resolution.width() / resolution.height()) + + # Set up the crop blur effect + self.crop_blur_effect.setCrop(self.qr_crop) + + def _on_camera_status_changed(self, status: QCamera.Status): + if self._ok_done: + # camera/scan is quitting, abort. + return + + self.logger.info('camera status changed to {}'.format(self._get_camera_status_name(status))) + + if status == QCamera.LoadedStatus: + # Determine the optimal resolution and compute the crop rect + camera_resolutions = self.camera.supportedViewfinderResolutions() + try: + resolution, was_ideal = self._get_resolution(camera_resolutions, self.SCAN_SIZE) + except RuntimeError as e: + self._error_message = str(e) + self.reject() + return + self._set_resolution(resolution) + + # Set the camera resolution + viewfinder_settings = QCameraViewfinderSettings() + viewfinder_settings.setResolution(resolution) + self.camera.setViewfinderSettings(viewfinder_settings) + + # Counter for the QR scanner frame number + self.frame_id = 0 + + self.camera.start() + self.lowres_label.setVisible(not was_ideal) # if they have a low res camera, show the warning label. + elif status == QCamera.UnloadedStatus or status == QCamera.UnavailableStatus: + self._error_message = _("Cannot start QR scanner, camera is unavailable.") + self.reject() + elif status == QCamera.ActiveStatus: + self.open() + + CameraErrorStrings = { + QCamera.NoError : "No Error", + QCamera.CameraError : "Camera Error", + QCamera.InvalidRequestError : "Invalid Request Error", + QCamera.ServiceMissingError : "Service Missing Error", + QCamera.NotSupportedFeatureError : "Unsupported Feature Error" + } + def _on_camera_error(self, errorCode): + errStr = self.CameraErrorStrings.get(errorCode, "Unknown Error") + self.logger.info(f"QCamera error: {errStr}") + + def accept(self): + self._ok_done = True # immediately blocks further processing + super().accept() + + def reject(self): + self._ok_done = True # immediately blocks further processing + super().reject() + + def _boilerplate_cleanup(self): + self._close_camera() + if self.isVisible(): + self.close() + + def _close_camera(self): + if self.camera: + self.camera.setViewfinder(None) + if self.camera_sc_conn: + self.camera.statusChanged.disconnect(self.camera_sc_conn) + self.camera_sc_conn = None + self.camera.unload() + self.camera = None + + def _on_finished(self, code): + res = ( (code == QDialog.Accepted + and self.validator_res and self.validator_res.accepted + and self.validator_res.simple_result) + or '' ) + + self.validator = None + + self.logger.info(f'closed {res}') + + self.qr_finished.emit(code == QDialog.Accepted, self._error_message, res) + + def _on_frame_available(self, frame: QImage): + if self._ok_done: + return + + self.frame_id += 1 + + if frame.size() != self.resolution: + self.logger.info('Getting video data at {}x{} instead of the requested {}x{}, switching resolution.'.format( + frame.size().width(), frame.size().height(), + self.resolution.width(), self.resolution.height() + )) + self._set_resolution(frame.size()) + + flip_x = self.flip_x.isChecked() + + # Only QR scan every QR_SCAN_PERIOD secs + qr_scanned = time.time() - self.last_qr_scan_ts >= self.qrreader.interval() + if qr_scanned: + self.last_qr_scan_ts = time.time() + # Crop the frame so we only scan a SCAN_SIZE rect + frame_cropped = frame.copy(self.qr_crop) + + # Convert to Y800 / GREY FourCC (single 8-bit channel) + # This creates a copy, so we don't need to keep the frame around anymore + frame_y800 = frame_cropped.convertToFormat(QImage.Format_Grayscale8) + + # Read the QR codes from the frame + self.qrreader_res = self.qrreader.read_qr_code( + frame_y800.constBits().__int__(), frame_y800.byteCount(), + frame_y800.bytesPerLine(), + frame_y800.width(), + frame_y800.height(), self.frame_id + ) + + # Call the validator to see if the scanned results are acceptable + self.validator_res = self.validator.validate_results(self.qrreader_res) + + # Update the video overlay with the results + self.video_overlay.set_results(self.qrreader_res, flip_x, self.validator_res) + + # Close the dialog if the validator accepted the result + if self.validator_res.accepted: + self.accept() + return + + # Apply the crop blur effect + if self.image_effect: + frame = self.image_effect.apply(frame) + + # If horizontal flipping is enabled, only flip the display + if flip_x: + frame = frame.mirrored(True, False) + + # Display the frame in the widget + self.video_widget.setPixmap(QPixmap.fromImage(frame)) + + self._update_stats(qr_scanned) + + def _init_stats(self): + self.last_stats_time = time.perf_counter() + self.frame_counter = 0 + self.qr_frame_counter = 0 + + def _update_stats(self, qr_scanned): + self.frame_counter += 1 + if qr_scanned: + self.qr_frame_counter += 1 + now = time.perf_counter() + last_stats_delta = now - self.last_stats_time + if last_stats_delta > 1.0: # stats every 1.0 seconds + fps = self.frame_counter / last_stats_delta + qr_fps = self.qr_frame_counter / last_stats_delta + if self.validator is not None: + self.validator.strong_count = math.ceil(qr_fps / 3) # 1/3 of a second's worth of qr frames determines strong_count + stats_format = 'running at {} FPS, scanner at {} FPS' + self.logger.info(stats_format.format(fps, qr_fps)) + self.frame_counter = 0 + self.qr_frame_counter = 0 + self.last_stats_time = now diff --git a/electrum/gui/qt/qrreader/crop_blur_effect.py b/electrum/gui/qt/qrreader/crop_blur_effect.py new file mode 100644 index 000000000..d1e4612e6 --- /dev/null +++ b/electrum/gui/qt/qrreader/crop_blur_effect.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# +# Electron Cash - lightweight Bitcoin client +# Copyright (C) 2019 Axel Gembe +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from PyQt5.QtWidgets import QGraphicsBlurEffect, QGraphicsEffect +from PyQt5.QtGui import QPainter, QTransform, QRegion +from PyQt5.QtCore import QObject, QRect, QPoint, Qt + + +class QrReaderCropBlurEffect(QGraphicsBlurEffect): + CROP_OFFSET_ENABLED = False + CROP_OFFSET = QPoint(5, 5) + + BLUR_DARKEN = 0.25 + BLUR_RADIUS = 8 + + def __init__(self, parent: QObject, crop: QRect = None): + super().__init__(parent) + self.crop = crop + self.setBlurRadius(self.BLUR_RADIUS) + + def setCrop(self, crop: QRect = None): + self.crop = crop + + def draw(self, painter: QPainter): + assert self.crop, 'crop must be set' + + # Compute painter regions for the crop and the blur + all_region = QRegion(painter.viewport()) + crop_region = QRegion(self.crop) + blur_region = all_region.subtracted(crop_region) + + # Let the QGraphicsBlurEffect only paint in blur_region + painter.setClipRegion(blur_region) + + # Fill with black and set opacity so that the blurred region is drawn darker + if self.BLUR_DARKEN > 0.0: + painter.fillRect(painter.viewport(), Qt.black) + painter.setOpacity(1 - self.BLUR_DARKEN) + + # Draw the blur effect + super().draw(painter) + + # Restore clipping and opacity + painter.setClipping(False) + painter.setOpacity(1.0) + + # Get the source pixmap + pixmap, offset = self.sourcePixmap(Qt.DeviceCoordinates, QGraphicsEffect.NoPad) + painter.setWorldTransform(QTransform()) + + # Get the source by adding the offset to the crop location + source = self.crop + if self.CROP_OFFSET_ENABLED: + source = source.translated(self.CROP_OFFSET) + painter.drawPixmap(self.crop.topLeft() + offset, pixmap, source) diff --git a/electrum/gui/qt/qrreader/validator.py b/electrum/gui/qt/qrreader/validator.py new file mode 100644 index 000000000..f8f8de950 --- /dev/null +++ b/electrum/gui/qt/qrreader/validator.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +# +# Electron Cash - lightweight Bitcoin client +# Copyright (C) 2019 Axel Gembe +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from typing import List, Dict, Callable, Any +from abc import ABC, abstractmethod + +from PyQt5.QtGui import QColor +from PyQt5.QtCore import Qt + +from electrum.i18n import _ +from electrum.qrreader import QrCodeResult + +from electrum.gui.qt.util import ColorScheme, QColorLerp + + +class QrReaderValidatorResult(): + """ + Result of a QR code validator + """ + + def __init__(self): + self.accepted: bool = False + + self.message: str = None + self.message_color: QColor = None + + self.simple_result : str = None + + self.result_usable: Dict[QrCodeResult, bool] = {} + self.result_colors: Dict[QrCodeResult, QColor] = {} + self.result_messages: Dict[QrCodeResult, str] = {} + + self.selected_results: List[QrCodeResult] = [] + + +class AbstractQrReaderValidator(ABC): + """ + Abstract base class for QR code result validators. + """ + + @abstractmethod + def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult: + """ + Checks a list of QR code results for usable codes. + """ + +class QrReaderValidatorCounting(AbstractQrReaderValidator): + """ + This QR code result validator doesn't directly accept any results but maintains a dictionary + of detection counts in `result_counts`. + """ + + result_counts: Dict[QrCodeResult, int] = {} + + def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult: + res = QrReaderValidatorResult() + + for result in results: + # Increment the detection count + if not result in self.result_counts: + self.result_counts[result] = 0 + self.result_counts[result] += 1 + + # Search for missing results, iterate over a copy because the loop might modify the dict + for result in self.result_counts.copy(): + # Count down missing results + if result in results: + continue + self.result_counts[result] -= 2 + # When the count goes to zero, remove + if self.result_counts[result] < 1: + del self.result_counts[result] + + return res + +class QrReaderValidatorColorizing(QrReaderValidatorCounting): + """ + This QR code result validator doesn't directly accept any results but colorizes the results + based on the counts maintained by `QrReaderValidatorCounting`. + """ + + WEAK_COLOR: QColor = QColor(Qt.red) + STRONG_COLOR: QColor = QColor(Qt.green) + + strong_count: int = 10 + + def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult: + res = super().validate_results(results) + + # Colorize the QR code results by their detection counts + for result in results: + # Enforce strong_count as upper limit + self.result_counts[result] = min(self.result_counts[result], self.strong_count) + + # Interpolate between WEAK_COLOR and STRONG_COLOR based on count / strong_count + lerp_factor = (self.result_counts[result] - 1) / self.strong_count + lerped_color = QColorLerp(self.WEAK_COLOR, self.STRONG_COLOR, lerp_factor) + res.result_colors[result] = lerped_color + + return res + +class QrReaderValidatorStrong(QrReaderValidatorColorizing): + """ + This QR code result validator doesn't directly accept any results but passes every strong + detection in the return values `selected_results`. + """ + + def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult: + res = super().validate_results(results) + + for result in results: + if self.result_counts[result] >= self.strong_count: + res.selected_results.append(result) + break + + return res + +class QrReaderValidatorCounted(QrReaderValidatorStrong): + """ + This QR code result validator accepts a result as soon as there is at least `minimum` and at + most `maximum` QR code(s) with strong detection. + """ + + def __init__(self, minimum: int = 1, maximum: int = 1): + super().__init__() + self.minimum = minimum + self.maximum = maximum + + def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult: + res = super().validate_results(results) + + num_results = len(res.selected_results) + if num_results < self.minimum: + if num_results > 0: + res.message = _('Too few QR codes detected.') + res.message_color = ColorScheme.RED.as_color() + elif num_results > self.maximum: + res.message = _('Too many QR codes detected.') + res.message_color = ColorScheme.RED.as_color() + else: + res.accepted = True + res.simple_result = (results and results[0].data) or '' # hack added by calin just to take the first one + + return res diff --git a/electrum/gui/qt/qrreader/video_overlay.py b/electrum/gui/qt/qrreader/video_overlay.py new file mode 100644 index 000000000..dab3bb0f8 --- /dev/null +++ b/electrum/gui/qt/qrreader/video_overlay.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# +# Electron Cash - lightweight Bitcoin client +# Copyright (C) 2019 Axel Gembe +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from typing import List + +from PyQt5.QtWidgets import QWidget +from PyQt5.QtGui import QPainter, QPaintEvent, QPen, QPainterPath, QColor, QTransform +from PyQt5.QtCore import QPoint, QSize, QRect, QRectF, Qt + +from electrum.qrreader import QrCodeResult + +from .validator import QrReaderValidatorResult + + +class QrReaderVideoOverlay(QWidget): + """ + Overlays the QR scanner results over the video + """ + + BG_RECT_PADDING = 10 + BG_RECT_CORNER_RADIUS = 10.0 + BG_RECT_OPACITY = 0.75 + + def __init__(self, parent: QWidget = None): + super().__init__(parent) + + self.results = [] + self.flip_x = False + self.validator_results = None + self.crop = None + self.resolution = None + + self.qr_outline_pen = QPen() + self.qr_outline_pen.setColor(Qt.red) + self.qr_outline_pen.setWidth(3) + self.qr_outline_pen.setStyle(Qt.DotLine) + + self.text_pen = QPen() + self.text_pen.setColor(Qt.black) + + self.bg_rect_pen = QPen() + self.bg_rect_pen.setColor(Qt.black) + self.bg_rect_pen.setStyle(Qt.DotLine) + self.bg_rect_fill = QColor(255, 255, 255, 255 * self.BG_RECT_OPACITY) + + def set_results(self, results: List[QrCodeResult], flip_x: bool, + validator_results: QrReaderValidatorResult): + self.results = results + self.flip_x = flip_x + self.validator_results = validator_results + self.update() + + def set_crop(self, crop: QRect): + self.crop = crop + + def set_resolution(self, resolution: QSize): + self.resolution = resolution + + def paintEvent(self, _event: QPaintEvent): + if not self.crop or not self.resolution: + return + + painter = QPainter(self) + + # Keep a backup of the transform and create a new one + transform = painter.worldTransform() + + # Set scaling transform + transform = transform.scale(self.width() / self.resolution.width(), + self.height() / self.resolution.height()) + + # Compute the transform to flip the coordinate system on the x axis + transform_flip = QTransform() + if self.flip_x: + transform_flip = transform_flip.translate(self.resolution.width(), 0.0) + transform_flip = transform_flip.scale(-1.0, 1.0) + + # Small helper for tuple to QPoint + def toqp(point): + return QPoint(point[0], point[1]) + + # Starting from here we care about AA + painter.setRenderHint(QPainter.Antialiasing) + + # Draw all the QR code results + for res in self.results: + painter.setWorldTransform(transform_flip * transform, False) + + # Draw lines between all of the QR code points + pen = QPen(self.qr_outline_pen) + if res in self.validator_results.result_colors: + pen.setColor(self.validator_results.result_colors[res]) + painter.setPen(pen) + num_points = len(res.points) + for i in range(0, num_points): + i_n = i + 1 + + line_from = toqp(res.points[i]) + line_from += self.crop.topLeft() + + line_to = toqp(res.points[i_n] if i_n < num_points else res.points[0]) + line_to += self.crop.topLeft() + + painter.drawLine(line_from, line_to) + + # Draw the QR code data + # Note that we reset the world transform to only the scaled transform + # because otherwise the text could be flipped. We only use transform_flip + # to map the center point of the result. + painter.setWorldTransform(transform, False) + font_metrics = painter.fontMetrics() + data_metrics = QSize(font_metrics.horizontalAdvance(res.data), font_metrics.capHeight()) + + center_pos = toqp(res.center) + center_pos += self.crop.topLeft() + center_pos = transform_flip.map(center_pos) + + text_offset = QPoint(data_metrics.width(), data_metrics.height()) + text_offset = text_offset / 2 + text_offset.setX(-text_offset.x()) + center_pos += text_offset + + padding = self.BG_RECT_PADDING + bg_rect_pos = center_pos - QPoint(padding, data_metrics.height() + padding) + bg_rect_size = data_metrics + (QSize(padding, padding) * 2) + bg_rect = QRect(bg_rect_pos, bg_rect_size) + bg_rect_path = QPainterPath() + radius = self.BG_RECT_CORNER_RADIUS + bg_rect_path.addRoundedRect(QRectF(bg_rect), radius, radius, Qt.AbsoluteSize) + painter.setPen(self.bg_rect_pen) + painter.fillPath(bg_rect_path, self.bg_rect_fill) + painter.drawPath(bg_rect_path) + + painter.setPen(self.text_pen) + painter.drawText(center_pos, res.data) diff --git a/electrum/gui/qt/qrreader/video_surface.py b/electrum/gui/qt/qrreader/video_surface.py new file mode 100644 index 000000000..ca2150038 --- /dev/null +++ b/electrum/gui/qt/qrreader/video_surface.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# +# Electron Cash - lightweight Bitcoin client +# Copyright (C) 2019 Axel Gembe +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from typing import List + +from PyQt5.QtMultimedia import (QVideoFrame, QAbstractVideoBuffer, QAbstractVideoSurface, + QVideoSurfaceFormat) +from PyQt5.QtGui import QImage +from PyQt5.QtCore import QObject, pyqtSignal + +from electrum.i18n import _ +from electrum.logging import get_logger + + +_logger = get_logger(__name__) + + +class QrReaderVideoSurface(QAbstractVideoSurface): + """ + Receives QVideoFrames from QCamera, converts them into a QImage, flips the X and Y axis if + necessary and sends them to listeners via the frame_available event. + """ + + def __init__(self, parent: QObject = None): + super().__init__(parent) + + def present(self, frame: QVideoFrame) -> bool: + if not frame.isValid(): + return False + + image_format = QVideoFrame.imageFormatFromPixelFormat(frame.pixelFormat()) + if image_format == QVideoFrame.Format_Invalid: + _logger.info(_('QR code scanner for video frame with invalid pixel format')) + return False + + if not frame.map(QAbstractVideoBuffer.ReadOnly): + _logger.info(_('QR code scanner failed to map video frame')) + return False + + try: + img = QImage(frame.bits(), frame.width(), frame.height(), image_format) + + # Check whether we need to flip the image on any axis + surface_format = self.surfaceFormat() + flip_x = surface_format.isMirrored() + flip_y = surface_format.scanLineDirection() == QVideoSurfaceFormat.BottomToTop + + # Mirror the image if needed + if flip_x or flip_y: + img = img.mirrored(flip_x, flip_y) + + # Create a copy of the image so the original frame data can be freed + img = img.copy() + finally: + frame.unmap() + + self.frame_available.emit(img) + + return True + + def supportedPixelFormats(self, handle_type: QAbstractVideoBuffer.HandleType) -> List[QVideoFrame.PixelFormat]: + if handle_type == QAbstractVideoBuffer.NoHandle: + # We support all pixel formats that can be understood by QImage directly + return [QVideoFrame.Format_ARGB32, QVideoFrame.Format_ARGB32_Premultiplied, + QVideoFrame.Format_RGB32, QVideoFrame.Format_RGB24, QVideoFrame.Format_RGB565, + QVideoFrame.Format_RGB555, QVideoFrame.Format_ARGB8565_Premultiplied] + return [] + + frame_available = pyqtSignal(QImage) diff --git a/electrum/gui/qt/qrreader/video_widget.py b/electrum/gui/qt/qrreader/video_widget.py new file mode 100644 index 000000000..b668f1601 --- /dev/null +++ b/electrum/gui/qt/qrreader/video_widget.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# +# Electron Cash - lightweight Bitcoin client +# Copyright (C) 2019 Axel Gembe +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from PyQt5.QtWidgets import QWidget +from PyQt5.QtGui import QPixmap, QPainter, QPaintEvent + + +class QrReaderVideoWidget(QWidget): + """ + Simple widget for drawing a pixmap + """ + + USE_BILINEAR_FILTER = True + + def __init__(self, parent: QWidget = None): + super().__init__(parent) + + self.pixmap = None + + def paintEvent(self, _event: QPaintEvent): + if not self.pixmap: + return + painter = QPainter(self) + if self.USE_BILINEAR_FILTER: + painter.setRenderHint(QPainter.SmoothPixmapTransform) + painter.drawPixmap(self.rect(), self.pixmap, self.pixmap.rect()) + + def setPixmap(self, pixmap: QPixmap): + self.pixmap = pixmap + self.update() diff --git a/electrum/gui/qt/qrtextedit.py b/electrum/gui/qt/qrtextedit.py index bb9ae4581..e7cbb50d5 100644 --- a/electrum/gui/qt/qrtextedit.py +++ b/electrum/gui/qt/qrtextedit.py @@ -72,24 +72,49 @@ class ScanQRTextEdit(ButtonsTextEdit, MessageBoxMixin, Logger): else: self.setText(data) - def qr_input(self): - from electrum import qrscanner - data = '' + # Due to the asynchronous nature of the qr reader we need to keep the + # dialog instance as member variable to prevent reentrancy/multiple ones + # from being presented at once. + qr_dialog = None + + def qr_input(self, *, callback=None) -> None: + if self.qr_dialog: + self.logger.warning("QR dialog is already presented, ignoring.") + return + from . import ElectrumGui + if ElectrumGui.warn_if_cant_import_qrreader(self): + return + from .qrreader import QrReaderCameraDialog, CameraError, MissingQrDetectionLib try: - data = qrscanner.scan_barcode(self.config.get_video_device()) - except UserFacingException as e: - self.show_error(e) - except BaseException as e: + self.qr_dialog = QrReaderCameraDialog(parent=self.top_level_window(), config=self.config) + + def _on_qr_reader_finished(success: bool, error: str, data): + if self.qr_dialog: + self.qr_dialog.deleteLater() + self.qr_dialog = None + if not success: + if error: + self.show_error(error) + return + if not data: + data = '' + if self.allow_multi: + new_text = self.text() + data + '\n' + else: + new_text = data + self.setText(new_text) + if callback and success: + callback(data) + + self.qr_dialog.qr_finished.connect(_on_qr_reader_finished) + self.qr_dialog.start_scan(self.config.get_video_device()) + except (MissingQrDetectionLib, CameraError) as e: + self.qr_dialog = None + self.show_error(str(e)) + except Exception as e: self.logger.exception('camera error') + self.qr_dialog = None self.show_error(repr(e)) - if not data: - data = '' - if self.allow_multi: - new_text = self.text() + data + '\n' - else: - new_text = data - self.setText(new_text) - return data def contextMenuEvent(self, e): m = self.createStandardContextMenu() diff --git a/electrum/gui/qt/settings_dialog.py b/electrum/gui/qt/settings_dialog.py index a0fbd74b1..12ca9d3ed 100644 --- a/electrum/gui/qt/settings_dialog.py +++ b/electrum/gui/qt/settings_dialog.py @@ -32,16 +32,15 @@ from PyQt5.QtWidgets import (QComboBox, QTabWidget, QVBoxLayout, QGridLayout, QLineEdit, QPushButton, QWidget, QHBoxLayout) -from electrum.i18n import _ +from electrum.i18n import _, languages from electrum import util, coinchooser, paymentrequest from electrum.util import base_units_list +from electrum.gui import messages + from .util import (ColorScheme, WindowModalDialog, HelpLabel, Buttons, CloseButton) -from electrum.i18n import languages -from electrum import qrscanner -from electrum.gui import messages if TYPE_CHECKING: from electrum.simple_config import SimpleConfig @@ -216,17 +215,25 @@ class SettingsDialog(WindowModalDialog): unit_combo.currentIndexChanged.connect(lambda x: on_unit(x, nz)) gui_widgets.append((unit_label, unit_combo)) - system_cameras = qrscanner._find_system_cameras() qr_combo = QComboBox() - qr_combo.addItem("Default","default") - for camera, device in system_cameras.items(): - qr_combo.addItem(camera, device) - #combo.addItem("Manually specify a device", config.get("video_device")) + qr_combo.addItem("Default", "default") + msg = (_("For scanning QR codes.") + "\n" + + _("Install the zbar package to enable this.")) + qr_label = HelpLabel(_('Video Device') + ':', msg) + system_cameras = [] + try: + from PyQt5.QtMultimedia import QCameraInfo + system_cameras = QCameraInfo.availableCameras() + except ImportError as e: + # Older Qt or missing libs -- disable GUI control and inform user why + qr_combo.setEnabled(False) + qr_label.setEnabled(False) + qr_combo.setToolTip(_("Unable to probe for cameras on this system. QtMultimedia is likely missing.")) + qr_label.setToolTip(qr_combo.toolTip()) + for cam in system_cameras: + qr_combo.addItem(cam.description(), cam.deviceName()) index = qr_combo.findData(self.config.get("video_device")) qr_combo.setCurrentIndex(index) - msg = _("Install the zbar package to enable this.") - qr_label = HelpLabel(_('Video Device') + ':', msg) - qr_combo.setEnabled(qrscanner.libzbar is not None) on_video_device = lambda x: self.config.set_key("video_device", qr_combo.itemData(x), True) qr_combo.currentIndexChanged.connect(on_video_device) gui_widgets.append((qr_label, qr_combo)) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index c6a766257..b15fe6ca4 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -12,18 +12,19 @@ from functools import partial, lru_cache from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, Union, List, Dict, Any, Sequence, Iterable) -from PyQt5.QtGui import (QFont, QColor, QCursor, QPixmap, QStandardItem, +from PyQt5.QtGui import (QFont, QColor, QCursor, QPixmap, QStandardItem, QImage, QPalette, QIcon, QFontMetrics, QShowEvent, QPainter, QHelpEvent) from PyQt5.QtCore import (Qt, QPersistentModelIndex, QModelIndex, pyqtSignal, QCoreApplication, QItemSelectionModel, QThread, QSortFilterProxyModel, QSize, QLocale, QAbstractItemModel, - QEvent) + QEvent, QRect, QPoint, QObject) from PyQt5.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, QAbstractItemView, QVBoxLayout, QLineEdit, QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton, QFileDialog, QWidget, QToolButton, QTreeView, QPlainTextEdit, QHeaderView, QApplication, QToolTip, QTreeWidget, QStyledItemDelegate, - QMenu, QStyleOptionViewItem) + QMenu, QStyleOptionViewItem, QLayout, QLayoutItem, + QGraphicsEffect, QGraphicsScene, QGraphicsPixmapItem) from electrum.i18n import _, languages from electrum.util import FileImportFailed, FileExportFailed, make_aiohttp_session, resource_path @@ -1131,6 +1132,129 @@ def webopen(url: str): webbrowser.open(url) +class FixedAspectRatioLayout(QLayout): + def __init__(self, parent: QWidget = None, aspect_ratio: float = 1.0): + super().__init__(parent) + self.aspect_ratio = aspect_ratio + self.items: List[QLayoutItem] = [] + + def set_aspect_ratio(self, aspect_ratio: float = 1.0): + self.aspect_ratio = aspect_ratio + self.update() + + def addItem(self, item: QLayoutItem): + self.items.append(item) + + def count(self) -> int: + return len(self.items) + + def itemAt(self, index: int) -> QLayoutItem: + if index >= len(self.items): + return None + return self.items[index] + + def takeAt(self, index: int) -> QLayoutItem: + if index >= len(self.items): + return None + return self.items.pop(index) + + def _get_contents_margins_size(self) -> QSize: + margins = self.contentsMargins() + return QSize(margins.left() + margins.right(), margins.top() + margins.bottom()) + + def setGeometry(self, rect: QRect): + super().setGeometry(rect) + if not self.items: + return + + contents = self.contentsRect() + if contents.height() > 0: + c_aratio = contents.width() / contents.height() + else: + c_aratio = 1 + s_aratio = self.aspect_ratio + item_rect = QRect(QPoint(0, 0), QSize( + contents.width() if c_aratio < s_aratio else contents.height() * s_aratio, + contents.height() if c_aratio > s_aratio else contents.width() / s_aratio + )) + + content_margins = self.contentsMargins() + free_space = contents.size() - item_rect.size() + + for item in self.items: + if free_space.width() > 0 and not item.alignment() & Qt.AlignLeft: + if item.alignment() & Qt.AlignRight: + item_rect.moveRight(contents.width() + content_margins.right()) + else: + item_rect.moveLeft(content_margins.left() + (free_space.width() / 2)) + else: + item_rect.moveLeft(content_margins.left()) + + if free_space.height() > 0 and not item.alignment() & Qt.AlignTop: + if item.alignment() & Qt.AlignBottom: + item_rect.moveBottom(contents.height() + content_margins.bottom()) + else: + item_rect.moveTop(content_margins.top() + (free_space.height() / 2)) + else: + item_rect.moveTop(content_margins.top()) + + item.widget().setGeometry(item_rect) + + def sizeHint(self) -> QSize: + result = QSize() + for item in self.items: + result = result.expandedTo(item.sizeHint()) + return self._get_contents_margins_size() + result + + def minimumSize(self) -> QSize: + result = QSize() + for item in self.items: + result = result.expandedTo(item.minimumSize()) + return self._get_contents_margins_size() + result + + def expandingDirections(self) -> Qt.Orientations: + return Qt.Horizontal | Qt.Vertical + + +def QColorLerp(a: QColor, b: QColor, t: float): + """ + Blends two QColors. t=0 returns a. t=1 returns b. t=0.5 returns evenly mixed. + """ + t = max(min(t, 1.0), 0.0) + i_t = 1.0 - t + return QColor( + (a.red() * i_t) + (b.red() * t), + (a.green() * i_t) + (b.green() * t), + (a.blue() * i_t) + (b.blue() * t), + (a.alpha() * i_t) + (b.alpha() * t), + ) + + +class ImageGraphicsEffect(QObject): + """ + Applies a QGraphicsEffect to a QImage + """ + + def __init__(self, parent: QObject, effect: QGraphicsEffect): + super().__init__(parent) + assert effect, 'effect must be set' + self.effect = effect + self.graphics_scene = QGraphicsScene() + self.graphics_item = QGraphicsPixmapItem() + self.graphics_item.setGraphicsEffect(effect) + self.graphics_scene.addItem(self.graphics_item) + + def apply(self, image: QImage): + assert image, 'image must be set' + result = QImage(image.size(), QImage.Format_ARGB32) + result.fill(Qt.transparent) + painter = QPainter(result) + self.graphics_item.setPixmap(QPixmap.fromImage(image)) + self.graphics_scene.render(painter) + self.graphics_item.setPixmap(QPixmap()) + return result + + if __name__ == "__main__": app = QApplication([]) t = WaitingDialog(None, 'testing ...', lambda: [time.sleep(1)], lambda x: QMessageBox.information(None, 'done', "done")) diff --git a/electrum/qrreader/__init__.py b/electrum/qrreader/__init__.py new file mode 100644 index 000000000..07c919690 --- /dev/null +++ b/electrum/qrreader/__init__.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# +# Electron Cash - lightweight Bitcoin client +# Copyright (C) 2019 Axel Gembe +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from typing import Optional + +from ..logging import get_logger + +from .abstract_base import AbstractQrCodeReader, QrCodeResult + + +_logger = get_logger(__name__) + + +class MissingLib(RuntimeError): + ''' Raised by underlying implementation if missing libs ''' + pass + + +def get_qr_reader() -> Optional[AbstractQrCodeReader]: + """ + Get the Qr code reader for the current platform + """ + try: + from .zbar import ZbarQrCodeReader + return ZbarQrCodeReader() + """ + # DEBUG CODE BELOW + # If you want to test this code on a platform that doesn't yet work or have + # zbar, use the below... + class Fake(AbstractQrCodeReader): + def read_qr_code(self, buffer, buffer_size, dummy, width, height, frame_id = -1): + ''' fake noop to test ''' + return [] + return Fake() + """ + except MissingLib as e: + _logger.exception("") + + return None diff --git a/electrum/qrreader/abstract_base.py b/electrum/qrreader/abstract_base.py new file mode 100644 index 000000000..954bbac52 --- /dev/null +++ b/electrum/qrreader/abstract_base.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# +# Electron Cash - lightweight Bitcoin client +# Copyright (C) 2019 Axel Gembe +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import ctypes +from typing import List, Tuple +from abc import ABC, abstractmethod + +QrCodePoint = Tuple[int, int] +QrCodePointList = List[QrCodePoint] + + +class QrCodeResult(): + """ + A detected QR code. + """ + def __init__(self, data: str, center: QrCodePoint, points: QrCodePointList): + self.data: str = data + self.center: QrCodePoint = center + self.points: QrCodePointList = points + + def __str__(self) -> str: + return 'data: {} center: {} points: {}'.format(self.data, self.center, self.points) + + def __hash__(self): + return hash(self.data) + + def __eq__(self, other): + return self.data == other.data + + def __ne__(self, other): + return not self == other + +class AbstractQrCodeReader(ABC): + """ + Abstract base class for QR code readers. + """ + + def interval(self) -> float: + ''' Reimplement to specify a time (in seconds) that the implementation + recommends elapse between subsequent calls to read_qr_code. + Implementations that have very expensive and/or slow detection code + may want to rate-limit read_qr_code calls by overriding this function. + e.g.: to make detection happen every 200ms, you would return 0.2 here. + Defaults to 0.0''' + return 0.0 + + @abstractmethod + def read_qr_code(self, buffer: ctypes.c_void_p, + buffer_size: int, # overall image size in bytes + rowlen_bytes: int, # the scan line length in bytes. (many libs, such as OSX, expect this value to properly grok image data) + width: int, height: int, frame_id: int = -1) -> List[QrCodeResult]: + """ + Reads a QR code from an image buffer in Y800 / GREY format. + Returns a list of detected QR codes which includes their data and positions. + """ diff --git a/electrum/qrreader/zbar.py b/electrum/qrreader/zbar.py new file mode 100644 index 000000000..14df4e70f --- /dev/null +++ b/electrum/qrreader/zbar.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# +# Electron Cash - lightweight Bitcoin client +# Copyright (C) 2019 Axel Gembe +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sys +import ctypes +import os +from typing import List +from enum import IntEnum + +from ..logging import get_logger + +from . import MissingLib +from .abstract_base import AbstractQrCodeReader, QrCodeResult + + +_logger = get_logger(__name__) + + +if sys.platform == 'darwin': + LIBNAME = 'libzbar.0.dylib' +elif sys.platform in ('windows', 'win32'): + LIBNAME = 'libzbar-0.dll' +else: + LIBNAME = 'libzbar.so.0' + +try: + try: + LIBZBAR = ctypes.cdll.LoadLibrary(os.path.join(os.path.dirname(__file__), '..', LIBNAME)) + except OSError as e: + LIBZBAR = ctypes.cdll.LoadLibrary(LIBNAME) + + LIBZBAR.zbar_image_create.restype = ctypes.c_void_p + LIBZBAR.zbar_image_scanner_create.restype = ctypes.c_void_p + LIBZBAR.zbar_image_scanner_get_results.restype = ctypes.c_void_p + LIBZBAR.zbar_symbol_set_first_symbol.restype = ctypes.c_void_p + LIBZBAR.zbar_symbol_get_data.restype = ctypes.POINTER(ctypes.c_char_p) + LIBZBAR.zbar_image_scanner_set_config.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int] + LIBZBAR.zbar_image_set_sequence.argtypes = [ctypes.c_void_p, ctypes.c_int] + LIBZBAR.zbar_image_set_size.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int] + LIBZBAR.zbar_image_set_format.argtypes = [ctypes.c_void_p, ctypes.c_int] + LIBZBAR.zbar_image_set_data.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p] + LIBZBAR.zbar_image_scanner_recycle_image.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + LIBZBAR.zbar_scan_image.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + LIBZBAR.zbar_image_scanner_get_results.argtypes = [ctypes.c_void_p] + LIBZBAR.zbar_symbol_set_first_symbol.argtypes = [ctypes.c_void_p] + LIBZBAR.zbar_symbol_get_data_length.argtypes = [ctypes.c_void_p] + LIBZBAR.zbar_symbol_get_data.argtypes = [ctypes.c_void_p] + LIBZBAR.zbar_symbol_get_loc_size.argtypes = [ctypes.c_void_p] + LIBZBAR.zbar_symbol_get_loc_x.argtypes = [ctypes.c_void_p, ctypes.c_int] + LIBZBAR.zbar_symbol_get_loc_y.argtypes = [ctypes.c_void_p, ctypes.c_int] + LIBZBAR.zbar_symbol_next.argtypes = [ctypes.c_void_p] + LIBZBAR.zbar_image_scanner_destroy.argtypes = [ctypes.c_void_p] + LIBZBAR.zbar_image_destroy.argtypes = [ctypes.c_void_p] + + #if is_verbose: + #LIBZBAR.zbar_set_verbosity(100) +except OSError: + _logger.exception("Failed to load zbar") + LIBZBAR = None + +FOURCC_Y800 = 0x30303859 + +@ctypes.CFUNCTYPE(None, ctypes.c_void_p) +def zbar_cleanup(image): + """ + Do nothing, this is just so zbar doesn't try to manage our QImage buffers + """ + +class ZbarSymbolType(IntEnum): + """ + Supported symbol types, see zbar_symbol_type_e in zbar.h + """ + EAN2 = 2 + EAN5 = 5 + EAN8 = 8 + UPCE = 9 + ISBN10 = 10 + UPCA = 12 + EAN13 = 13 + ISBN13 = 14 + COMPOSITE = 15 + I25 = 25 + DATABAR = 34 + DATABAR_EXP = 35 + CODABAR = 38 + CODE39 = 39 + PDF417 = 57 + QRCODE = 64 + SQCODE = 80 + CODE93 = 93 + CODE128 = 128 + +class ZbarConfig(IntEnum): + """ + Supported configuration options, see zbar_config_e in zbar.h + """ + ENABLE = 0 + +class ZbarQrCodeReader(AbstractQrCodeReader): + """ + Reader that uses libzbar + """ + + def __init__(self): + if not LIBZBAR: + raise MissingLib('Zbar library not found') + # Set up zbar + self.zbar_scanner = LIBZBAR.zbar_image_scanner_create() + self.zbar_image = LIBZBAR.zbar_image_create() + + # Disable all symbols + for sym_type in ZbarSymbolType: + LIBZBAR.zbar_image_scanner_set_config(self.zbar_scanner, sym_type, ZbarConfig.ENABLE, 0) + + # Enable only QR codes + LIBZBAR.zbar_image_scanner_set_config(self.zbar_scanner, ZbarSymbolType.QRCODE, + ZbarConfig.ENABLE, 1) + + def __del__(self): + if LIBZBAR: + LIBZBAR.zbar_image_scanner_destroy(self.zbar_scanner) + LIBZBAR.zbar_image_destroy(self.zbar_image) + + def read_qr_code(self, buffer: ctypes.c_void_p, buffer_size: int, + rowlen_bytes: int, # this param is ignored in this implementation + width: int, height: int, frame_id: int = -1) -> List[QrCodeResult]: + LIBZBAR.zbar_image_set_sequence(self.zbar_image, frame_id) + LIBZBAR.zbar_image_set_size(self.zbar_image, width, height) + LIBZBAR.zbar_image_set_format(self.zbar_image, FOURCC_Y800) + LIBZBAR.zbar_image_set_data(self.zbar_image, buffer, buffer_size, zbar_cleanup) + LIBZBAR.zbar_image_scanner_recycle_image(self.zbar_scanner, self.zbar_image) + LIBZBAR.zbar_scan_image(self.zbar_scanner, self.zbar_image) + + result_set = LIBZBAR.zbar_image_scanner_get_results(self.zbar_scanner) + + res = [] + symbol = LIBZBAR.zbar_symbol_set_first_symbol(result_set) + while symbol: + symbol_data_len = LIBZBAR.zbar_symbol_get_data_length(symbol) + symbol_data_ptr = LIBZBAR.zbar_symbol_get_data(symbol) + symbol_data_bytes = ctypes.string_at(symbol_data_ptr, symbol_data_len) + symbol_data = symbol_data_bytes.decode('utf-8') + + symbol_loc = [] + symbol_loc_len = LIBZBAR.zbar_symbol_get_loc_size(symbol) + for i in range(0, symbol_loc_len): + # Normalize the coordinates into 0..1 range by dividing by width / height + symbol_loc_x = LIBZBAR.zbar_symbol_get_loc_x(symbol, i) + symbol_loc_y = LIBZBAR.zbar_symbol_get_loc_y(symbol, i) + symbol_loc.append((symbol_loc_x, symbol_loc_y)) + + # Find the center by getting the average values of the corners x and y coordinates + symbol_loc_sum_x = sum([l[0] for l in symbol_loc]) + symbol_loc_sum_y = sum([l[1] for l in symbol_loc]) + symbol_loc_center = (int(symbol_loc_sum_x / symbol_loc_len), int(symbol_loc_sum_y / symbol_loc_len)) + + res.append(QrCodeResult(symbol_data, symbol_loc_center, symbol_loc)) + + symbol = LIBZBAR.zbar_symbol_next(symbol) + + return res diff --git a/electrum/qrscanner.py b/electrum/qrscanner.py index 081800078..ccfeba997 100644 --- a/electrum/qrscanner.py +++ b/electrum/qrscanner.py @@ -50,11 +50,10 @@ except BaseException as e1: libzbar = ctypes.cdll.LoadLibrary(name) except BaseException as e2: libzbar = None - if sys.platform != 'darwin': - _logger.error(f"failed to load zbar. exceptions: {[e1,e2]!r}") + _logger.error(f"failed to load zbar. exceptions: {[e1,e2]!r}") -def scan_barcode_ctypes(device='', timeout=-1, display=True, threaded=False) -> Optional[str]: +def scan_barcode(device='', timeout=-1, display=True, threaded=False) -> Optional[str]: if libzbar is None: raise UserFacingException("Cannot start QR scanner: zbar not available.") libzbar.zbar_symbol_get_data.restype = ctypes.c_char_p @@ -82,28 +81,6 @@ def scan_barcode_ctypes(device='', timeout=-1, display=True, threaded=False) -> data = libzbar.zbar_symbol_get_data(symbol) return data.decode('utf8') -def scan_barcode_osx(*args_ignored, **kwargs_ignored): - import subprocess - # NOTE: This code needs to be modified if the positions of this file changes with respect to the helper app! - # This assumes the built macOS .app bundle which ends up putting the helper app in - # .app/contrib/osx/CalinsQRReader/build/Release/CalinsQRReader.app. - root_ec_dir = os.path.abspath(os.path.dirname(__file__) + "/../") - prog = root_ec_dir + "/" + "contrib/osx/CalinsQRReader/build/Release/CalinsQRReader.app/Contents/MacOS/CalinsQRReader" - if not os.path.exists(prog): - raise UserFacingException("Cannot start QR scanner: helper app not found.") - data = '' - try: - # This will run the "CalinsQRReader" helper app (which also gets bundled with the built .app) - # Just like the zbar implementation -- the main app will hang until the QR window returns a QR code - # (or is closed). Communication with the subprocess is done via stdout. - # See contrib/CalinsQRReader for the helper app source code. - with subprocess.Popen([prog], stdout=subprocess.PIPE) as p: - data = p.stdout.read().decode('utf-8').strip() - return data - except OSError as e: - raise UserFacingException("Cannot start camera helper app: {}".format(e.strerror)) - -scan_barcode = scan_barcode_osx if sys.platform == 'darwin' else scan_barcode_ctypes def _find_system_cameras(): device_root = "/sys/class/video4linux" diff --git a/setup.py b/setup.py index efcd5dd0f..1ad3103c6 100755 --- a/setup.py +++ b/setup.py @@ -72,8 +72,10 @@ setup( extras_require=extras_require, packages=[ 'electrum', + 'electrum.qrreader', 'electrum.gui', 'electrum.gui.qt', + 'electrum.gui.qt.qrreader', 'electrum.plugins', ] + [('electrum.plugins.'+pkg) for pkg in find_packages('electrum/plugins')], package_dir={