Merge pull request #10955 from f321x/qml-qtmultimedia-scanner
What changed, and why it matters
This commit replaces the QR-code scanning backend of the Electrum wallet. It removes the old zbar library and the custom Android BarcodeScannerView/CameraView components, and switches everything to Qt Multimedia for camera access and zxing-cpp for decoding QR codes. The change touches build scripts, packaging, QML/Qt GUI code, and the low-level decoder wrapper. It is a large refactoring with security-relevant side effects: it drops a dependency that had known CVEs, changes how camera frames are handled, and adds new input-validation and memory-management code around the decoder. There is no direct evidence in the commit that this fixes an exploitable vulnerability in Electrum itself, but the change is clearly security-motivated and hardens the QR pipeline.
Treat this as a security-hardening refactor rather than a discrete CVE fix. Reviewers should focus on the new zxing.py ctypes wrapper (buffer validation, memory freeing, version pinning), the Qt Multimedia permission and secureWindow handling on Android, and build reproducibility of the new zxing-cpp binaries. Users building from source should ensure Qt Multimedia and zxing-cpp are available. No immediate emergency response is indicated, but the change should be included in the next release notes as a security improvement.
Security signals we found
Dependency swap from zbar to zxing-cpp, explicitly dropping a library with a known CVE (CVE-2023-40889) mentioned in build comments
New ctypes wrapper adds input validation for buffer dimensions, stride, crop rectangles, and checks zxing-cpp major version compatibility
QR payload now returned as raw bytes; callers explicitly decode/validate UTF-8 instead of assuming text
Android QR scanning moved from a separate Java Activity/AAR to in-process Qt Multimedia, reducing IPC and native AAR attack surface
secureWindow flag added to ScanDialog/SendDialog to prevent screenshots/screen recordings during camera preview
Removal of legacy zbar camera path and associated libiconv Windows build/patch
Large build-system changes with new pinned hashes and CMake-based builds
Evidence from the diff
The patch is a cross-platform migration of Electrum’s QR scanner: zbar is replaced by zxing-cpp, and camera capture is unified on Qt Multimedia (previously zbar handled the camera on Linux). Build recipes for Android, AppImage, Windows, and macOS are updated to compile/bundle zxing-cpp instead of zbar/libiconv. The Python QR reader API now returns raw bytes instead of decoded strings, and callers are updated to validate UTF-8. A new ctypes wrapper (electrum/qrreader/zxing.py) loads libZXing and pins the zxing-cpp major version; it validates buffer geometry and crop rectangles before passing them to the C API and frees allocated data with ZXing_free. On Android, the old Java SimpleScannerActivity and BarcodeScannerView AAR integration are removed; scanning now uses the same QML/Qt Multimedia path as desktop, with explicit CAMERA permission handling and a secureWindow flag to block screenshots during scanning. The commit also removes a libiconv patch and a workaround for an old zbar CVE (CVE-2023-40889) because zbar is no longer used.
Changed components
electrum/qrreader/zxing.py (new zxing-cpp ctypes wrapper)electrum/qrreader/__init__.py (reader selection and version info)electrum/qrreader/abstract_base.py (API now returns bytes, adds crop parameter)electrum/gui/qt/qrreader/__init__.py and qtmultimedia/* (desktop camera/decoder integration)electrum/gui/qml/qeqr.py (QML QR parser, frame decoding, thread pool)electrum/gui/qml/components/QRScan.qml, ScanDialog.qml, SendDialog.qml, WalletMainView.qml, main.qml (QML UI)electrum/gui/qml/qeapp.py (permission helpers)electrum/gui/qml/java_classes/org/electrum/qr/SimpleScannerActivity.java (deleted)contrib/make_zxing.sh, contrib/make_zbar.sh (deleted), contrib/android/p4a_recipes/libzxing/__init__.py (new), build scripts for AppImage/Wine/macOS/AndroidInspect captured patch +666 / −1388
### .github/workflows/builds.yml
@@ -46,11 +46,11 @@ jobs:
path: contrib/build-wine/.cache/win*/wine_pip_cache
key: wine-pip-${{ hashFiles('contrib/deterministic-build/*.txt', 'contrib/build-wine/**') }}
- - name: Cache Wine native lib builds (libsecp256k1, libusb, libzbar)
+ - name: Cache Wine native lib builds (libsecp256k1, libusb, zxing-cpp)
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: contrib/build-wine/.cache/win*/build
- key: wine-build-${{ hashFiles('contrib/make_libsecp256k1.sh', 'contrib/make_libusb.sh', 'contrib/make_zbar.sh', 'contrib/build-wine/**') }}
+ key: wine-build-${{ hashFiles('contrib/make_libsecp256k1.sh', 'contrib/make_libusb.sh', 'contrib/make_zxing.sh', 'contrib/build-wine/**') }}
- name: Build Windows binaries
run: ./contrib/build-wine/build.sh
@@ -147,7 +147,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: contrib/build-linux/appimage/.cache/appimage
- key: appimage-build-${{ hashFiles('contrib/make_libsecp256k1.sh', 'contrib/make_zbar.sh', 'contrib/build-linux/appimage/**') }}
+ key: appimage-build-${{ hashFiles('contrib/make_libsecp256k1.sh', 'contrib/make_zxing.sh', 'contrib/build-linux/appimage/**') }}
- name: Build AppImage
run: ./contrib/build-linux/appimage/build.sh
### .gitignore
@@ -41,7 +41,6 @@ contrib/osx/build-venv/
contrib/android/android_debug.keystore
contrib/android/.cache/
contrib/secp256k1/
-contrib/zbar/
contrib/libusb/
contrib/.venv_make_packages/
### README.md
@@ -35,6 +35,20 @@ If you want to use the Qt interface, install the Qt dependencies:
$ sudo apt-get install python3-pyqt6
```
+#### QR code scanning
+
+QR code decoding uses [zxing-cpp](https://github.com/zxing-cpp/zxing-cpp/).
+To build the library, install CMake and a C++20 compiler and run:
+```
+$ ./contrib/make_zxing.sh
+```
+
+Camera scanning also requires Qt Multimedia. It is included in the PyQt6 pip
+package; when using Debian/Ubuntu packages, install it separately:
+```
+$ sudo apt-get install python3-pyqt6.qtmultimedia
+```
+
#### libsecp256k1
For elliptic curve operations,
### contrib/android/Dockerfile
@@ -80,8 +80,6 @@ RUN mkdir --parents "${ANDROID_SDK_HOME}/.android/" \
# download Java-17 (debian 13 only packages Java-21 and Java-25)
# - we download the amd64 binaries from debian 12 repos
# - we should try to upgrade to Java-21...
-# - the main blocker seems to be having to update Gradle (to a version compatible with Java-21)
-# - make_barcode_scanner.sh: markusfisch/{zxing-cpp, ...} pins old Gradle
ENV JAVA_JRE_DL_URL="https://snapshot.debian.org/archive/debian/20260130T143028Z/pool/main/o/openjdk-17/openjdk-17-jre-headless_17.0.18+8-1~deb12u1_amd64.deb"
ENV JAVA_JRE_ARCHIVE="openjdk-17-jre-headless.deb"
ENV JAVA_JRE_HASH="5bc36cbb4e383dbea4168d57b5fd9b42375ec8837dd62a1d56677632c3c960e0"
@@ -235,8 +233,8 @@ RUN cd /opt \
&& /opt/venv/bin/python3 -m pip install --no-build-isolation --no-dependencies -e .
# install python-for-android
-ENV P4A_CHECKOUT_COMMIT="315bb8adf8022972f0ee87a45896d05c38134e1c"
-# ^ from branch electrum_202602 (note: careful with force-pushing! see #8162)
+ENV P4A_CHECKOUT_COMMIT="df3c7b003d17dc8702b40044e0429db9b264eab5"
+# ^ from branch electrum_202609_qtmultimedia (note: careful with force-pushing! see #8162)
RUN cd /opt \
&& git clone https://github.com/spesmilo/python-for-android \
&& cd python-for-android \
### contrib/android/buildozer_qml.spec
@@ -55,6 +55,8 @@ source.exclude_dirs =
# (list) List of exclusions using pattern matching
source.exclude_patterns = Makefile,setup*,
+ # the android zxing scanner is built through the p4a recipe
+ electrum/libZXing.*,
# not reproducible:
packages/aiohttp-*.dist-info/*,
packages/frozenlist-*.dist-info/*
@@ -79,7 +81,7 @@ requirements =
pycryptodomex,
pyqt6sip,
pyqt6,
- libzbar
+ libzxing
# (str) Presplash of the application
presplash.filename = %(source.dir)s/electrum/gui/icons/electrum_presplash.png
@@ -151,27 +153,18 @@ android.accept_sdk_license = True
# down the build process. Allows wildcards matching, for example:
# OUYA-ODK/libs/*.jar
#android.add_jars = foo.jar,bar.jar,path/to/more/*.jar
-#android.add_jars = lib/android/zbar.jar
android.add_jars = .buildozer/android/platform/*/build/libs_collections/Electrum/jar/*.jar
-android.add_aars =
- contrib/android/.cache/aars/BarcodeScannerView.aar,
- contrib/android/.cache/aars/CameraView.aar,
- contrib/android/.cache/aars/zxing-cpp.aar
-
-
# (list) List of Java files to add to the android project (can be java or a
# directory containing the files)
android.add_src = electrum/gui/qml/java_classes/
-# kotlin-stdlib is required for zxing-cpp (BarcodeScannerView)
android.gradle_dependencies =
- androidx.core:core:1.16.0,
- org.jetbrains.kotlin:kotlin-stdlib:1.8.22
+ androidx.core:core:1.16.0
-android.add_activities = org.electrum.qr.SimpleScannerActivity, org.electrum.biometry.BiometricActivity
+android.add_activities = org.electrum.biometry.BiometricActivity
# (list) Put these files or directories in the apk res directory.
# The option may be used in three ways, the value may contain one or zero ':'
@@ -183,7 +176,6 @@ android.add_activities = org.electrum.qr.SimpleScannerActivity, org.electrum.bio
# 3) A directory, here 'legal_resources' must contain one or more directories,
# each of a resource kind: drawable, xml, etc...
# android.add_resources = legal_resources
-android.add_resources = electrum/gui/qml/android_res/layout:layout
# (str) python-for-android branch to use, if not master, useful to try
# not yet merged features.
### contrib/android/make_apk.sh
@@ -95,22 +95,18 @@ if [[ "$2" == "all" ]] ; then
# FIXME failures are not propagated out: we should fail the script if any arch build fails
export APP_ANDROID_ARCHS=armeabi-v7a
export APP_ANDROID_NUMERIC_VERSION=$("$CONTRIB_ANDROID"/get_apk_versioncode.py "$APP_ANDROID_ARCHS")
- "$CONTRIB_ANDROID"/make_barcode_scanner.sh "$APP_ANDROID_ARCHS" || fail "make_barcode_scanner.sh failed"
make $TARGET
export APP_ANDROID_ARCHS=arm64-v8a
export APP_ANDROID_NUMERIC_VERSION=$("$CONTRIB_ANDROID"/get_apk_versioncode.py "$APP_ANDROID_ARCHS")
- "$CONTRIB_ANDROID"/make_barcode_scanner.sh "$APP_ANDROID_ARCHS" || fail "make_barcode_scanner.sh failed"
make $TARGET
export APP_ANDROID_ARCHS=x86_64
export APP_ANDROID_NUMERIC_VERSION=$("$CONTRIB_ANDROID"/get_apk_versioncode.py "$APP_ANDROID_ARCHS")
- "$CONTRIB_ANDROID"/make_barcode_scanner.sh "$APP_ANDROID_ARCHS" || fail "make_barcode_scanner.sh failed"
make $TARGET
else
export APP_ANDROID_ARCHS=$2
export APP_ANDROID_NUMERIC_VERSION=$("$CONTRIB_ANDROID"/get_apk_versioncode.py "$APP_ANDROID_ARCHS")
- "$CONTRIB_ANDROID"/make_barcode_scanner.sh "$APP_ANDROID_ARCHS" || fail "make_barcode_scanner.sh failed"
make $TARGET
fi
### contrib/android/make_barcode_scanner.sh
@@ -1,144 +0,0 @@
-#!/bin/bash
-
-# script to clone and build https://github.com/markusfisch/BarcodeScannerView and its dependencies,
-# https://github.com/markusfisch/CameraView/ and https://github.com/markusfisch/zxing-cpp
-# which are being used as barcode scanner in the Android app.
-
-# To bump the version of BarcodeScannerView, get the newest version tag from the github repo,
-# then get the required dependencies from
-# https://github.com/markusfisch/BarcodeScannerView/blob/**VERSION_TAG**/barcodescannerview/build.gradle
-# then update the commit hashes below. Also update kotlin-stdlib in buildozer_qml.spec to the
-# "kotlin-version" specified in the used zxing-cpp commit:
-# https://github.com/markusfisch/zxing-cpp/blob/master/wrappers/aar/build.gradle
-
-
-BARCODE_SCANNER_VIEW_COMMIT_HASH="0bdb69269c252bb6daef2f871b76403c8b051945" # 1.6.5
-BARCODE_SCANNER_VIEW_REPO="https://github.com/markusfisch/BarcodeScannerView.git"
-
-CAMERA_VIEW_COMMIT_HASH="745597d05bc6abfdb3637a09a8ecaf30fdce7b6e" # 1.10.0
-CAMERA_VIEW_REPO="https://github.com/markusfisch/CameraView.git"
-
-ZXING_CPP_COMMIT_HASH="79f5adc6250e90de0bd635eb9181c5f8a18affda" # v2.3.0.4 using kotlin-stdlib 1.8.22
-ZXING_CPP_REPO="https://github.com/markusfisch/zxing-cpp.git"
-
-
-########################################################################################################
-set -e
-
-CONTRIB_ANDROID="$(dirname "$(readlink -e "$0")")"
-CONTRIB="$CONTRIB_ANDROID"/..
-CACHEDIR="$CONTRIB_ANDROID/.cache"
-BUILDDIR="$CACHEDIR/builds"
-
-. "$CONTRIB"/build_tools_util.sh
-
-# target architecture passed as argument by`make_apk.sh`
-TARGET_ARCH="$1"
-
-# check if TARGET_ARCH is set and supported
-if [[ "$TARGET_ARCH" != "armeabi-v7a" \
- && "$TARGET_ARCH" != "arm64-v8a" \
- && "$TARGET_ARCH" != "x86_64" ]]; then
- fail "make_barcode_scanner.sh invalid target architecture argument: $TARGET_ARCH"
-fi
-
-info "Building BarcodeScannerView and deps for architecture: $TARGET_ARCH"
-
-# check if directories exist, create them if not
-if [ ! -d "$CACHEDIR/aars" ]; then
- mkdir -p "$CACHEDIR/aars"
-fi
-
-if [ ! -d "$BUILDDIR" ]; then
- mkdir -p "$BUILDDIR"
-fi
-
-
-####### zxing-cpp ########
-
-# check if zxing-cpp aar is already in cachedir, else build it
-ZXING_CPP_BUILD_ID="$TARGET_ARCH-$ZXING_CPP_COMMIT_HASH"
-if [ -f "$CACHEDIR/aars/zxing-cpp-$ZXING_CPP_BUILD_ID.aar" ]; then
- info "zxing-cpp for $ZXING_CPP_BUILD_ID already exists in cache, skipping build."
- cp "$CACHEDIR/aars/zxing-cpp-$ZXING_CPP_BUILD_ID.aar" "$CACHEDIR/aars/zxing-cpp.aar"
-else
- info "Building zxing-cpp for $ZXING_CPP_BUILD_ID..."
- ZXING_CPP_DIR="$BUILDDIR/zxing-cpp"
- clone_or_update_repo "$ZXING_CPP_REPO" "$ZXING_CPP_COMMIT_HASH" "$ZXING_CPP_DIR"
- cd "$ZXING_CPP_DIR/wrappers/aar"
- chmod +x gradlew
-
- # Set local.properties to use SDK of docker container
- echo "sdk.dir=${ANDROID_SDK_HOME}" > local.properties
- # gradlew will install a specific NDK version required by zxing-cpp
- ./gradlew :zxingcpp:assembleRelease -Pandroid.injected.build.abi="$TARGET_ARCH"
-
- # Copy the built AAR to cache directory
- ZXING_AAR_SOURCE="$ZXING_CPP_DIR/wrappers/aar/zxingcpp/build/outputs/aar/zxingcpp-release.aar"
- ZXING_AAR_DEST_GENERIC="$CACHEDIR/aars/zxing-cpp.aar"
- ZXING_AAR_DEST_SPECIFIC="$CACHEDIR/aars/zxing-cpp-$ZXING_CPP_BUILD_ID.aar"
- if [ ! -f "$ZXING_AAR_SOURCE" ]; then
- fail "zxing-cpp AAR not found at $ZXING_AAR_SOURCE, build failed?"
- fi
- cp "$ZXING_AAR_SOURCE" "$ZXING_AAR_DEST_GENERIC"
- # keeping an arch specific copy allows to skip the build later if it already exists
- cp "$ZXING_AAR_SOURCE" "$ZXING_AAR_DEST_SPECIFIC"
- info "zxing-cpp AAR copied to $ZXING_AAR_DEST_GENERIC"
-fi
-
-########### CameraView ###########
-
-CAMERA_VIEW_BUILD_ID="$CAMERA_VIEW_COMMIT_HASH"
-if [ -f "$CACHEDIR/aars/CameraView-$CAMERA_VIEW_BUILD_ID.aar" ]; then
- info "CameraView AAR already exists in cache, skipping build."
- cp "$CACHEDIR/aars/CameraView-$CAMERA_VIEW_BUILD_ID.aar" "$CACHEDIR/aars/CameraView.aar"
-else
- info "Building CameraView..."
- CAMERA_VIEW_DIR="$BUILDDIR/CameraView"
- clone_or_update_repo "$CAMERA_VIEW_REPO" "$CAMERA_VIEW_COMMIT_HASH" "$CAMERA_VIEW_DIR"
- cd "$CAMERA_VIEW_DIR"
- chmod +x gradlew
-
- echo "sdk.dir=${ANDROID_SDK_HOME}" > local.properties
- ./gradlew :cameraview:assembleRelease
-
- CAMERA_AAR_SOURCE="$CAMERA_VIEW_DIR/cameraview/build/outputs/aar/cameraview-release.aar"
- CAMERA_AAR_DEST_GENERIC="$CACHEDIR/aars/CameraView.aar"
- CAMERA_AAR_DEST_SPECIFIC="$CACHEDIR/aars/CameraView-$CAMERA_VIEW_BUILD_ID.aar"
- if [ ! -f "$CAMERA_AAR_SOURCE" ]; then
- fail "CameraView AAR not found at $CAMERA_AAR_SOURCE"
- fi
- cp "$CAMERA_AAR_SOURCE" "$CAMERA_AAR_DEST_GENERIC"
- cp "$CAMERA_AAR_SOURCE" "$CAMERA_AAR_DEST_SPECIFIC"
- info "CameraView AAR copied to $CAMERA_AAR_DEST_GENERIC"
-fi
-
-########### BarcodeScannerView ###########
-
-BARCODE_SCANNER_VIEW_BUILD_ID="$BARCODE_SCANNER_VIEW_COMMIT_HASH"
-if [ -f "$CACHEDIR/aars/BarcodeScannerView-$BARCODE_SCANNER_VIEW_BUILD_ID.aar" ]; then
- info "BarcodeScannerView AAR already exists in cache, skipping build."
- cp "$CACHEDIR/aars/BarcodeScannerView-$BARCODE_SCANNER_VIEW_BUILD_ID.aar" "$CACHEDIR/aars/BarcodeScannerView.aar"
-else
- info "Building BarcodeScannerView..."
- BARCODE_SCANNER_VIEW_DIR="$BUILDDIR/BarcodeScannerView"
- clone_or_update_repo "$BARCODE_SCANNER_VIEW_REPO" "$BARCODE_SCANNER_VIEW_COMMIT_HASH" "$BARCODE_SCANNER_VIEW_DIR"
- cd "$BARCODE_SCANNER_VIEW_DIR"
- chmod +x gradlew
-
- echo "sdk.dir=${ANDROID_SDK_HOME}" > local.properties
- ./gradlew :barcodescannerview:assembleRelease
-
- BARCODE_AAR_SOURCE="$BARCODE_SCANNER_VIEW_DIR/barcodescannerview/build/outputs/aar/barcodescannerview-release.aar"
- BARCODE_AAR_DEST_GENERIC="$CACHEDIR/aars/BarcodeScannerView.aar"
- BARCODE_AAR_DEST_SPECIFIC="$CACHEDIR/aars/BarcodeScannerView-$BARCODE_SCANNER_VIEW_BUILD_ID.aar"
- if [ ! -f "$BARCODE_AAR_SOURCE" ]; then
- fail "BarcodeScannerView AAR not found at $BARCODE_AAR_SOURCE"
- fi
- cp "$BARCODE_AAR_SOURCE" "$BARCODE_AAR_DEST_GENERIC"
- cp "$BARCODE_AAR_SOURCE" "$BARCODE_AAR_DEST_SPECIFIC"
- info "BarcodeScannerView AAR copied to $BARCODE_AAR_DEST_GENERIC"
-fi
-
-
-info "All barcode scanner libraries built successfully for $TARGET_ARCH"
### contrib/android/p4a_recipes/ffmpeg_qt/__init__.py
@@ -0,0 +1,17 @@
+import os
+
+from pythonforandroid.recipes.ffmpeg_qt import FFmpegQtRecipe
+from pythonforandroid.util import load_source
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+assert FFmpegQtRecipe._version == "7.1.5"
+assert FFmpegQtRecipe.depends == []
+assert FFmpegQtRecipe.python_depends == []
+
+
+class FFmpegQtRecipePinned(util.InheritedRecipeMixin, FFmpegQtRecipe):
+ sha512sum = "c0649ef9edd4176b90459e92b636be18077ca898f24fe1a8dc60f3c5fd58cf718db9720bbe804c6f6894453e21c0625d706ea37d3523fcb40d61779763d7d965"
+
+
+recipe = FFmpegQtRecipePinned()
### contrib/android/p4a_recipes/libiconv/__init__.py
@@ -1,19 +0,0 @@
-import os
-
-from pythonforandroid.recipes.libiconv import LibIconvRecipe
-from pythonforandroid.util import load_source
-
-util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
-
-
-assert LibIconvRecipe._version == "1.16"
-assert LibIconvRecipe.depends == []
-assert LibIconvRecipe.python_depends == []
-
-
-class LibIconvRecipePinned(util.InheritedRecipeMixin, LibIconvRecipe):
- version = "1.18"
- sha512sum = "a55eb3b7b785a78ab8918db8af541c9e11deb5ff4f89d54483287711ed797d87848ce0eafffa7ce26d9a7adb4b5a9891cb484f94bd4f51d3ce97a6a47b4c719a"
-
-
-recipe = LibIconvRecipePinned()
### contrib/android/p4a_recipes/libzbar/__init__.py
@@ -1,20 +0,0 @@
-import os
-
-from pythonforandroid.recipes.libzbar import LibZBarRecipe
-from pythonforandroid.util import load_source
-
-util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
-
-
-assert LibZBarRecipe.depends == ['libiconv']
-assert LibZBarRecipe.python_depends == []
-
-
-class LibZBarRecipePinned(util.InheritedRecipeMixin, LibZBarRecipe):
- version = "bb05ec54eec57f8397cb13fb9161372a281a1219"
- url = "https://github.com/mchehab/zbar/archive/{version}.zip"
- sha512sum = "186312ef0a50404efef79a5fbed34534569fab2873a6bb6d2e3d8ea64fa461c5537ca4fb0e659670d72b021e514f8fd4651b1e85954bf987015d8eb2e6f68375"
- patches = [] # werror.patch not needed for modern zbar
-
-
-recipe = LibZBarRecipePinned()
### contrib/android/p4a_recipes/libzxing/__init__.py
@@ -0,0 +1,48 @@
+import os
+from multiprocessing import cpu_count
+
+import sh
+
+from pythonforandroid.logger import shprint
+from pythonforandroid.recipe import Recipe
+
+
+class LibZXingRecipe(Recipe):
+ # zxing-cpp v3.1.1. Keep in sync with contrib/make_zxing.sh.
+ # A major version bump also needs ZXING_MAJOR_VERSION in electrum/qrreader/zxing.py.
+ version = "287c85df6f961c8efbfb5ffd736cd9457b8b890e"
+ url = "https://github.com/zxing-cpp/zxing-cpp/archive/{version}.tar.gz"
+ sha512sum = "6bac11726d616461ee0caeade81f824c01ac09ab284fe13c852fda7b0761a3e84887fa9ec042c793aebbfc2138119dd5be908cc9b14b349066d7ae303760127b"
+ depends = []
+ built_libraries = {'libZXing.so': 'build'}
+ need_stl_shared = True
+
+ def build_arch(self, arch):
+ env = self.get_recipe_env(arch)
+ source_dir = self.get_build_dir(arch.arch)
+ build_dir = os.path.join(source_dir, 'build')
+ # Build core directly, without the upstream C API test's dependencies.
+ shprint(
+ sh.cmake, '-S', os.path.join(source_dir, 'core'), '-B', build_dir,
+ '-DCMAKE_TOOLCHAIN_FILE=' + os.path.join(self.ctx.ndk_dir, 'build', 'cmake', 'android.toolchain.cmake'),
+ '-DANDROID_ABI=' + arch.arch,
+ '-DANDROID_PLATFORM=android-' + str(self.ctx.ndk_api),
+ '-DANDROID_STL=' + self.stl_lib_name,
+ '-DCMAKE_BUILD_TYPE=Release',
+ '-DCMAKE_SKIP_RPATH=ON',
+ '-DBUILD_SHARED_LIBS=ON',
+ '-DZXING_C_API=ON',
+ '-DZXING_READERS=ON',
+ '-DZXING_WRITERS=OFF',
+ '-DZXING_ENABLE_1D=OFF',
+ '-DZXING_ENABLE_AZTEC=OFF',
+ '-DZXING_ENABLE_DATAMATRIX=OFF',
+ '-DZXING_ENABLE_MAXICODE=OFF',
+ '-DZXING_ENABLE_PDF417=OFF',
+ '-DZXING_ENABLE_QRCODE=ON',
+ _env=env,
+ )
+ shprint(sh.cmake, '--build', build_dir, '--parallel', str(cpu_count()), _env=env)
+
+
+recipe = LibZXingRecipe()
### contrib/android/p4a_recipes/qt6/__init__.py
@@ -6,7 +6,7 @@
util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
assert Qt6Recipe._version == "6.10.2"
-assert Qt6Recipe.depends == ['python3', 'hostqt6']
+assert Qt6Recipe.depends == ['python3', 'hostqt6', 'ffmpeg_qt']
assert Qt6Recipe.python_depends == []
### contrib/build-linux/appimage/Dockerfile
@@ -29,6 +29,8 @@ RUN apt-get update -q && \
wget \
python3 \
make \
+ cmake \
+ clang-16 \
autotools-dev \
autoconf \
libtool \
@@ -72,15 +74,32 @@ RUN apt-get update -q && \
libc6-dev \
libc6 \
libc-dev-bin \
- libv4l-dev \
- libjpeg62-turbo-dev \
libx11-dev \
+ libegl1 \
+ libgl1 \
+ libxrandr2 \
desktop-file-utils \
&& \
rm -rf /var/lib/apt/lists/* && \
apt-get autoremove -y && \
apt-get clean
+# build binutils from source:
+# bullseye's binutils (2.35) corrupts shared libraries whose program headers were rewritten by
+# patchelf, e.g. the ffmpeg libs shipped in the PyQt6-Qt6 wheel: after "strip", the dynamic loader
+# rejects them with "ELF load command address/offset not page-aligned", and Qt Multimedia then has
+# no backend (no camera for QR scanning).
+# FIXME: remove this once the base image is bumped to trixie (or later)
+RUN cd /tmp && \
+ wget -q "https://ftp.gnu.org/gnu/binutils/binutils-2.47.tar.xz" && \
+ echo "154ab23b60070e8f27013c22977f1129425d67d1e8acd6e13010e617811e4cff binutils-2.47.tar.xz" | sha256sum -c - && \
+ tar xf binutils-2.47.tar.xz && \
+ mkdir binutils-build && cd binutils-build && \
+ ../binutils-2.47/configure --prefix=/usr/local --enable-deterministic-archives --disable-nls && \
+ make -j"$(nproc)" all-binutils && \
+ make install-binutils && \
+ cd /tmp && rm -rf binutils-build binutils-2.47 binutils-2.47.tar.xz
+
# create new user to avoid using root; but with sudo access and no password for convenience.
ARG UID=1000
RUN useradd -u "$UID" -m -s /usr/bin/bash -d /home/user user
### contrib/build-linux/appimage/make_appimage.sh
@@ -89,13 +89,13 @@ fi
cp -f "$DLL_TARGET_DIR"/libsecp256k1.so.* "$APPDIR/usr/lib/" || fail "Could not copy libsecp to its destination"
-if [ -f "$DLL_TARGET_DIR/libzbar.so.0" ]; then
- info "libzbar already built, skipping"
+if [ -f "$DLL_TARGET_DIR/libZXing.so" ]; then
+ info "zxing-cpp already built, skipping"
else
- # note: could instead just use the libzbar0 pkg from debian/apt, but that is too old and missing fixes for CVE-2023-40889
- "$CONTRIB"/make_zbar.sh || fail "Could not build zbar"
+ # Bullseye's default GCC lacks the required C++20 support.
+ CC=clang-16 CXX=clang++-16 "$CONTRIB"/make_zxing.sh || fail "Could not build zxing-cpp"
fi
-cp -f "$DLL_TARGET_DIR/libzbar.so.0" "$APPDIR/usr/lib/" || fail "Could not copy libzbar to its destination"
+cp -f "$DLL_TARGET_DIR/libZXing.so" "$APPDIR/usr/lib/" || fail "Could not copy zxing-cpp to its destination"
appdir_python() {
@@ -232,13 +232,19 @@ done
rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/{qml,libexec}
rm -rf "$PYDIR"/site-packages/PyQt6/{pyrcc*.so,pylupdate*.so,uic}
rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/plugins/{bearer,gamepads,geometryloaders,geoservices,playlistformats,position,renderplugins,sceneparsers,sensors,sqldrivers,texttospeech,webview}
-for component in Bluetooth Concurrent Designer Help Location NetworkAuth Nfc Positioning PositioningQuick Qml Quick Sensors SerialPort Sql Test Web Xml Labs ShaderTools SpatialAudio ; do
+for component in Bluetooth Designer Help Location NetworkAuth Nfc Positioning PositioningQuick Sensors SerialPort Sql Test Web Xml Labs ShaderTools SpatialAudio ; do
rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/lib/libQt6${component}*
rm -rf "$PYDIR"/site-packages/PyQt6/Qt${component}*
rm -rf "$PYDIR"/site-packages/PyQt6/bindings/Qt${component}*
done
-for component in Qml Quick ; do
- rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/lib/libQt6*${component}.so*
+# Qt's FFmpeg backend links to Quick/Qml; Qt 6.10's Multimedia also links to Concurrent.
+# https://github.com/qt/qtmultimedia/blob/v6.9.2/src/plugins/multimedia/ffmpeg/CMakeLists.txt#L199-L203
+# https://github.com/qt/qtdeclarative/blob/v6.9.2/src/quick/CMakeLists.txt#L243-L248
+# https://github.com/qt/qtmultimedia/blob/v6.10.0/src/multimedia/CMakeLists.txt#L124-L128
+# Keep those native libraries even though the desktop GUI does not import the bindings.
+for component in Concurrent Qml Quick ; do
+ rm -rf "$PYDIR"/site-packages/PyQt6/Qt${component}*
+ rm -rf "$PYDIR"/site-packages/PyQt6/bindings/Qt${component}*
done
rm -rf "$PYDIR"/site-packages/PyQt6/Qt.so
### contrib/build-wine/make_win.sh
@@ -49,40 +49,10 @@ else
"$CONTRIB"/make_libsecp256k1.sh || fail "Could not build libsecp"
fi
-if [ -f "$DLL_TARGET_DIR/libzbar-0.dll" ]; then
- info "libzbar already built, skipping"
+if [ -f "$DLL_TARGET_DIR/ZXing.dll" ]; then
+ info "zxing-cpp already built, skipping"
else
- (
- # iconv is needed for zbar. see https://github.com/mchehab/zbar/blob/a549566ea11eb03622bd4458a1728ffe3f589163/README-windows.md
- # (previously were using win-iconv, but changed to GNU libiconv due to compilation errors with modern gcc)
- LIBICONV_VER="1.18"
- download_if_not_exist "$CACHEDIR/libiconv-${LIBICONV_VER}.tar.gz" "https://ftp.gnu.org/pub/gnu/libiconv/libiconv-${LIBICONV_VER}.tar.gz"
- verify_hash "$CACHEDIR/libiconv-${LIBICONV_VER}.tar.gz" "3b08f5f4f9b4eb82f151a7040bfd6fe6c6fb922efe4b1659c66ea933276965e8"
- tar xf "$CACHEDIR/libiconv-${LIBICONV_VER}.tar.gz" -C "$CACHEDIR"
- # ref https://github.com/msys2/MINGW-packages/blob/7f68e9f2488737bbe03888ade094eaee8021d1c5/mingw-w64-libiconv/PKGBUILD
- info "Building libiconv..."
- cd "$CACHEDIR/libiconv-${LIBICONV_VER}"
- # Patches taken from msys2/MINGW-packages
- patch -p1 < "$here/patches/libiconv-fix-pointer-buf.patch"
- ./configure \
- $AUTOCONF_FLAGS \
- --prefix="/usr/${GCC_TRIPLET_HOST}" \
- --disable-static \
- --enable-shared \
- --enable-extra-encodings \
- --enable-relocatable \
- --disable-rpath \
- --enable-silent-rules \
- --enable-nls
- CC="${GCC_TRIPLET_HOST}-gcc" make "-j$CPU_COUNT" || fail "Could not build libiconv"
- cp -fpv "libcharset/lib/.libs/libcharset-1.dll" "$DLL_TARGET_DIR/" || fail "Could not copy the libcharset binary to DLL_TARGET_DIR"
- cp -fpv "lib/.libs/libiconv-2.dll" "$DLL_TARGET_DIR/" || fail "Could not copy the libiconv binary to DLL_TARGET_DIR"
- # FIXME avoid using sudo
- sudo make install || fail "Could not install libiconv"
- # workaround to delete files owned by root, created by "make install":
- make clean
- )
- "$CONTRIB"/make_zbar.sh || fail "Could not build zbar"
+ "$CONTRIB"/make_zxing.sh || fail "Could not build zxing-cpp"
fi
if [ -f "$DLL_TARGET_DIR/libusb-1.0.dll" ]; then
### contrib/build-wine/patches/libiconv-fix-pointer-buf.patch
@@ -1,37 +0,0 @@
---- a/lib/iconv.c 2018-05-03 23:18:55.997221700 -0400
-+++ b/lib/iconv.c 2018-05-03 23:26:47.611682700 -0400
-@@ -170,12 +170,12 @@ static const struct stringpool2_t string
- #include "aliases2.h"
- #undef S
- };
- #define stringpool2 ((const char *) &stringpool2_contents)
- static const struct alias sysdep_aliases[] = {
--#define S(tag,name,encoding_index) { (int)(long)&((struct stringpool2_t *)0)->stringpool_##tag, encoding_index },
-+#define S(tag,name,encoding_index) { (int)(intptr_t)&((struct stringpool2_t *)0)->stringpool_##tag, encoding_index },
- #include "aliases2.h"
- #undef S
- };
- #ifdef __GNUC__
- __inline
- #else
---- a/lib/genaliases.c 2023-01-14 00:00:00.000000000 +0000
-+++ b/lib/genaliases.c 2023-01-14 10:18:00.000000000 +0000
-@@ -50,7 +50,7 @@
- putc(c, out2);
- }
- }
-- fprintf(out2,"\")' tmp.h | sed -e 's|^.*\\(stringpool_str[0-9]*\\).*$| (int)(long)\\&((struct stringpool_t *)0)->\\1,|'\n");
-+ fprintf(out2,"\")' tmp.h | sed -e 's|^.*\\(stringpool_str[0-9]*\\).*$| (int)(intptr_t)\\&((struct stringpool_t *)0)->\\1,|'\n");
- for (; n > 0; names++, n--)
- emit_alias(out1, *names, c_name);
- }
---- a/lib/genaliases2.c 2023-01-14 00:00:00.000000000 +0000
-+++ b/lib/genaliases2.c 2023-01-14 10:18:00.000000000 +0000
-@@ -44,6 +44,6 @@
- static void emit_encoding (FILE* out1, FILE* out2, const char* tag, const char* const* names, size_t n, const char* c_name)
- {
-- fprintf(out2," (int)(long)&((struct stringpool2_t *)0)->stringpool_%s_%u,\n",tag,counter);
-+ fprintf(out2," (int)(intptr_t)&((struct stringpool2_t *)0)->stringpool_%s_%u,\n",tag,counter);
- for (; n > 0; names++, n--)
- emit_alias(out1, tag, *names, c_name);
- }
### contrib/build_tools_util.sh
@@ -143,7 +143,8 @@ function host_strip()
gcc_host strip "$@"
;;
darwin)
- # TODO: Strip on macOS?
+ # dylibs have to keep their global symbols, so only strip debug and local symbols
+ strip -S -x "$@"
;;
esac
fi
### contrib/make_zbar.sh
@@ -1,94 +0,0 @@
-#!/bin/bash
-
-# This script can be used on Linux hosts to build native libzbar binaries.
-# sudo apt-get install pkg-config libx11-dev libx11-6 libv4l-dev libxv-dev libxext-dev libjpeg-dev
-#
-# It can also be used to cross-compile to Windows:
-# $ sudo apt-get install mingw-w64 mingw-w64-tools win-iconv-mingw-w64-dev
-# For a Windows x86 (32-bit) target, run:
-# $ GCC_TRIPLET_HOST="i686-w64-mingw32" BUILD_TYPE="wine" ./contrib/make_zbar.sh
-# Or for a Windows x86_64 (64-bit) target, run:
-# $ GCC_TRIPLET_HOST="x86_64-w64-mingw32" BUILD_TYPE="wine" ./contrib/make_zbar.sh
-
-ZBAR_VERSION="bb05ec54eec57f8397cb13fb9161372a281a1219"
-# ^ tag 0.23.93
-
-set -e
-
-. "$(dirname "$0")/build_tools_util.sh" || (echo "Could not source build_tools_util.sh" && exit 1)
-
-here="$(dirname "$(realpath "$0" 2> /dev/null || grealpath "$0")")"
-CONTRIB="$here"
-PROJECT_ROOT="$CONTRIB/.."
-
-pkgname="zbar"
-info "Building $pkgname..."
-
-(
- cd "$CONTRIB"
- if [ ! -d zbar ]; then
- git clone https://github.com/mchehab/zbar.git
- fi
- cd zbar
- if ! $(git cat-file -e ${ZBAR_VERSION}) ; then
- info "Could not find requested version $ZBAR_VERSION in local clone; fetching..."
- git fetch --all
- fi
- git reset --hard
- git clean -dfxq
- git checkout "${ZBAR_VERSION}^{commit}"
-
- if [ "$BUILD_TYPE" = "wine" ] ; then
- echo "libzbar_la_LDFLAGS += -Wc,-static" >> zbar/Makefile.am
- echo "LDFLAGS += -Wc,-static" >> Makefile.am
- fi
- if ! [ -x configure ] ; then
- autoreconf -vfi || fail "Could not run autoreconf for $pkgname. Please make sure you have automake and libtool installed, and try again."
- fi
- if ! [ -r config.status ] ; then
- if [ "$BUILD_TYPE" = "wine" ] ; then
- # windows target
- AUTOCONF_FLAGS="$AUTOCONF_FLAGS \
- --with-x=no \
- --enable-video=yes \
- --with-jpeg=no \
- --with-directshow=yes \
- --disable-dependency-tracking"
- elif [ $(uname) == "Darwin" ]; then
- # macos target
- AUTOCONF_FLAGS="$AUTOCONF_FLAGS \
- --with-x=no \
- --enable-video=no \
- --with-jpeg=no"
- else
- # linux target
- AUTOCONF_FLAGS="$AUTOCONF_FLAGS \
- --with-x=yes \
- --enable-video=yes \
- --with-jpeg=yes"
- fi
- ./configure \
- $AUTOCONF_FLAGS \
- --prefix="$here/$pkgname/dist" \
- --enable-pthread=no \
- --enable-doc=no \
- --with-python=no \
- --with-gtk=no \
- --with-qt=no \
- --with-java=no \
- --with-imagemagick=no \
- --with-dbus=no \
- --enable-codes=qrcode \
- --disable-static \
- --enable-shared || fail "Could not configure $pkgname. Please make sure you have a C compiler installed and try again."
- fi
- make "-j$CPU_COUNT" || fail "Could not build $pkgname"
- make install || fail "Could not install $pkgname"
- . "$here/$pkgname/dist/lib/libzbar.la"
- host_strip "$here/$pkgname/dist/lib/$dlname"
- cp -fpv "$here/$pkgname/dist/lib/$dlname" "$PROJECT_ROOT/electrum" || fail "Could not copy the $pkgname binary to its destination"
- info "$dlname has been placed in the inner 'electrum' folder."
- if [ -n "$DLL_TARGET_DIR" ] ; then
- cp -fpv "$here/$pkgname/dist/lib/$dlname" "$DLL_TARGET_DIR/" || fail "Could not copy the $pkgname binary to DLL_TARGET_DIR"
- fi
-)
### contrib/make_zxing.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+
+# Build the QR decoder and C API for Linux, macOS, and Windows (using MinGW).
+# Requires CMake and a C++20 compiler. Android builds use the libzxing p4a recipe.
+# To cross-compile for Windows:
+# $ GCC_TRIPLET_HOST=x86_64-w64-mingw32 BUILD_TYPE=wine ./contrib/make_zxing.sh
+
+set -e
+. "$(dirname "$0")/build_tools_util.sh"
+
+# zxing-cpp v3.1.1. Keep in sync with android/p4a_recipes/libzxing/__init__.py.
+# A major version bump also needs ZXING_MAJOR_VERSION in electrum/qrreader/zxing.py.
+ZXING_VERSION="287c85df6f961c8efbfb5ffd736cd9457b8b890e"
+ZXING_SHA256="97d952c661b1f79d21aacc2ec544ef05c4d1465f55692cc49622ea6a8166ca7b"
+
+CONTRIB="$(dirname "$(realpath "$0")")"
+PROJECT_ROOT="$CONTRIB/.."
+cache_dir="$CONTRIB/.cache/zxing"
+archive="$cache_dir/zxing-$ZXING_VERSION.tar.gz"
+source_dir="$cache_dir/zxing-cpp-$ZXING_VERSION"
+build_dir="$source_dir/build-$BUILD_TYPE${GCC_TRIPLET_HOST:+-$GCC_TRIPLET_HOST}"
+
+cmake_flags=()
+case "$BUILD_TYPE" in
+ linux) library="libZXing.so" ;;
+ darwin) library="libZXing.dylib" ;;
+ wine)
+ library="ZXing.dll"
+ cmake_flags+=(
+ -DCMAKE_SYSTEM_NAME=Windows
+ "-DCMAKE_C_COMPILER=$GCC_TRIPLET_HOST-gcc"
+ "-DCMAKE_CXX_COMPILER=$GCC_TRIPLET_HOST-g++"
+ "-DCMAKE_RC_COMPILER=$GCC_TRIPLET_HOST-windres"
+ # Bundle compiler runtimes and omit the PE timestamp for reproducibility.
+ "-DCMAKE_SHARED_LINKER_FLAGS=-static -static-libgcc -static-libstdc++ -Wl,--no-insert-timestamp"
+ )
+ ;;
+ *) fail "Unsupported native build type: $BUILD_TYPE" ;;
+esac
+
+mkdir -p "$cache_dir"
+download_if_not_exist "$archive" "https://github.com/zxing-cpp/zxing-cpp/archive/$ZXING_VERSION.tar.gz"
+verify_hash "$archive" "$ZXING_SHA256"
+if [ ! -d "$source_dir" ]; then
+ tar -xzf "$archive" -C "$cache_dir"
+fi
+
+# CMake caches absolute paths and compilers, which differ between local and Docker builds.
+# Release scripts cache the finished library in DLL_TARGET_DIR instead.
+rm -rf "$build_dir"
+
+# Build core directly: the top-level C API target also pulls in test dependencies.
+cmake -S "$source_dir/core" -B "$build_dir" \
+ "${cmake_flags[@]}" \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_SKIP_RPATH=ON \
+ -DBUILD_SHARED_LIBS=ON \
+ -DZXING_C_API=ON \
+ -DZXING_READERS=ON \
+ -DZXING_WRITERS=OFF \
+ -DZXING_ENABLE_1D=OFF \
+ -DZXING_ENABLE_AZTEC=OFF \
+ -DZXING_ENABLE_DATAMATRIX=OFF \
+ -DZXING_ENABLE_MAXICODE=OFF \
+ -DZXING_ENABLE_PDF417=OFF \
+ -DZXING_ENABLE_QRCODE=ON
+cmake --build "$build_dir" --parallel "$CPU_COUNT"
+# MinGW adds a lib prefix, whereas the ctypes loader uses the MSVC-style name.
+if [ "$BUILD_TYPE" = "wine" ]; then
+ cp -fv "$build_dir/libZXing.dll" "$build_dir/$library"
+fi
+host_strip "$build_dir/$library"
+cp -Lfv "$build_dir/$library" "$PROJECT_ROOT/electrum/$library"
+info "$library has been placed in the inner electrum folder."
+if [ -n "$DLL_TARGET_DIR" ]; then
+ cp -Lfv "$build_dir/$library" "$DLL_TARGET_DIR/$library"
+fi
### contrib/osx/make_osx.sh
@@ -85,7 +85,9 @@ python3 -m pip install --no-build-isolation --no-dependencies --no-binary :all:
|| fail "Could not install build dependencies (mac)"
info "Installing some build-time deps for compilation..."
-brew install autoconf automake libtool gettext coreutils pkgconfig
+# cmake, wget and gcc@14 are required for zxing-cpp.
+# gcc can be removed with a C++ 20 compatible compiler becoming available (newer MacOS version)
+brew install autoconf automake libtool gettext coreutils pkgconfig cmake wget gcc@14
info "Building PyInstaller."
PYINSTALLER_REPO="https://github.com/pyinstaller/pyinstaller.git"
@@ -155,13 +157,17 @@ else
fi
cp -f "$DLL_TARGET_DIR"/libsecp256k1.*.dylib "$PROJECT_ROOT/electrum" || fail "Could not copy libsecp256k1 dylib"
-if [ ! -f "$DLL_TARGET_DIR/libzbar.0.dylib" ]; then
- info "Building ZBar dylib..."
- "$CONTRIB"/make_zbar.sh || fail "Could not build ZBar dylib"
+if [ ! -f "$DLL_TARGET_DIR/libZXing.dylib" ]; then
+ info "Building zxing-cpp dylib..."
+ # Build zxing with the installed gcc 14 for C++20 support and statically link the runtimes into the library.
+ CC="$(brew --prefix gcc@14)/bin/gcc-14" \
+ CXX="$(brew --prefix gcc@14)/bin/g++-14" \
+ LDFLAGS="-static-libgcc -static-libstdc++ -Wl,-S" \
+ "$CONTRIB"/make_zxing.sh || fail "Could not build zxing-cpp dylib"
else
- info "Skipping ZBar build: reusing already built dylib."
+ info "Skipping zxing-cpp build: reusing already built dylib."
fi
-cp -f "$DLL_TARGET_DIR/libzbar.0.dylib" "$PROJECT_ROOT/electrum/" || fail "Could not copy ZBar dylib"
+cp -f "$DLL_TARGET_DIR/libZXing.dylib" "$PROJECT_ROOT/electrum/" || fail "Could not copy zxing-cpp dylib"
if [ ! -f "$DLL_TARGET_DIR/libusb-1.0.dylib" ]; then
info "Building libusb dylib..."
### electrum/commands.py
@@ -805,8 +805,8 @@ async def version_info(self):
# instead of using pip, hence it is useful to list them for debugging.
from electrum_ecc import ecc_fast
ret.update(ecc_fast.version_info())
- from . import qrscanner
- ret.update(qrscanner.version_info())
+ from . import qrreader
+ ret.update(qrreader.version_info())
ret.update(DeviceMgr.version_info())
ret.update(crypto.version_info())
# add some special cases
### electrum/gui/qml/android_res/layout/scanner_layout.xml
@@ -1,36 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-
-<FrameLayout
- xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <FrameLayout
- android:id="@+id/content_frame"
- android:layout_width="match_parent"
- android:layout_height="match_parent" />
-
- <TextView
- android:id="@+id/hint"
- android:layout_gravity="center|top"
- android:gravity="center"
- android:text="Scan a QR code."
- android:layout_width="wrap_content"
- android:textColor="#ffffff"
- android:shadowColor="#000000"
- android:shadowDx="1"
- android:shadowDy="1"
- android:shadowRadius="2"
- android:textSize="15sp"
- android:padding="14dp"
- android:layout_height="wrap_content" />
-
- <Button
- android:id="@+id/paste_btn"
- android:layout_gravity="center|bottom"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="Paste from clipboard 📋" />
-
-</FrameLayout>
### electrum/gui/qml/components/ScanDialog.qml
@@ -6,23 +6,22 @@ import org.electrum
import "controls"
-// currently not used on android, kept for testing on desktop, and future use
-// on android when qt6 camera support becomes usable (i.e. stops crashing)
ElDialog {
id: scanDialog
property string error
- property string hint
+ property string hint: qsTr('Scan a QR code.')
signal foundText(data: string)
- signal foundBinary(data: Bytes)
width: parent.width
height: parent.height
padding: 0
header: null
- topPadding: 0 // dialog needs topPadding override
+ topPadding: app.statusBarHeight
+
+ onAboutToHide: qrscan.stop()
function doClose() {
qrscan.stop()
@@ -52,5 +51,13 @@ ElDialog {
}
}
+ // Keep the camera preview out of screenshots and screen recordings.
+ Binding {
+ target: AppController
+ property: 'secureWindow'
+ when: scanDialog.visible // enables stacking multiple secureWindow dialogs
+ value: true
+ }
+
onClosed: destroy()
}
### electrum/gui/qml/components/SendDialog.qml
@@ -7,7 +7,6 @@ import org.electrum 1.0
import "controls"
-// currently not used on android, kept for future use when qt6 camera stops crashing
ElDialog {
id: root
@@ -22,7 +21,7 @@ ElDialog {
header: null
padding: 0
- topPadding: 0
+ topPadding: app.statusBarHeight
onAboutToHide: {
console.log('about to hide')
@@ -120,4 +119,12 @@ ElDialog {
Bitcoin {
id: bitcoin
}
+
+ // Keep the camera preview out of screenshots and screen recordings.
+ Binding {
+ target: AppController
+ property: 'secureWindow'
+ when: root.visible // enables stacking multiple secureWindow dialogs
+ value: true
+ }
}
### electrum/gui/qml/components/WalletMainView.qml
@@ -34,57 +34,20 @@ Item {
}
function openSendDialog() {
- // Qt based send dialog if not on android
- if (!AppController.isAndroid()) {
- _sendDialog = app.sendDialog.createObject(mainView, {invoiceParser: invoiceParser, piResolver: piResolver})
- _sendDialog.open()
- return
- }
-
- // Android based send dialog if on android
- var scanner = app.scanDialog.createObject(mainView, {
- hint: Daemon.currentWallet.isLightning
- ? qsTr('Scan an Invoice, an Address, an LNURL, a PSBT or a Channel Backup')
- : qsTr('Scan an Invoice, an Address, an LNURL or a PSBT')
- })
- scanner.onFoundText.connect(function(data) {
- data = data.trim()
- if (bitcoin.isRawTx(data)) {
- app.stack.push(Qt.resolvedUrl('TxDetails.qml'), { rawtx: data })
- } else if (Daemon.currentWallet.isValidChannelBackup(data)) {
- var dialog = app.messageDialog.createObject(app, {
- title: qsTr('Import Channel Backup?'),
- yesno: true
- })
- dialog.accepted.connect(function() {
- Daemon.currentWallet.importChannelBackup(data)
- })
- dialog.open()
- } else {
- piResolver.recipient = data
- }
- //scanner.destroy() // TODO
- })
- scanner.open()
+ _sendDialog = qtSendDialog.createObject(mainView, {invoiceParser: invoiceParser, piResolver: piResolver})
+ _sendDialog.open()
}
function closeSendDialog() {
- if (!AppController.isAndroid()) {
- if (_sendDialog) {
- _sendDialog.doClose()
- _sendDialog = null
- }
+ if (_sendDialog) {
+ _sendDialog.doClose()
+ _sendDialog = null
}
}
function restartSendDialog() {
- if (!AppController.isAndroid()) {
- if (_sendDialog) {
- _sendDialog.restart()
- }
- return
- } else {
- openSendDialog()
+ if (_sendDialog) {
+ _sendDialog.restart()
}
}
@@ -580,6 +543,11 @@ Item {
}
}
+ Component {
+ id: qtSendDialog
+ SendDialog {}
+ }
+
Component {
id: receiveDetailsDialog
@@ -744,4 +712,3 @@ Item {
}
}
}
-
### electrum/gui/qml/components/controls/InfoTextArea.qml
@@ -18,6 +18,7 @@ TextHighlightPane {
property alias text: infotext.text
property int iconStyle: InfoTextArea.IconStyle.Info
property alias textFormat: infotext.textFormat
+ property alias horizontalAlignment: infotext.horizontalAlignment
property bool compact: false
borderColor: iconStyle == InfoTextArea.IconStyle.Info
### electrum/gui/qml/components/controls/QRScan.qml
@@ -12,6 +12,8 @@ Item {
property string url
property string hint
+ readonly property string cameraPermission: 'android.permission.CAMERA'
+
signal foundText(data: string)
function restart() {
@@ -22,7 +24,8 @@ Item {
function start() {
console.log('qrscan.start')
- loader.item.startTimer.start()
+ AppController.requestPermission(scanner.cameraPermission)
+ scanner.active = true
}
function stop() {
@@ -40,6 +43,7 @@ Item {
id: loader
anchors.fill: parent
sourceComponent: scancomp
+ onLoaded: scanner.start()
onStatusChanged: {
if (loader.status == Loader.Ready) {
console.log('camera loaded')
@@ -55,7 +59,6 @@ Item {
Item {
property alias vo: _vo
property alias ic: _ic
- property alias startTimer: _startTimer
VideoOutput {
id: _vo
@@ -79,6 +82,7 @@ Item {
visible: scanner.hint
background.opacity: 0.5
iconStyle: InfoTextArea.IconStyle.None
+ horizontalAlignment: Text.AlignHCenter
anchors {
top: parent.top
topMargin: constants.paddingXLarge
@@ -90,14 +94,15 @@ Item {
text: scanner.hint
}
- Component.onCompleted: {
- startTimer.start()
+ TapHandler {
+ // "tap-to-focus", trigger an image capture which forces an autofocus
+ onTapped: if (_ic.readyForCapture) _ic.capture()
}
}
ImageCapture {
id: _ic
-
+ onImageAvailable: (requestId, frame) => qr.decodeFrameOneShot(frame)
}
MediaDevices {
@@ -106,9 +111,19 @@ Item {
Camera {
id: camera
- cameraDevice: mediaDevices.defaultVideoInput
+ cameraDevice: {
+ // The Android FFmpeg backend does not mark a default camera.
+ for (var device of mediaDevices.videoInputs) {
+ if (device.position === CameraDevice.BackFace)
+ return device
+ }
+ return mediaDevices.defaultVideoInput
+ }
+ // The permission prompt pauses the activity, so the application
+ // state change re-evaluates this binding after the user answers.
active: scanner.active
- focusMode: Camera.FocusModeAutoNear
+ && Qt.application.state === Qt.ApplicationActive
+ && AppController.hasPermission(scanner.cameraPermission)
customFocusPoint: Qt.point(0.5, 0.5)
onErrorOccurred: {
@@ -121,14 +136,6 @@ Item {
imageCapture: _ic
camera: camera
}
-
- Timer {
- id: _startTimer
- interval: 500
- repeat: false
- onTriggered: scanner.active = true
- }
-
}
}
@@ -149,7 +156,8 @@ Item {
Connections {
target: qr
function onDataChanged() {
- console.log('QR DATA: ' + qr.data)
+ if (!qr.data)
+ return
scanner.active = false
scanner.foundText(qr.data)
}
### electrum/gui/qml/components/main.qml
@@ -451,13 +451,10 @@ ApplicationWindow
}
}
- property Component sendDialog // set in Component.onCompleted
- property Component scanDialog // set in Component.onCompleted
+ property alias scanDialog: _scanDialog
Component {
id: _scanDialog
- QRScanner {
- onFinished: destroy()
- }
+ ScanDialog {}
}
Component {
@@ -533,27 +530,6 @@ ApplicationWindow
Component.onCompleted: {
coverTimer.start()
- if (AppController.isAndroid()) {
- app.scanDialog = _scanDialog
- } else {
- // for running on Desktop. uses QtMultimedia.
- app.scanDialog = Qt.createComponent('ScanDialog.qml')
- app.sendDialog = Qt.createComponent('SendDialog.qml') // component property not used on android
- if (app.scanDialog.status != Component.Ready || app.sendDialog.status != Component.Ready) {
- console.error('Failed to load QML components, exiting: '
- + app.scanDialog.errorString() + app.sendDialog.errorString())
- let dialog = app.messageDialog.createObject(app, {
- iconSource: Qt.resolvedUrl('../../icons/warning.png'),
- text: app.scanDialog.errorString() + app.sendDialog.errorString()
- })
- dialog.closed.connect(function() {
- app.quitApp()
- })
- dialog.open()
- return
- }
- }
-
function continueWithServerConnection() {
if (!Network.autoConnectDefined) {
var dialog = serverConnectWizard.createObject(app)
### electrum/gui/qml/java_classes/org/electrum/biometry/BiometricActivity.java
@@ -22,8 +22,6 @@
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
-import org.electrum.electrum.res.R;
-
public class BiometricActivity extends Activity {
private static final String TAG = "BiometricActivity";
private static final String KEY_NAME = "electrum_biometric_key";
@@ -172,4 +170,4 @@ private Cipher getCipher() throws Exception {
+ KeyProperties.BLOCK_MODE_CBC + "/"
+ KeyProperties.ENCRYPTION_PADDING_PKCS7);
}
-}
\ No newline at end of file
+}
### electrum/gui/qml/java_classes/org/electrum/qr/SimpleScannerActivity.java
@@ -1,258 +0,0 @@
-package org.electrum.qr;
-
-import android.app.Activity;
-import android.os.Bundle;
-import android.os.Build;
-import android.util.Log;
-import android.content.Intent;
-import android.Manifest;
-import android.content.ClipData;
-import android.content.ClipDescription;
-import android.content.ClipboardManager;
-import android.content.Context;
-import android.content.pm.PackageManager;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.Window;
-import android.view.WindowInsets;
-import android.view.WindowManager;
-import android.widget.Button;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import androidx.core.app.ActivityCompat;
-
-import java.util.Arrays;
-
-import de.markusfisch.android.barcodescannerview.widget.BarcodeScannerView;
-import de.markusfisch.android.zxingcpp.ZxingCpp.Result;
-import de.markusfisch.android.zxingcpp.ZxingCpp.ContentType;
-
-
-import org.electrum.electrum.res.R; // package set in build.gradle
-
-public class SimpleScannerActivity extends Activity {
- private static final int MY_PERMISSIONS_CAMERA = 1002;
-
- private BarcodeScannerView mScannerView = null;
- final String TAG = "org.electrum.qr.SimpleScannerActivity";
-
- private boolean mAlreadyRequestedPermissions = false;
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.scanner_layout);
-
- // change top text
- Intent intent = getIntent();
- String text = intent.getStringExtra(intent.EXTRA_TEXT);
- TextView hintTextView = (TextView) findViewById(R.id.hint);
- hintTextView.setText(text);
-
- // secure this window
- boolean secure = !intent.getBooleanExtra("allow_screenshots", false);
- setSecureWindow(secure);
-
- // bind "paste" button
- Button btn = (Button) findViewById(R.id.paste_btn);
- btn.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
- if (clipboard.hasPrimaryClip()
- && (clipboard.getPrimaryClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)
- || clipboard.getPrimaryClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML))) {
- ClipData.Item item = clipboard.getPrimaryClip().getItemAt(0);
- String clipboardText = item.getText().toString();
- // limit size of content. avoid https://developer.android.com/reference/android/os/TransactionTooLargeException.html
- if (clipboardText.length() > 512 * 1024) {
- Toast.makeText(SimpleScannerActivity.this, "Clipboard contents too large.", Toast.LENGTH_SHORT).show();
- return;
- }
- SimpleScannerActivity.this.setResultAndClose(null, clipboardText);
- } else {
- Toast.makeText(SimpleScannerActivity.this, "Clipboard is empty.", Toast.LENGTH_SHORT).show();
- }
- }
- });
- setupEdgeToEdge();
- }
-
- @Override
- public void onResume() {
- super.onResume();
- if (this.hasPermission()) {
- this.startCamera();
- } else if (!mAlreadyRequestedPermissions) {
- mAlreadyRequestedPermissions = true;
- this.requestPermission();
- }
- }
-
- @Override
- public void onPause() {
- super.onPause();
- if (null != mScannerView) {
- mScannerView.close(); // Stop camera on pause
- }
- }
-
- private void startCamera() {
- if (mScannerView == null) {
- mScannerView = new BarcodeScannerView(this);
- // Set crop ratio to 75% (this defines the square area shown in the scanner view)
- mScannerView.setCropRatio(0.75f);
- // allow tap to focus (note: some devices don't support autofocus which is enabled by default)
- mScannerView.setTapToFocus();
- // by default only Format.QR_CODE is set
- ViewGroup contentFrame = (ViewGroup) findViewById(R.id.content_frame);
- contentFrame.addView(mScannerView);
- mScannerView.setOnBarcodeListener(result -> {
- // Handle the scan result
- this.setResultAndClose(result, null);
- // Return false to stop scanning after first result
- return false;
- });
- }
- mScannerView.openAsync(); // Start camera on resume
- }
-
- private void setResultAndClose(Result scanResult, String textOnly) {
- Intent resultIntent = new Intent();
- if (textOnly != null) {
- Log.v(TAG, "clipboard contentType TEXT");
- resultIntent.putExtra("text", textOnly);
- } else if (scanResult != null) {
- if (scanResult.getContentType() == ContentType.TEXT) {
- Log.v(TAG, "scanResult contentType TEXT");
- resultIntent.putExtra("text", scanResult.getText());
- } else if (scanResult.getContentType() == ContentType.BINARY) {
- Log.v(TAG, "scanResult contentType BINARY");
- resultIntent.putExtra("binary", scanResult.getRawBytes());
- } else {
- Log.v(TAG, "scanresult contenttype unknown");
- }
- }
- setResult(Activity.RESULT_OK, resultIntent);
- this.finish();
- }
-
- private boolean hasPermission() {
- return (ActivityCompat.checkSelfPermission(this,
- Manifest.permission.CAMERA)
- == PackageManager.PERMISSION_GRANTED);
- }
-
- private void requestPermission() {
- ActivityCompat.requestPermissions(this,
- new String[]{Manifest.permission.CAMERA},
- MY_PERMISSIONS_CAMERA);
- }
-
- @Override
- public void onRequestPermissionsResult(int requestCode,
- String permissions[], int[] grantResults) {
- switch (requestCode) {
- case MY_PERMISSIONS_CAMERA: {
- if (grantResults.length > 0
- && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
- // permission was granted, yay!
- this.startCamera();
- } else {
- // permission denied
- //this.finish();
- }
- return;
- }
- }
- }
-
- private boolean enforcesEdgeToEdge() {
- // if true the UI needs to be padded to be e2e compatible
- return Build.VERSION.SDK_INT >= 35;
- }
-
- private void setupEdgeToEdge() {
- if (!enforcesEdgeToEdge()) {
- return;
- }
-
- // Get the root view and set up insets listener
- getWindow().getDecorView().setOnApplyWindowInsetsListener((v, insets) -> {
- android.graphics.Insets systemBars = insets.getInsets(WindowInsets.Type.systemBars());
-
- // Apply padding to content frame to keep scanner focus area centered
- ViewGroup contentFrame = findViewById(R.id.content_frame);
- if (contentFrame != null) {
- contentFrame.setPadding(
- systemBars.left,
- systemBars.top,
- systemBars.right,
- systemBars.bottom
- );
- }
-
- // Apply top padding to hint text for status bar
- TextView hintTextView = findViewById(R.id.hint);
- if (hintTextView != null) {
- hintTextView.setPadding(
- hintTextView.getPaddingLeft(),
- systemBars.top,
- hintTextView.getPaddingRight(),
- hintTextView.getPaddingBottom()
- );
- }
-
- // Apply bottom margin to paste button for navigation bar
- Button pasteButton = findViewById(R.id.paste_btn);
- if (pasteButton != null) {
- ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) pasteButton.getLayoutParams();
- params.bottomMargin = systemBars.bottom;
- pasteButton.setLayoutParams(params);
- }
-
- return insets;
- });
- }
-
- public void setSecureWindow(boolean secure) {
- // method duplicated from p4a bootstraps/ org.kivy.android.PythonActivity
- runOnUiThread(new Runnable() {
- private Activity mActivity;
- private boolean mEnable;
-
- public Runnable _initialize(Activity activity, boolean enable) {
- this.mActivity = activity;
- this.mEnable = enable;
- return this;
- }
-
- @Override
- public void run() {
- Window window = this.mActivity.getWindow();
-
- if ( !((window.getAttributes().flags & WindowManager.LayoutParams.FLAG_SECURE) != 0) ^ mEnable)
- return; // no change needed
-
- if (mEnable) {
- Log.v(TAG, "Setting Secure Window");
- window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
- } else {
- Log.v(TAG, "UnSetting Secure Window");
- window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE);
- // The below forces a redraw of the window/view, which is needed on some phones
- // to actually clear the flag. However on some other phones, it crashes the app...
- // see https://github.com/spesmilo/electrum/issues/8522
- /*if (ViewCompat.isAttachedToWindow(window.getDecorView())) {
- WindowManager wm = this.mActivity.getWindowManager();
- wm.removeViewImmediate(window.getDecorView());
- wm.addView(window.getDecorView(), window.getAttributes());
- }*/
- }
- }
- }._initialize(this, secure));
- }
-
-
-}
### electrum/gui/qml/qeapp.py
@@ -30,7 +30,6 @@
from .qenetwork import QENetwork
from .qewallet import QEWallet
from .qeqr import QEQRParser, QEQRImageProvider, QEQRImageProviderHelper
-from .qeqrscanner import QEQRScanner
from .qebitcoin import QEBitcoin
from .qefx import QEFX
from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCpfpFeeBumper, QETxCanceller, QETxSweepFinalizer, FeeSlider
@@ -156,7 +155,7 @@ def on_notification_timer(self):
# multiple times
if self.isAndroid() and not self.hasPermission(permissions.Permission.POST_NOTIFICATIONS) \
and self._permissions.get(permissions.Permission.POST_NOTIFICATIONS) is None:
- self.request_permission(permissions.Permission.POST_NOTIFICATIONS)
+ self.requestPermission(permissions.Permission.POST_NOTIFICATIONS)
return
try:
wallet, message = self.user_notification_queue.get_nowait()
@@ -199,9 +198,10 @@ def hasPermission(self, permissionFqcn: str) -> bool:
result = permissions.check_permission(permissionFqcn)
return result
- def request_permission(self, permissionFqcn: str, permission_result_cb: Optional[Callable] = None):
- if not self.isAndroid():
- return True
+ @pyqtSlot(str)
+ def requestPermission(self, permissionFqcn: str, permission_result_cb: Optional[Callable] = None):
+ if self.hasPermission(permissionFqcn):
+ return
self.logger.debug(f'requesting {permissionFqcn=}')
permissions.request_permission(
permissionFqcn,
@@ -491,7 +491,6 @@ def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: '
qmlRegisterType(QEWallet, 'org.electrum', 1, 0, 'Wallet')
qmlRegisterType(QEBitcoin, 'org.electrum', 1, 0, 'Bitcoin')
qmlRegisterType(QEQRParser, 'org.electrum', 1, 0, 'QRParser')
- qmlRegisterType(QEQRScanner, 'org.electrum', 1, 0, 'QRScanner')
qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX')
qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer')
qmlRegisterType(QEPIResolver, 'org.electrum', 1, 0, 'PIResolver')
### electrum/gui/qml/qeqr.py
@@ -1,34 +1,72 @@
-import asyncio
import qrcode
from qrcode.exceptions import DataOverflowError
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import contextmanager
import math
import urllib
-from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRect
+from PyQt6 import sip
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
from PyQt6.QtGui import QImage, QColor
from PyQt6.QtQuick import QQuickImageProvider
try:
- from PyQt6.QtMultimedia import QVideoSink
+ from PyQt6.QtMultimedia import QVideoFrame, QVideoFrameFormat, QVideoSink
+ # Pixel formats whose first plane is an 8-bit luma image, which the QR reader can decode as is.
+ _PF = QVideoFrameFormat.PixelFormat
+ _LUMA_PLANE_FORMATS = frozenset({
+ _PF.Format_Y8,
+ _PF.Format_NV12, _PF.Format_NV21,
+ _PF.Format_YUV420P, _PF.Format_YV12, _PF.Format_YUV422P,
+ _PF.Format_IMC1, _PF.Format_IMC2, _PF.Format_IMC3, _PF.Format_IMC4,
+ })
except ImportError:
- # stub QVideoSink when not found, as it's not essential on android
- # and requires many dependencies when unit testing.
- # Note: missing QtMultimedia will lead to errors when using QR scanner on desktop
+ # Stub QVideoSink for unit tests without the multimedia dependencies.
+ # QR scanning requires QtMultimedia on all platforms.
from PyQt6.QtCore import QObject as QVideoSink
+ _LUMA_PLANE_FORMATS = frozenset()
from electrum.logging import get_logger
from electrum.qrreader import get_qr_reader
-from electrum.i18n import _
-from electrum.util import profiler, get_asyncio_loop
+from electrum.util import profiler
from electrum.gui.common_qt.util import draw_qr
+_logger = get_logger(__name__)
+
+
+@contextmanager
+def _luma_image(frame: 'QVideoFrame'):
+ """Yields the frame as an 8-bit luma image: (buffer, buffer_size, stride, width, height).
+
+ Camera frames are usually planar or semi-planar YUV, whose first plane already is such
+ an image, so it is yielded straight from the mapped frame, without any pixel conversion
+ or copy. Frames in other pixel formats are converted via QImage.
+ """
+ if frame.pixelFormat() in _LUMA_PLANE_FORMATS and frame.map(QVideoFrame.MapMode.ReadOnly):
+ try:
+ width, stride = frame.width(), frame.bytesPerLine(0)
+ if 0 < width <= stride:
+ # Android reports a YUV_420_888 plane as ending with the last row's pixels
+ # rather than its padding. Drop that row then, so the view fits the buffer.
+ height = min(frame.height(), frame.mappedBytes(0) // stride)
+ if height > 0:
+ _logger.debug("decoding luma plane of camera frame in-place", only_once=True)
+ yield frame.bits(0).__int__(), height * stride, stride, width, height
+ return
+ finally:
+ frame.unmap()
+ _logger.debug("converting camera frame to grayscale via QImage", only_once=True)
+ image = frame.toImage().convertToFormat(QImage.Format.Format_Grayscale8)
+ if image.isNull():
+ raise ValueError(f'cannot convert video frame to an image. pixel format: {frame.pixelFormat()}')
+ yield image.constBits().__int__(), image.sizeInBytes(), image.bytesPerLine(), image.width(), image.height()
+
+
class QEQRParser(QObject):
_logger = get_logger(__name__)
- busyChanged = pyqtSignal()
dataChanged = pyqtSignal()
- sizeChanged = pyqtSignal()
videoSinkChanged = pyqtSignal()
def __init__(self, text=None, parent=None):
@@ -40,8 +78,10 @@ def __init__(self, text=None, parent=None):
self._text = text
self.qrreader = get_qr_reader()
- if not self.qrreader:
- raise Exception(_("The platform QR detection library is not available."))
+
+ self._decoder = ThreadPoolExecutor(max_workers=1, thread_name_prefix='QEQRParser')
+ decoder = self._decoder
+ self.destroyed.connect(lambda: decoder.shutdown(wait=False))
@pyqtProperty(QVideoSink, notify=videoSinkChanged)
def videoSink(self):
@@ -50,72 +90,66 @@ def videoSink(self):
@videoSink.setter
def videoSink(self, sink: QVideoSink):
if self._video_sink != sink:
+ if self._video_sink is not None:
+ self._video_sink.videoFrameChanged.disconnect(self.onVideoFrame)
self._video_sink = sink
- self._video_sink.videoFrameChanged.connect(self.onVideoFrame)
+ if self._video_sink is not None:
+ self._video_sink.videoFrameChanged.connect(self.onVideoFrame)
+ self.videoSinkChanged.emit()
def onVideoFrame(self, videoframe):
if self._busy or self._data:
return
- self._busy = True
- self.busyChanged.emit()
-
if not videoframe.isValid():
self._logger.debug('invalid frame')
return
- async def co_parse_qr(frame):
- image = frame.toImage()
- self._parseQR(image)
-
- asyncio.run_coroutine_threadsafe(co_parse_qr(videoframe), get_asyncio_loop())
-
- def _parseQR(self, image: QImage):
- self._size = min(image.width(), image.height())
- self.sizeChanged.emit()
- img_crop_rect = self._get_crop(image, self._size)
- frame_cropped = image.copy(img_crop_rect)
-
- # Convert to Y800 / GREY FourCC (single 8-bit channel)
- frame_y800 = frame_cropped.convertToFormat(QImage.Format.Format_Grayscale8)
- self.frame_id = 0
- # Read the QR codes from the frame
- self.qrreader_res = self.qrreader.read_qr_code(
- frame_y800.constBits().__int__(),
- frame_y800.sizeInBytes(),
- frame_y800.bytesPerLine(),
- frame_y800.width(),
- frame_y800.height(),
- self.frame_id
- )
-
- if len(self.qrreader_res) > 0:
- result = self.qrreader_res[0]
- self._data = result
- self.dataChanged.emit()
-
- self._busy = False
- self.busyChanged.emit()
-
- def _get_crop(self, image: QImage, scan_size: int) -> QRect:
- """Returns a QRect that is scan_size x scan_size in the middle of the resolution"""
- scan_pos_x = (image.width() - scan_size) // 2
- scan_pos_y = (image.height() - scan_size) // 2
- return QRect(scan_pos_x, scan_pos_y, scan_size, scan_size)
+ self._busy = True
- @pyqtProperty(bool, notify=busyChanged)
- def busy(self):
- return self._busy
+ # keep a reference to frame data on python side, otherwise Qt can free it after the function returns
+ frame = QVideoFrame(videoframe)
+ self._decoder.submit(self._decode_frame, frame)
+
+ @pyqtSlot('QVariant')
+ def decodeFrameOneShot(self, frame: 'QVideoFrame'):
+ """
+ Decodes frame even if the decoder is already busy (with a preview frame)
+ Allows a manual 'tap-to-focus'-like image capture for devices without autofocus:
+ https://github.com/spesmilo/electrum/issues/10383
+ """
+ if self._data or not frame.isValid():
+ return
+ self._logger.debug(f"one-shot frame decode")
+ self._decoder.submit(self._decode_frame, QVideoFrame(frame))
- @pyqtProperty(int, notify=sizeChanged)
- def size(self):
- return self._size
+ def _decode_frame(self, frame: 'QVideoFrame'):
+ # Runs on the worker thread. Signals emitted here are queued to the GUI thread.
+ try:
+ with _luma_image(frame) as (buffer, buffer_size, stride, width, height):
+ # only the centre square of the image is scanned
+ size = min(width, height)
+ results = self.qrreader.read_qr_code(
+ buffer, buffer_size, stride, width, height,
+ crop=((width - size) // 2, (height - size) // 2, size, size),
+ )
+ if results:
+ try:
+ self._data = results[0].data.decode('utf-8')
+ except UnicodeDecodeError:
+ self._logger.warning('ignoring QR code: payload is not valid UTF-8 text')
+ else:
+ self.dataChanged.emit()
+ except Exception as e:
+ if isinstance(e, RuntimeError) and sip.isdeleted(self):
+ return # the parser was destroyed while decoding
+ self._logger.exception('Error parsing QR frame')
+ finally:
+ self._busy = False
@pyqtProperty(str, notify=dataChanged)
def data(self):
- if not self._data:
- return ''
- return self._data.data
+ return self._data or ''
@pyqtSlot()
def reset(self):
### electrum/gui/qml/qeqrscanner.py
@@ -1,93 +0,0 @@
-import os
-
-from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Qt
-from PyQt6.QtGui import QGuiApplication
-
-from electrum.gui.qml.qeconfig import QEConfig
-from electrum.gui.qml.qetypes import QEBytes
-from electrum.util import send_exception_to_crash_reporter
-from electrum.logging import get_logger
-from electrum.i18n import _
-
-
-if 'ANDROID_DATA' in os.environ:
- from jnius import autoclass
- from android import activity
-
- jpythonActivity = autoclass('org.kivy.android.PythonActivity').mActivity
- jString = autoclass('java.lang.String')
- jIntent = autoclass('android.content.Intent')
-
-
-class QEQRScanner(QObject):
- REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY = 30368 # random 16 bit int
-
- _logger = get_logger(__name__)
-
- foundText = pyqtSignal(str)
- foundBinary = pyqtSignal(QEBytes)
-
- finished = pyqtSignal()
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self._hint = _("Scan a QR code.")
- self.finished.connect(self._unbind, Qt.ConnectionType.QueuedConnection)
-
- self.destroyed.connect(lambda: self.on_destroy())
-
- def on_destroy(self):
- self._unbind()
-
- @pyqtProperty(str)
- def hint(self):
- return self._hint
-
- @hint.setter
- def hint(self, v: str):
- self._hint = v
-
- @pyqtSlot()
- def open(self):
- if 'ANDROID_DATA' not in os.environ:
- self._scan_qr_non_android()
- return
- jSimpleScannerActivity = autoclass("org.electrum.qr.SimpleScannerActivity")
- intent = jIntent(jpythonActivity, jSimpleScannerActivity)
- intent.putExtra(jIntent.EXTRA_TEXT, jString(self._hint))
- intent.putExtra('allow_screenshots', QEConfig.instance.config.GUI_QML_ALWAYS_ALLOW_SCREENSHOTS)
-
- activity.bind(on_activity_result=self.on_qr_activity_result)
- jpythonActivity.startActivityForResult(intent, self.REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY)
-
- @pyqtSlot()
- def close(self):
- # no-op to prevent qml type error
- pass
-
- def on_qr_activity_result(self, requestCode, resultCode, intent):
- if requestCode != self.REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY:
- self._logger.warning(f"got activity result with invalid {requestCode=}")
- return
- try:
- if resultCode == -1: # RESULT_OK:
- if (contents := intent.getStringExtra(jString("text"))) is not None:
- self.foundText.emit(contents)
- if (contents := intent.getByteArrayExtra(jString("binary"))) is not None:
- self._binary_content = QEBytes(bytes(contents.tolist()))
- self.foundBinary.emit(self._binary_content)
- except Exception as e: # exc would otherwise get lost
- send_exception_to_crash_reporter(e)
- finally:
- self.finished.emit()
-
- @pyqtSlot()
- def _unbind(self):
- if 'ANDROID_DATA' in os.environ:
- activity.unbind(on_activity_result=self.on_qr_activity_result)
-
- def _scan_qr_non_android(self):
- data = QGuiApplication.clipboard().text()
- self.foundText.emit(data)
- self.finished.emit()
- return
### electrum/gui/qt/qrreader/__init__.py
@@ -2,40 +2,23 @@
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
#
-# We have two toolchains to scan qr codes:
-# 1. access camera via QtMultimedia, take picture, feed picture to zbar
-# 2. let zbar handle whole flow (including accessing the camera)
+# Camera capture uses QtMultimedia on all desktop platforms; image decoding
+# uses zxing-cpp. QtMultimedia is imported lazily as some distributions package
+# it separately from PyQt6 (e.g. python3-pyqt6.qtmultimedia on Debian).
#
-# notes:
-# - zbar needs to be compiled with platform-dependent extra config options to be able
-# to access the camera
-# - zbar fails to access the camera on macOS
-# - qtmultimedia seems to support more cameras on Windows than zbar
-# - qtmultimedia is often not packaged with PyQt
-# in particular, on debian, you need both "python3-pyqt6" and "python3-pyqt6.qtmultimedia"
-# - older versions of qtmultimedia don't seem to work reliably
-#
-# Considering the above, we use QtMultimedia for Windows and macOS, as there
-# most users run our binaries where we can make sure the packaged versions work well.
-# On Linux where many people run from source, we use zbar.
-#
-# Note: this module is safe to import on all platforms.
+# Note: this module does not require QtMultimedia to import.
-import sys
from typing import Callable, Optional, TYPE_CHECKING, Mapping, Sequence
-from PyQt6.QtWidgets import QMessageBox, QWidget
-from PyQt6.QtGui import QImage, QPainter, QColor
-from PyQt6.QtCore import QRect, QCoreApplication
+from PyQt6.QtWidgets import QWidget
+from PyQt6.QtGui import QImage
+from PyQt6.QtCore import QCoreApplication
from PyQt6 import QtCore
from electrum.i18n import _
-from electrum.util import UserFacingException
from electrum.logging import get_logger
from electrum.qrreader import get_qr_reader, QrCodeResult, MissingQrDetectionLib
-from electrum.gui.qt.util import MessageBoxMixin, custom_message_box
-
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
@@ -66,79 +49,31 @@ def do_scan():
def scan_qr_from_image(image: QImage) -> Sequence[QrCodeResult]:
"""Might raise exception: MissingQrDetectionLib."""
+ if image.isNull():
+ return []
qr_reader = get_qr_reader()
+ image_y800 = image.convertToFormat(QImage.Format.Format_Grayscale8)
+ return qr_reader.read_qr_code(
+ image_y800.constBits().__int__(),
+ image_y800.sizeInBytes(),
+ image_y800.bytesPerLine(),
+ image_y800.width(),
+ image_y800.height(),
+ )
- for attempt in range(4):
- image_y800 = image.convertToFormat(QImage.Format.Format_Grayscale8)
- res = qr_reader.read_qr_code(
- image_y800.constBits().__int__(),
- image_y800.sizeInBytes(),
- image_y800.bytesPerLine(),
- image_y800.width(),
- image_y800.height(),
- )
- if res:
- break
- # zbar doesn't like qr codes that are too large in relation to the whole image
- image = _reduce_qr_code_density(image)
- return res
-
-def _reduce_qr_code_density(image: QImage) -> QImage:
- """ Reduces the size of the qr code relative to the whole image. """
- new_image = QImage(image.width(), image.height(), QImage.Format.Format_RGB32)
- new_image.fill(QColor(255, 255, 255)) # Fill white
-
- painter = QPainter(new_image)
- source_rect = QRect(0, 0, image.width(), image.height())
- target_rect = QRect(0, 0, int(image.width() * 0.75), int(image.height() * 0.75))
- painter.drawImage(target_rect, image, source_rect)
- painter.end()
-
- return new_image
def find_system_cameras() -> Mapping[str, str]:
"""Returns a camera_description -> camera_path map."""
- if sys.platform == 'darwin' or sys.platform in ('windows', 'win32'):
- try:
- from .qtmultimedia import find_system_cameras
- except (ImportError, RuntimeError) as e:
- _logger.exception('error importing .qtmultimedia')
- return {}
- else:
- return find_system_cameras()
- else: # desktop Linux and similar
- from electrum import qrscanner
- return qrscanner.find_system_cameras()
+ try:
+ from .qtmultimedia import find_system_cameras
+ except (ImportError, RuntimeError):
+ _logger.exception('error importing .qtmultimedia')
+ return {}
+ return find_system_cameras()
# --- Internals below (not part of external API)
-def _scan_qrcode_using_zbar(
- *,
- parent: Optional[QWidget],
- config: 'SimpleConfig',
- callback: Callable[[bool, str, Optional[str]], None],
-) -> None:
- from electrum import qrscanner
- data = None
- try:
- data = qrscanner.scan_barcode(config.get_video_device())
- except UserFacingException as e:
- success = False
- error = str(e)
- except BaseException as e:
- _logger.exception('camera error')
- success = False
- error = repr(e)
- else:
- success = True
- error = ""
- if data is None:
- # probably user cancelled
- success = False
- callback(success, error, data)
-
-
# Use a global to prevent multiple QR dialogs created simultaneously
_qr_dialog = None
@@ -152,15 +87,9 @@ def _scan_qrcode_using_qtmultimedia(
try:
from .qtmultimedia import QrReaderCameraDialog, CameraError
except (ImportError, RuntimeError) as e:
- icon = QMessageBox.Icon.Warning
- title = _("QR Reader Error")
- message = _("QR reader failed to load. This may happen if "
- "you are using an older version of PyQt.") + "\n\n" + str(e)
+ message = _("QR scanning requires Qt Multimedia, which failed to load:") + "\n\n" + str(e)
_logger.exception(message)
- 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)
+ callback(False, message, None)
return
global _qr_dialog
@@ -181,12 +110,19 @@ def _on_qr_reader_finished(success: bool, error: str, data):
_qr_dialog.qr_finished.connect(_on_qr_reader_finished)
_qr_dialog.start_scan(config.get_video_device())
except (MissingQrDetectionLib, CameraError) as e:
- _qr_dialog = None
- callback(False, str(e), None)
+ error = str(e)
except Exception as e:
_logger.exception('camera error')
+ error = repr(e)
+ else:
+ return
+
+ if _qr_dialog:
+ _qr_dialog.qr_finished.disconnect(_on_qr_reader_finished)
+ _qr_dialog._boilerplate_cleanup()
+ _qr_dialog.deleteLater()
_qr_dialog = None
- callback(False, repr(e), None)
+ callback(False, error, None)
def _scan_qrcode_from_camera(
@@ -200,10 +136,7 @@ def _scan_qrcode_from_camera(
if not _has_camera_permission():
callback(False, _("Missing camera permission."), None)
return
- if sys.platform == 'darwin' or sys.platform in ('windows', 'win32'):
- _scan_qrcode_using_qtmultimedia(parent=parent, config=config, callback=callback)
- else: # desktop Linux and similar
- _scan_qrcode_using_zbar(parent=parent, config=config, callback=callback)
+ _scan_qrcode_using_qtmultimedia(parent=parent, config=config, callback=callback)
def _has_camera_permission() -> bool:
@@ -213,4 +146,3 @@ def _has_camera_permission() -> bool:
app = QCoreApplication.instance()
permission_status = app.checkPermission(QtCore.QCameraPermission())
return permission_status == QtCore.Qt.PermissionStatus.Granted
-
### electrum/gui/qt/qrreader/qtmultimedia/camera_dialog.py
@@ -70,6 +70,9 @@ class QrReaderCameraDialog(Logger, MessageBoxMixin, QDialog):
def __init__(self, parent: Optional[QWidget], *, config: SimpleConfig):
''' Note: make sure parent is a "top_level_window()" as per
MessageBoxMixin API else bad things can happen on macOS. '''
+
+ self.qrreader = get_qr_reader() # check the decoder before creating a parent-owned dialog
+
QDialog.__init__(self, parent=parent)
Logger.__init__(self)
@@ -90,9 +93,6 @@ def __init__(self, parent: Optional[QWidget], *, config: SimpleConfig):
self.config = config
- # Try to get the QR reader for this system
- self.qrreader = get_qr_reader()
-
# Set up the window, add the maximize button
flags = self.windowFlags()
flags = flags | Qt.WindowType.WindowMaximizeButtonHint
@@ -220,9 +220,8 @@ def start_scan(self, device: str = ''):
self.logger.info(f"chosen camera format: {res.width()}x{res.height()}")
self.camera.setCameraFormat(camera_format)
- self.camera.start()
-
self.open()
+ self.camera.start()
def _set_resolution(self, resolution: QSize):
self.resolution = resolution
@@ -239,7 +238,11 @@ def _set_resolution(self, resolution: QSize):
self.crop_blur_effect.setCrop(self.qr_crop)
def _on_camera_error(self, error: QCamera.Error, error_str: str):
+ if error == QCamera.Error.NoError or self._ok_done:
+ return
self.logger.info(f"QCamera error: {error}. {error_str}")
+ self._error_message = error_str
+ self.reject()
def accept(self):
self._ok_done = True # immediately blocks further processing
@@ -258,18 +261,26 @@ def _close_camera(self):
if self.camera:
self.camera.stop()
self.camera = None
+ if self.media_capture_session:
+ self.media_capture_session.setVideoSink(None)
+ self.media_capture_session.setCamera(None)
+ self.media_capture_session = None
def _on_finished(self, code):
- res = ( (code == QDialog.DialogCode.Accepted
- and self.validator_res and self.validator_res.accepted
- and self.validator_res.simple_result)
- or '' )
+ accepted = code == QDialog.DialogCode.Accepted
+ res = ''
+ if accepted and self.validator_res and self.validator_res.accepted and self.validator_res.simple_result:
+ try:
+ res = self.validator_res.simple_result.decode('utf-8')
+ except UnicodeDecodeError:
+ accepted = False
+ self._error_message = _("The QR code does not contain valid UTF-8 text.")
self.validator = None
- self.logger.info(f'closed {res}')
+ self.logger.info(f'closed, got result of {len(res)} chars')
- self.qr_finished.emit(code == QDialog.DialogCode.Accepted, self._error_message, res)
+ self.qr_finished.emit(accepted, self._error_message, res)
def _on_frame_available(self, frame: QImage):
if self._ok_done:
@@ -295,14 +306,18 @@ def _on_frame_available(self, frame: QImage):
frame_y800 = frame_cropped.convertToFormat(QImage.Format.Format_Grayscale8)
# Read the QR codes from the frame
- self.qrreader_res = self.qrreader.read_qr_code(
- frame_y800.constBits().__int__(),
- frame_y800.sizeInBytes(),
- frame_y800.bytesPerLine(),
- frame_y800.width(),
- frame_y800.height(),
- self.frame_id,
+ try:
+ self.qrreader_res = self.qrreader.read_qr_code(
+ frame_y800.constBits().__int__(),
+ frame_y800.sizeInBytes(),
+ frame_y800.bytesPerLine(),
+ frame_y800.width(),
+ frame_y800.height(),
+ self.frame_id,
)
+ except Exception:
+ self.logger.exception('Failed to decode QR frame')
+ self.qrreader_res = []
# Call the validator to see if the scanned results are acceptable
self.validator_res = self.validator.validate_results(self.qrreader_res)
### electrum/gui/qt/qrreader/qtmultimedia/validator.py
@@ -46,7 +46,7 @@ def __init__(self):
self.message: str | None = None
self.message_color: QColor | None = None
- self.simple_result : str | None = None
+ self.simple_result : bytes | None = None
self.result_usable: Dict[QrCodeResult, bool] = {}
self.result_colors: Dict[QrCodeResult, QColor] = {}
@@ -163,6 +163,6 @@ def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResu
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
+ res.simple_result = (results and results[0].data) or b'' # hack added by calin just to take the first one
return res
### electrum/gui/qt/qrreader/qtmultimedia/video_overlay.py
@@ -130,8 +130,9 @@ def toqp(point):
# because otherwise the text could be flipped. We only use transform_flip
# to map the center point of the result.
painter.setWorldTransform(transform, False)
+ text = res.data.decode('utf-8', errors='replace')
font_metrics = painter.fontMetrics()
- data_metrics = QSize(font_metrics.horizontalAdvance(res.data), font_metrics.capHeight())
+ data_metrics = QSize(font_metrics.horizontalAdvance(text), font_metrics.capHeight())
center_pos = toqp(res.center)
center_pos += self.crop.topLeft()
@@ -154,4 +155,4 @@ def toqp(point):
painter.drawPath(bg_rect_path)
painter.setPen(self.text_pen)
- painter.drawText(center_pos, res.data)
+ painter.drawText(center_pos, text)
### electrum/gui/qt/util.py
@@ -833,7 +833,11 @@ def input_qr_from_screenshot(
except UserFacingException as e:
show_error(str(e))
return
- data = scanned_qr.data
+ try:
+ data = scanned_qr.data.decode('utf-8')
+ except UnicodeDecodeError:
+ show_error(_("The QR code does not contain valid UTF-8 text."))
+ return
try:
if allow_multi:
text = self.text()
@@ -920,10 +924,11 @@ def input_qr_from_file(
show_error(_("More than one QR code was found in the image."))
return
- if len(scan_result) > 1:
- result_text = "\n".join([r.data for r in scan_result])
- else:
- result_text = scan_result[0].data
+ try:
+ result_text = "\n".join(r.data.decode('utf-8') for r in scan_result)
+ except UnicodeDecodeError:
+ show_error(_("The QR code does not contain valid UTF-8 text."))
+ return
try:
setText(result_text)
### electrum/qrreader/__init__.py
@@ -25,8 +25,9 @@
#
# A module, that, given an image (buffer), finds and decodes a QR code in it.
-from typing import Optional
+from typing import Mapping, Optional
+from ..i18n import _
from ..logging import get_logger
from .abstract_base import AbstractQrCodeReader, QrCodeResult
@@ -36,23 +37,21 @@
class MissingQrDetectionLib(RuntimeError):
- ''' Raised if we can't find zbar or whatever other platform lib
- we require to detect QR in image frames. '''
+ ''' Raised if the library required to detect QR codes is unavailable. '''
def get_qr_reader() -> AbstractQrCodeReader:
"""
- Get the Qr code reader for the current platform.
+ Get the QR code reader.
Might raise exception: MissingQrDetectionLib.
"""
- excs = []
try:
- from .zbar import ZbarQrCodeReader
- return ZbarQrCodeReader()
+ from .zxing import ZXingQrCodeReader
+ return ZXingQrCodeReader()
"""
# DEBUG CODE BELOW
# If you want to test this code on a platform that doesn't yet work or have
- # zbar, use the below...
+ # zxing-cpp, use the below...
class Fake(AbstractQrCodeReader):
def read_qr_code(self, buffer, buffer_size, dummy, width, height, frame_id = -1):
''' fake noop to test '''
@@ -61,9 +60,15 @@ def read_qr_code(self, buffer, buffer_size, dummy, width, height, frame_id = -1)
"""
except MissingLib as e:
_logger.exception("")
- excs.append(e)
+ raise MissingQrDetectionLib(_("The QR detection library is not available.") + f"\n{e}") from e
- raise MissingQrDetectionLib(f"The platform QR detection library is not available.\nerrors: {excs!r}")
+
+def version_info() -> Mapping[str, Optional[str]]:
+ from .zxing import LIBZXING, LIBZXING_VERSION
+ return {
+ "libZXing.path": LIBZXING._name if LIBZXING else None,
+ "libZXing.version": LIBZXING_VERSION,
+ }
# --- Internals below (not part of external API)
### electrum/qrreader/abstract_base.py
@@ -24,7 +24,7 @@
# SOFTWARE.
import ctypes
-from typing import List, Tuple
+from typing import List, Optional, Tuple
from abc import ABC, abstractmethod
QrCodePoint = Tuple[int, int]
@@ -34,9 +34,11 @@
class QrCodeResult():
"""
A detected QR code.
+ data holds the raw payload bytes, without any charset conversion:
+ text QR codes usually hold UTF-8, but the payload can also be binary.
"""
- def __init__(self, data: str, center: QrCodePoint, points: QrCodePointList):
- self.data: str = data
+ def __init__(self, data: bytes, center: QrCodePoint, points: QrCodePointList):
+ self.data: bytes = data
self.center: QrCodePoint = center
self.points: QrCodePointList = points
@@ -70,8 +72,11 @@ def interval(self) -> float:
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]:
+ width: int, height: int, frame_id: int = -1,
+ *, crop: Optional[Tuple[int, int, int, int]] = None) -> 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.
+ crop = (left, top, width, height) restricts the scan to that part of the image;
+ the positions of the results are then relative to it.
+ Returns a list of detected QR codes which includes their raw payload bytes and positions.
"""
### electrum/qrreader/zbar.py
@@ -1,184 +0,0 @@
-#!/usr/bin/env python3
-#
-# Electron Cash - lightweight Bitcoin client
-# Copyright (C) 2019 Axel Gembe <derago@gmail.com>
-#
-# 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 'ANDROID_DATA' in os.environ:
- LIBNAME = 'libzbar.so'
-elif 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
### electrum/qrreader/zxing.py
@@ -0,0 +1,172 @@
+# Copyright (C) 2026 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://opensource.org/licenses/mit-license.php
+
+import ctypes
+import os
+import sys
+from typing import List, Optional, Tuple
+
+from . import MissingLib
+from .abstract_base import AbstractQrCodeReader, QrCodeResult
+from ..logging import get_logger
+
+
+_logger = get_logger(__name__)
+
+
+class _Point(ctypes.Structure):
+ _fields_ = [('x', ctypes.c_int), ('y', ctypes.c_int)]
+
+
+class _Position(ctypes.Structure):
+ _fields_ = [(corner, _Point) for corner in ('topLeft', 'topRight', 'bottomRight', 'bottomLeft')]
+
+
+if sys.platform == 'darwin':
+ LIBNAME = 'libZXing.dylib'
+elif sys.platform == 'win32':
+ LIBNAME = 'ZXing.dll'
+else:
+ LIBNAME = 'libZXing.so'
+
+# Pin ZXING to a major version to prevent the API from silently changing
+# e.g. when loading a system provided library or bumping the hash pin
+ZXING_MAJOR_VERSION = 3
+
+
+def _check_version(lib: ctypes.CDLL) -> str:
+ """Returns the version of an API-compatible zxing-cpp, raises ValueError otherwise."""
+ lib.ZXing_Version.restype = ctypes.c_char_p # static string, not owned by the caller
+ lib.ZXing_Version.argtypes = []
+ version = (lib.ZXing_Version() or b'').decode('ascii', errors='replace')
+ if version.split('.')[0] != str(ZXING_MAJOR_VERSION):
+ raise ValueError(f'unsupported zxing-cpp version {version!r}, need {ZXING_MAJOR_VERSION}.x')
+ return version
+
+
+try:
+ try:
+ LIBZXING = ctypes.cdll.LoadLibrary(os.path.join(os.path.dirname(__file__), '..', LIBNAME))
+ except OSError:
+ # Fall back to a system-wide install, e.g. when running from source.
+ LIBZXING = ctypes.cdll.LoadLibrary(LIBNAME)
+ LIBZXING_VERSION = _check_version(LIBZXING)
+
+ # ZXingC.h from the pinned zxing-cpp build. Owning pointers must remain
+ # pointers (not c_char_p, which copies strings and loses the allocation).
+ signatures = {
+ 'ZXing_ImageView_new_checked': (ctypes.c_void_p, [ctypes.c_void_p] + [ctypes.c_int] * 6),
+ 'ZXing_ImageView_crop': (None, [ctypes.c_void_p] + [ctypes.c_int] * 4),
+ 'ZXing_ImageView_delete': (None, [ctypes.c_void_p]),
+ 'ZXing_ReaderOptions_new': (ctypes.c_void_p, []),
+ 'ZXing_ReaderOptions_delete': (None, [ctypes.c_void_p]),
+ 'ZXing_ReaderOptions_setFormats': (None, [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int), ctypes.c_int]),
+ 'ZXing_BarcodeFormatFromString': (ctypes.c_int, [ctypes.c_char_p]),
+ 'ZXing_ReadBarcodes': (ctypes.c_void_p, [ctypes.c_void_p, ctypes.c_void_p]),
+ 'ZXing_Barcodes_size': (ctypes.c_int, [ctypes.c_void_p]),
+ 'ZXing_Barcodes_at': (ctypes.c_void_p, [ctypes.c_void_p, ctypes.c_int]),
+ 'ZXing_Barcodes_delete': (None, [ctypes.c_void_p]),
+ 'ZXing_Barcode_bytes': (ctypes.c_void_p, [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)]),
+ 'ZXing_Barcode_position': (_Position, [ctypes.c_void_p]),
+ 'ZXing_LastErrorMsg': (ctypes.c_void_p, []),
+ 'ZXing_free': (None, [ctypes.c_void_p]),
+ }
+ for name, (restype, argtypes) in signatures.items():
+ function = getattr(LIBZXING, name)
+ function.restype = restype
+ function.argtypes = argtypes
+except (OSError, AttributeError, ValueError):
+ _logger.exception('Failed to load zxing-cpp C API')
+ LIBZXING = LIBZXING_VERSION = None
+
+
+class ZXingQrCodeReader(AbstractQrCodeReader):
+ """Decode grayscale image buffers using zxing-cpp's C API."""
+
+ def __init__(self):
+ self._options = None
+ if LIBZXING is None:
+ raise MissingLib('zxing-cpp library with C API not found')
+ self._lib = LIBZXING
+ self._options = self._lib.ZXing_ReaderOptions_new()
+ if not self._options:
+ raise self._error()
+ qr_format = ctypes.c_int(self._lib.ZXing_BarcodeFormatFromString(b'QRCode'))
+ if qr_format.value == 0xffff: # ZXing_BarcodeFormat_Invalid
+ raise MissingLib('zxing-cpp does not support the QRCode barcode format')
+ self._lib.ZXing_ReaderOptions_setFormats(self._options, ctypes.byref(qr_format), 1)
+
+ def __del__(self):
+ if self._options:
+ self._lib.ZXing_ReaderOptions_delete(self._options)
+
+ def _error(self) -> RuntimeError:
+ message = self._lib.ZXing_LastErrorMsg()
+ try:
+ text = ctypes.string_at(message).decode('utf-8', errors='replace') if message else 'Unknown error'
+ return RuntimeError(f'zxing-cpp: {text}')
+ finally:
+ self._lib.ZXing_free(message)
+
+ def read_qr_code(
+ self,
+ buffer: ctypes.c_void_p,
+ buffer_size: int,
+ rowlen_bytes: int,
+ width: int,
+ height: int,
+ frame_id: int = -1,
+ *,
+ crop: Optional[Tuple[int, int, int, int]] = None,
+ ) -> List[QrCodeResult]:
+ # Reject invalid geometry before Python integers are narrowed to C ints.
+ if not buffer or any(n <= 0 or n > 0x7fffffff for n in (buffer_size, rowlen_bytes, width, height)):
+ raise ValueError('Invalid QR image buffer or dimensions')
+ if rowlen_bytes < width or height * rowlen_bytes > buffer_size:
+ raise ValueError('QR image buffer is too small for its dimensions and stride')
+ if crop is not None:
+ left, top, crop_width, crop_height = crop
+ if not (0 <= left < width and 0 <= top < height
+ and 0 < crop_width <= width - left and 0 < crop_height <= height - top):
+ raise ValueError('QR crop rectangle is not inside the image')
+
+ image = self._lib.ZXing_ImageView_new_checked(
+ buffer,
+ buffer_size,
+ width,
+ height,
+ 0x01000000, # ZXing_ImageFormat_Lum
+ rowlen_bytes,
+ 1,
+ )
+
+ if not image:
+ raise self._error()
+ barcodes = None
+ try:
+ if crop is not None:
+ self._lib.ZXing_ImageView_crop(image, left, top, crop_width, crop_height)
+ barcodes = self._lib.ZXing_ReadBarcodes(image, self._options)
+ if not barcodes:
+ raise self._error()
+ results = []
+ for i in range(self._lib.ZXing_Barcodes_size(barcodes)):
+ barcode = self._lib.ZXing_Barcodes_at(barcodes, i) # borrowed from barcodes
+ length = ctypes.c_int()
+ data = self._lib.ZXing_Barcode_bytes(barcode, ctypes.byref(length))
+ try:
+ if not data:
+ raise MemoryError('zxing-cpp could not allocate the QR payload')
+ payload = ctypes.string_at(data, length.value) # raw bytes, no charset conversion
+ finally:
+ self._lib.ZXing_free(data)
+ position = self._lib.ZXing_Barcode_position(barcode)
+ points = [(getattr(position, corner).x, getattr(position, corner).y)
+ for corner, _type in _Position._fields_]
+ center = (sum(x for x, _ in points) // 4, sum(y for _, y in points) // 4)
+ results.append(QrCodeResult(payload, center, points))
+ return results
+ finally:
+ self._lib.ZXing_Barcodes_delete(barcodes)
+ self._lib.ZXing_ImageView_delete(image)
### electrum/qrscanner.py
@@ -1,112 +0,0 @@
-#!/usr/bin/env python
-#
-# Electrum - lightweight Bitcoin client
-# Copyright (C) 2015 Thomas Voegtlin
-#
-# 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.
-#
-# A QR scanner that uses zbar (via ctypes)
-# - to access the camera,
-# - and to find and decode QR codes (visible in the live feed).
-
-import os
-import sys
-import ctypes
-from typing import Optional, Mapping
-
-from .util import UserFacingException
-from .i18n import _
-from .logging import get_logger
-
-
-_logger = get_logger(__name__)
-
-
-if sys.platform == 'darwin':
- name = 'libzbar.0.dylib'
-elif sys.platform in ('windows', 'win32'):
- name = 'libzbar-0.dll'
-else:
- name = 'libzbar.so.0'
-
-try:
- libzbar = ctypes.cdll.LoadLibrary(os.path.join(os.path.dirname(__file__), name))
-except BaseException as e1:
- try:
- libzbar = ctypes.cdll.LoadLibrary(name)
- except BaseException as e2:
- libzbar = None
- _logger.error(f"failed to load zbar. exceptions: {[e1,e2]!r}")
-
-
-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
- libzbar.zbar_processor_create.restype = ctypes.POINTER(ctypes.c_int)
- libzbar.zbar_processor_get_results.restype = ctypes.POINTER(ctypes.c_int)
- libzbar.zbar_symbol_set_first_symbol.restype = ctypes.POINTER(ctypes.c_int)
- # libzbar.zbar_set_verbosity(100) # verbose logs for debugging
- proc = libzbar.zbar_processor_create(threaded)
- libzbar.zbar_processor_request_size(proc, 640, 480)
- if libzbar.zbar_processor_init(proc, device.encode('utf-8'), display) != 0:
- raise UserFacingException(
- _("Cannot start QR scanner: initialization failed.") + "\n" +
- _("Make sure you have a camera connected and enabled."))
- libzbar.zbar_processor_set_visible(proc)
- if libzbar.zbar_process_one(proc, timeout):
- symbols = libzbar.zbar_processor_get_results(proc)
- else:
- symbols = None
- libzbar.zbar_processor_destroy(proc)
- if symbols is None:
- return
- if not libzbar.zbar_symbol_set_get_size(symbols):
- return
- symbol = libzbar.zbar_symbol_set_first_symbol(symbols)
- data = libzbar.zbar_symbol_get_data(symbol)
- return data.decode('utf8')
-
-
-def find_system_cameras() -> Mapping[str, str]:
- device_root = "/sys/class/video4linux"
- devices = {} # Name -> device
- if os.path.exists(device_root):
- for device in os.listdir(device_root):
- path = os.path.join(device_root, device, 'name')
- try:
- with open(path, encoding='utf-8') as f:
- name = f.read()
- except Exception:
- continue
- name = name.strip('\n')
- devices[name] = os.path.join("/dev", device)
- return devices
-
-
-def version_info() -> Mapping[str, Optional[str]]:
- return {
- "libzbar.path": libzbar._name if libzbar else None,
- }
-
-
-if __name__ == "__main__":
- print(scan_barcode())
### electrum/simple_config.py
@@ -902,7 +902,7 @@ def __setattr__(self, name, value):
'video_device', default='default', type_=str,
short_desc=lambda: _('Video Device'),
long_desc=lambda: (_("For scanning QR codes.") + "\n" +
- _("Install the zbar package to enable this.")),
+ _("QR scanning requires Qt Multimedia and zxing-cpp.")),
)
OPENALIAS_ID = ConfigVar(
'alias', default="", type_=str,Why this scored 35/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.