Fixed: push notifications on VPN connect/disconnect

This commit is contained in:
dranik
2026-05-04 23:38:27 +03:00
parent 009ca981d5
commit 6087375fb0
17 changed files with 238 additions and 17 deletions

View File

@@ -24,5 +24,8 @@
<string name="notificationSettingsDialogMessage">Для показа уведомлений необходимо включить уведомления в системных настройках</string> <string name="notificationSettingsDialogMessage">Для показа уведомлений необходимо включить уведомления в системных настройках</string>
<string name="openNotificationSettings">Открыть настройки уведомлений</string> <string name="openNotificationSettings">Открыть настройки уведомлений</string>
<string name="vpnStateEventChannelName">Уведомления о VPN</string>
<string name="vpnStateEventChannelDescription">Краткие оповещения при подключении и отключении VPN</string>
<string name="tvNoFileBrowser">Пожалуйста, установите приложение для просмотра файлов</string> <string name="tvNoFileBrowser">Пожалуйста, установите приложение для просмотра файлов</string>
</resources> </resources>

View File

@@ -24,5 +24,8 @@
<string name="notificationSettingsDialogMessage">To show notifications, you must enable notifications in the system settings</string> <string name="notificationSettingsDialogMessage">To show notifications, you must enable notifications in the system settings</string>
<string name="openNotificationSettings">Open notification settings</string> <string name="openNotificationSettings">Open notification settings</string>
<string name="vpnStateEventChannelName">VPN connection alerts</string>
<string name="vpnStateEventChannelDescription">Brief alerts when VPN connects or disconnects</string>
<string name="tvNoFileBrowser">Please install a file management utility to browse files</string> <string name="tvNoFileBrowser">Please install a file management utility to browse files</string>
</resources> </resources>

View File

@@ -1003,6 +1003,11 @@ class AmneziaActivity : QtActivity() {
@Suppress("unused") @Suppress("unused")
fun isNotificationPermissionGranted(): Boolean = applicationContext.isNotificationPermissionGranted() fun isNotificationPermissionGranted(): Boolean = applicationContext.isNotificationPermissionGranted()
@Suppress("unused")
fun showVpnStateNotification(title: String, message: String) {
ServiceNotification.showVpnStateEvent(applicationContext, title, message)
}
@Suppress("unused") @Suppress("unused")
fun requestNotificationPermission() { fun requestNotificationPermission() {
val shouldShowPreRequest = shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) val shouldShowPreRequest = shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)

View File

@@ -26,6 +26,9 @@ private const val OLD_NOTIFICATION_CHANNEL_ID: String = "org.amnezia.vpn.notific
private const val NOTIFICATION_CHANNEL_ID: String = "org.amnezia.vpn.notifications" private const val NOTIFICATION_CHANNEL_ID: String = "org.amnezia.vpn.notifications"
const val NOTIFICATION_ID = 1337 const val NOTIFICATION_ID = 1337
const val VPN_STATE_EVENT_NOTIFICATION_ID = 1338
private const val VPN_STATE_EVENT_CHANNEL_ID = "org.amnezia.vpn.vpn_state_events"
private const val GET_ACTIVITY_REQUEST_CODE = 0 private const val GET_ACTIVITY_REQUEST_CODE = 0
private const val CONNECT_REQUEST_CODE = 1 private const val CONNECT_REQUEST_CODE = 1
private const val DISCONNECT_REQUEST_CODE = 2 private const val DISCONNECT_REQUEST_CODE = 2
@@ -162,8 +165,42 @@ class ServiceNotification(private val context: Context) {
.setDescription(context.resources.getString(R.string.notificationChannelDescription)) .setDescription(context.resources.getString(R.string.notificationChannelDescription))
.build() .build()
) )
createNotificationChannel(
Builder(VPN_STATE_EVENT_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setShowBadge(false)
.setSound(null, null)
.setVibrationEnabled(false)
.setLightsEnabled(false)
.setName(context.getString(R.string.vpnStateEventChannelName))
.setDescription(context.getString(R.string.vpnStateEventChannelDescription))
.build()
)
} }
} }
/** Brief alert when VPN connects or disconnects (invoked from Qt via AmneziaActivity). */
fun showVpnStateEvent(context: Context, title: String, message: String) {
if (!context.isNotificationPermissionGranted()) return
val nm = NotificationManagerCompat.from(context)
val notification = NotificationCompat.Builder(context, VPN_STATE_EVENT_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_amnezia_round)
.setContentTitle(title)
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(
PendingIntent.getActivity(
context,
GET_ACTIVITY_REQUEST_CODE,
Intent(context, AmneziaActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
)
.build()
nm.notify(VPN_STATE_EVENT_NOTIFICATION_ID, notification)
}
} }
} }

