diff --git a/contrib/android/Dockerfile b/contrib/android/Dockerfile index 4e012d0cb..59b44a85b 100644 --- a/contrib/android/Dockerfile +++ b/contrib/android/Dockerfile @@ -190,7 +190,7 @@ RUN cd /opt \ && /opt/venv/bin/python3 -m pip install --no-build-isolation --no-dependencies -e . # install python-for-android -ENV P4A_CHECKOUT_COMMIT="0ab0d872e6c6b88ddc05b9c4ba6fcd3aa7921242" +ENV P4A_CHECKOUT_COMMIT="32a05cdedd41f0f569e9126fdfce23069c36fd9a" # ^ from branch electrum_20240930 (note: careful with force-pushing! see #8162) RUN cd /opt \ && git clone https://github.com/spesmilo/python-for-android \ diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index c064fb489..927870f1c 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -5,7 +5,8 @@ import os import sys import html import threading -from typing import TYPE_CHECKING, Set +from functools import partial +from typing import TYPE_CHECKING, Set, List, Optional, Callable from PyQt6.QtCore import (pyqtSlot, pyqtSignal, pyqtProperty, QObject, QT_VERSION_STR, PYQT_VERSION_STR, qInstallMessageHandler, QTimer, QSortFilterProxyModel) @@ -52,7 +53,7 @@ if TYPE_CHECKING: if 'ANDROID_DATA' in os.environ: from jnius import autoclass, cast - from android import activity + from android import activity, permissions jpythonActivity = autoclass('org.kivy.android.PythonActivity').mActivity jHfc = autoclass('android.view.HapticFeedbackConstants') @@ -88,6 +89,9 @@ class QEAppController(BaseCrashReporter, QObject): self._intent = '' self._secureWindow = False + # map of permissions and grant status _after_ asking user + self._permissions = {} # type: dict[str, bool] + # set up notification queue and notification_timer self.user_notification_queue = queue.Queue() self.user_notification_last_time = 0 @@ -124,10 +128,11 @@ class QEAppController(BaseCrashReporter, QObject): def on_wallet_usernotify(self, wallet, message): self.logger.debug(message) - self.user_notification_queue.put((wallet,message)) + self.user_notification_queue.put((wallet, message)) if not self.notification_timer.isActive(): self.logger.debug('starting app notification timer') self.notification_timer.start() + self.on_notification_timer() def on_notification_timer(self): if self.user_notification_queue.qsize() == 0: @@ -140,6 +145,13 @@ class QEAppController(BaseCrashReporter, QObject): return self.user_notification_last_time = now self.logger.info("Notifying GUI about new user notifications") + # request permission and defer notify until after permission request callback + # note: permission request is only shown to user once, so it is safe to request + # 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) + return try: wallet, message = self.user_notification_queue.get_nowait() self.userNotify.emit(str(wallet), message) @@ -148,8 +160,6 @@ class QEAppController(BaseCrashReporter, QObject): def doNotify(self, wallet_name, message): self.logger.debug(f'sending push notification to OS: {message=!r}') - # FIXME: this does not work on Android 13+. We would need to declare (in manifest) - # and also request-at-runtime android.permission.POST_NOTIFICATIONS. try: # TODO: lazy load not in UI thread please global notification @@ -173,6 +183,42 @@ class QEAppController(BaseCrashReporter, QObject): except Exception as e: self.logger.error(f'unable to bind intent: {repr(e)}') + @pyqtSlot(str, result=bool) + def hasPermission(self, permissionFqcn: str) -> bool: + if not self.isAndroid(): + return True + 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 + self.logger.debug(f'requesting {permissionFqcn=}') + permissions.request_permission( + permissionFqcn, + callback=partial(self.on_request_permissions_result, permissionFqcn, permission_result_cb) + ) + + def on_request_permissions_result( + self, + permission: str, + permission_result_cb: Optional[Callable[[bool], None]], + permissions: List[str], + grant_results: List[bool] + ): + self.logger.debug(f'on_request_permissions_result, len={len(permissions)}, p={repr(permissions)}, g={repr(grant_results)}') + grant_result = None + try: + grant_result = grant_results[permissions.index(permission)] + except ValueError: + pass + + if grant_result is not None: + self._permissions[permission] = grant_result + + if permission_result_cb: + permission_result_cb(grant_result) + def on_new_intent(self, intent): if not self._app_started: self._intent = intent