View File

@@ -31,6 +31,7 @@ set(LIBS ${LIBS}
set(HEADERS ${HEADERS} set(HEADERS ${HEADERS}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/macos/macos_ne_vpn_notification.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.h
@@ -42,6 +43,7 @@ set_source_files_properties(${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_contro
set(SOURCES ${SOURCES} set(SOURCES ${SOURCES}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/macos/macos_ne_vpn_notification.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.mm

View File

@@ -84,6 +84,10 @@ if(NOT ANDROID)
set(HEADERS ${HEADERS} set(HEADERS ${HEADERS}
${CLIENT_ROOT_DIR}/ui/utils/notificationHandler.h ${CLIENT_ROOT_DIR}/ui/utils/notificationHandler.h
) )
else()
set(HEADERS ${HEADERS}
${CLIENT_ROOT_DIR}/platforms/android/android_notificationhandler.h
)
endif() endif()
set(SOURCES ${SOURCES} set(SOURCES ${SOURCES}
@@ -174,6 +178,10 @@ if(NOT ANDROID)
set(SOURCES ${SOURCES} set(SOURCES ${SOURCES}
${CLIENT_ROOT_DIR}/ui/utils/notificationHandler.cpp ${CLIENT_ROOT_DIR}/ui/utils/notificationHandler.cpp
) )
else()
set(SOURCES ${SOURCES}
${CLIENT_ROOT_DIR}/platforms/android/android_notificationhandler.cpp
)
endif() endif()
set(COMMON_FILES_H set(COMMON_FILES_H

View File

@@ -5,9 +5,7 @@
#include <QQmlContext> #include <QQmlContext>
#include <QThread> #include <QThread>
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) #include "ui/utils/systemTrayNotificationHandler.h"
#include "ui/utils/systemTrayNotificationHandler.h"
#endif
#include "ui/controllers/api/subscriptionUiController.h" #include "ui/controllers/api/subscriptionUiController.h"
#include "ui/controllers/api/apiNewsUiController.h" #include "ui/controllers/api/apiNewsUiController.h"
@@ -141,9 +139,7 @@ private:
SecureServersRepository* m_serversRepository; SecureServersRepository* m_serversRepository;
SecureAppSettingsRepository* m_appSettingsRepository; SecureAppSettingsRepository* m_appSettingsRepository;
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
NotificationHandler* m_notificationHandler; NotificationHandler* m_notificationHandler;
#endif
QMetaObject::Connection m_reloadConfigErrorOccurredConnection; QMetaObject::Connection m_reloadConfigErrorOccurredConnection;

View File

@@ -407,9 +407,12 @@ void CoreSignalHandlers::initNotificationHandler()
&ConnectionUiController::closeConnection); &ConnectionUiController::closeConnection);
connect(m_coreController, &CoreController::translationsUpdated, m_coreController->m_notificationHandler, &NotificationHandler::onTranslationsUpdated); connect(m_coreController, &CoreController::translationsUpdated, m_coreController->m_notificationHandler, &NotificationHandler::onTranslationsUpdated);
auto* trayHandler = qobject_cast<SystemTrayNotificationHandler*>(m_coreController->m_notificationHandler); #if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
connect(m_coreController, &CoreController::websiteUrlChanged, trayHandler, &SystemTrayNotificationHandler::updateWebsiteUrl); if (auto *trayHandler = qobject_cast<SystemTrayNotificationHandler *>(m_coreController->m_notificationHandler)) {
#endif connect(m_coreController, &CoreController::websiteUrlChanged, trayHandler, &SystemTrayNotificationHandler::updateWebsiteUrl);
}
#endif
#endif
} }
void CoreSignalHandlers::initUpdateFoundHandler() void CoreSignalHandlers::initUpdateFoundHandler()

View File

@@ -307,6 +307,16 @@ void AndroidController::requestNotificationPermission()
callActivityMethod("requestNotificationPermission", "()V"); callActivityMethod("requestNotificationPermission", "()V");
} }
void AndroidController::showVpnStateNotification(const QString &title, const QString &message)
{
if (!isNotificationPermissionGranted()) {
return;
}
callActivityMethod("showVpnStateNotification", "(Ljava/lang/String;Ljava/lang/String;)V",
QJniObject::fromString(title).object<jstring>(),
QJniObject::fromString(message).object<jstring>());
}
bool AndroidController::requestAuthentication() bool AndroidController::requestAuthentication()
{ {
QEventLoop wait; QEventLoop wait;

View File

@@ -53,6 +53,7 @@ public:
QPixmap getAppIcon(const QString &package, QSize *size, const QSize &requestedSize); QPixmap getAppIcon(const QString &package, QSize *size, const QSize &requestedSize);
bool isNotificationPermissionGranted(); bool isNotificationPermissionGranted();
void requestNotificationPermission(); void requestNotificationPermission();
void showVpnStateNotification(const QString &title, const QString &message);
bool requestAuthentication(); bool requestAuthentication();
void sendTouch(float x, float y); void sendTouch(float x, float y);

View File

@@ -0,0 +1,19 @@
#include "android_notificationhandler.h"
#include "android_controller.h"
AndroidNotificationHandler::AndroidNotificationHandler(QObject *parent)
: NotificationHandler(parent)
{
}
void AndroidNotificationHandler::notify(Message type, const QString &title, const QString &message, int timerMsec)
{
Q_UNUSED(type);
Q_UNUSED(timerMsec);
// Permission is checked on the Kotlin side as well; avoid JNI if already denied.
if (!AndroidController::instance()->isNotificationPermissionGranted()) {
return;
}
AndroidController::instance()->showVpnStateNotification(title, message);
}

View File

@@ -0,0 +1,16 @@
#ifndef ANDROID_NOTIFICATIONHANDLER_H
#define ANDROID_NOTIFICATIONHANDLER_H
#include "ui/notificationhandler.h"
class AndroidNotificationHandler final : public NotificationHandler {
Q_OBJECT
public:
explicit AndroidNotificationHandler(QObject *parent = nullptr);
protected:
void notify(Message type, const QString &title, const QString &message, int timerMsec) override;
};
#endif // ANDROID_NOTIFICATIONHANDLER_H

View File

@@ -61,6 +61,7 @@ IOSNotificationHandler::~IOSNotificationHandler() { }
void IOSNotificationHandler::notify(NotificationHandler::Message type, const QString& title, void IOSNotificationHandler::notify(NotificationHandler::Message type, const QString& title,
const QString& message, int timerMsec) { const QString& message, int timerMsec) {
Q_UNUSED(type); Q_UNUSED(type);
Q_UNUSED(timerMsec);
if (!m_delegate) { if (!m_delegate) {
return; return;
@@ -71,11 +72,13 @@ void IOSNotificationHandler::notify(NotificationHandler::Message type, const QSt
content.body = message.toNSString(); content.body = message.toNSString();
content.sound = [UNNotificationSound defaultSound]; content.sound = [UNNotificationSound defaultSound];
int timerSec = timerMsec / 1000; NSTimeInterval delay = 0.1;
UNTimeIntervalNotificationTrigger* trigger = UNTimeIntervalNotificationTrigger* trigger =
[UNTimeIntervalNotificationTrigger triggerWithTimeInterval:timerSec repeats:NO]; [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:delay repeats:NO];
UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:@"amneziavpn" NSString* requestId = [NSString stringWithFormat:@"amneziavpn.vpnstate.%lld",
(long long)([[NSDate date] timeIntervalSince1970] * 1000.0)];
UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:requestId
content:content content:content
trigger:trigger]; trigger:trigger];
@@ -143,6 +146,7 @@ IOSNotificationHandler::~IOSNotificationHandler() { }
void IOSNotificationHandler::notify(NotificationHandler::Message type, const QString& title, void IOSNotificationHandler::notify(NotificationHandler::Message type, const QString& title,
const QString& message, int timerMsec) { const QString& message, int timerMsec) {
Q_UNUSED(type); Q_UNUSED(type);
Q_UNUSED(timerMsec);
if (!m_delegate) { if (!m_delegate) {
return; return;
@@ -153,11 +157,13 @@ void IOSNotificationHandler::notify(NotificationHandler::Message type, const QSt
content.body = message.toNSString(); content.body = message.toNSString();
content.sound = [UNNotificationSound defaultSound]; content.sound = [UNNotificationSound defaultSound];
int timerSec = timerMsec / 1000; NSTimeInterval delay = 0.1;
UNTimeIntervalNotificationTrigger* trigger = UNTimeIntervalNotificationTrigger* trigger =
[UNTimeIntervalNotificationTrigger triggerWithTimeInterval:timerSec repeats:NO]; [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:delay repeats:NO];
UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:@"amneziavpn" NSString* requestId = [NSString stringWithFormat:@"amneziavpn.vpnstate.%lld",
(long long)([[NSDate date] timeIntervalSince1970] * 1000.0)];
UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:requestId
content:content content:content
trigger:trigger]; trigger:trigger];

View File

@@ -0,0 +1,8 @@
#ifndef MACOS_NE_VPN_NOTIFICATION_H
#define MACOS_NE_VPN_NOTIFICATION_H
class QString;
void macosNePostVpnStateNotification(const QString &title, const QString &message);
#endif

View File

@@ -0,0 +1,91 @@
#include "macos_ne_vpn_notification.h"
#include <QtGlobal>
#include <QString>
#import <Foundation/Foundation.h>
#import <UserNotifications/UserNotifications.h>
namespace {
@interface MacosNeVpnNotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
@end
@implementation MacosNeVpnNotificationDelegate
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
willPresentNotification:(UNNotification *)notification
withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler
{
Q_UNUSED(center)
Q_UNUSED(notification)
completionHandler(UNNotificationPresentationOptionList | UNNotificationPresentationOptionBanner);
}
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
withCompletionHandler:(void (^)(void))completionHandler
{
Q_UNUSED(center)
Q_UNUSED(response)
completionHandler();
}
@end
MacosNeVpnNotificationDelegate *delegateInstance()
{
static MacosNeVpnNotificationDelegate *d;
static dispatch_once_t once;
dispatch_once(&once, ^{
d = [[MacosNeVpnNotificationDelegate alloc] init];
});
return d;
}
void ensureNotificationCenterSetup()
{
static dispatch_once_t once;
dispatch_once(&once, ^{
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center requestAuthorizationWithOptions:(UNAuthorizationOptionSound | UNAuthorizationOptionAlert |
UNAuthorizationOptionBadge)
completionHandler:^(BOOL granted, NSError *_Nullable error) {
Q_UNUSED(granted);
if (!error) {
center.delegate = delegateInstance();
}
}];
});
}
} // namespace
void macosNePostVpnStateNotification(const QString &title, const QString &message)
{
ensureNotificationCenterSetup();
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
content.title = title.toNSString();
content.body = message.toNSString();
content.sound = nil;
NSTimeInterval delay = 0.1;
UNTimeIntervalNotificationTrigger *trigger =
[UNTimeIntervalNotificationTrigger triggerWithTimeInterval:delay repeats:NO];
NSString *identifier =
[NSString stringWithFormat:@"amneziavpn.vpnstate.%lld", (long long)([[NSDate date] timeIntervalSince1970] * 1000.0)];
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:identifier
content:content
trigger:trigger];
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center addNotificationRequest:request
withCompletionHandler:^(NSError *_Nullable error) {
if (error) {
NSLog(@"macosNePostVpnStateNotification failed: %@", error);
}
}];
}

View File

@@ -5,16 +5,19 @@
#include <QDebug> #include <QDebug>
#include "notificationHandler.h" #include "notificationHandler.h"
#if defined(Q_OS_IOS) #if defined(Q_OS_ANDROID)
# include "platforms/android/android_notificationhandler.h"
#elif defined(Q_OS_IOS)
# include "platforms/ios/iosnotificationhandler.h" # include "platforms/ios/iosnotificationhandler.h"
#else #else
# include "systemTrayNotificationHandler.h" # include "systemTrayNotificationHandler.h"
#endif #endif
// static // static
NotificationHandler* NotificationHandler::create(QObject* parent) { NotificationHandler* NotificationHandler::create(QObject* parent) {
#if defined(Q_OS_IOS) #if defined(Q_OS_ANDROID)
return new AndroidNotificationHandler(parent);
#elif defined(Q_OS_IOS)
return new IOSNotificationHandler(parent); return new IOSNotificationHandler(parent);
#else #else
return new SystemTrayNotificationHandler(parent); return new SystemTrayNotificationHandler(parent);

View File

@@ -10,6 +10,10 @@
# include "platforms/macos/macosutils.h" # include "platforms/macos/macosutils.h"
#endif #endif
#ifdef MACOS_NE
# include "platforms/macos/macos_ne_vpn_notification.h"
#endif
#include <QApplication> #include <QApplication>
#include <QDesktopServices> #include <QDesktopServices>
#include <QIcon> #include <QIcon>
@@ -152,6 +156,12 @@ void SystemTrayNotificationHandler::notify(NotificationHandler::Message type,
int timerMsec) { int timerMsec) {
Q_UNUSED(type); Q_UNUSED(type);
#ifdef MACOS_NE
Q_UNUSED(timerMsec);
macosNePostVpnStateNotification(title, message);
return;
#endif
QIcon icon(ConnectedTrayIconName); QIcon icon(ConnectedTrayIconName);
m_systemTrayIcon.showMessage(title, message, icon, timerMsec); m_systemTrayIcon.showMessage(title, message, icon, timerMsec);
} }