From ddecfcad2612322ce10dadd0b5333ef2e9760872 Mon Sep 17 00:00:00 2001 From: yyy-amnezia Date: Fri, 20 Mar 2026 14:51:36 +0200 Subject: [PATCH 01/11] fix: apple platform network switch fix (#2359) * Apple platform network switch fix * macos_ne exclusion fixed --- .../ios/PacketTunnelProvider+OpenVPN.swift | 9 +++- .../platforms/ios/PacketTunnelProvider.swift | 54 ++++++++++++++++--- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift b/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift index 118545c2c..6f534e8a8 100644 --- a/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift +++ b/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift @@ -126,8 +126,13 @@ extension PacketTunnelProvider { } vpnReachability.startTracking { [weak self] status in - guard status == .reachableViaWiFi else { return } - self?.ovpnAdapter?.reconnect(afterTimeInterval: 5) + switch status { + case .reachableViaWiFi, .reachableViaWWAN: + ovpnLog(.info, message: "Reachability changed, reconnecting OpenVPN session") + self?.ovpnAdapter?.reconnect(afterTimeInterval: 1) + default: + break + } } startHandler = completionHandler diff --git a/client/platforms/ios/PacketTunnelProvider.swift b/client/platforms/ios/PacketTunnelProvider.swift index 8a6784133..1825b816c 100644 --- a/client/platforms/ios/PacketTunnelProvider.swift +++ b/client/platforms/ios/PacketTunnelProvider.swift @@ -41,10 +41,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { var ovpnAdapter: OpenVPNAdapter? private lazy var openVPNPacketFlowAdapter = PacketTunnelFlowAdapter(flow: packetFlow) private let pathMonitorQueue = DispatchQueue(label: Constants.processQueueName + ".path-monitor") + private let networkChangeQueue = DispatchQueue(label: Constants.processQueueName + ".network-change") private let pathMonitor = NWPathMonitor() private var didReceiveInitialPathUpdate = false private var currentPath: Network.NWPath? private var currentPathSignature: String? + private var pendingNetworkChangeWorkItem: DispatchWorkItem? + private var isApplyingNetworkChange = false var splitTunnelType: Int? var splitTunnelSites: [String]? @@ -78,14 +81,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { guard hasMeaningfulChange, let proto = self.protoType else { return } - // WireGuard/AWG manages network changes internally; avoid restarting the tunnel here. - if proto == .wireguard { + // OpenVPN and WireGuard/AWG handle network changes internally. + // Restarting them here can race their own reconnect logic and break tunnel setup. + if proto == .wireguard || proto == .openvpn { return } - DispatchQueue.main.async { - self.handle(networkChange: path) { _ in } - } + self.scheduleNetworkChangeHandling(for: proto, path: path) } pathMonitor.start(queue: pathMonitorQueue) @@ -259,9 +261,47 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } private func handle(networkChange changePath: Network.NWPath, completion: @escaping (Error?) -> Void) { + guard protoType == .xray else { + updateActiveInterfaceIndex(for: changePath) + completion(nil) + return + } + updateActiveInterfaceIndex(for: changePath) - wg_log(.info, message: "Tunnel restarted.") - startTunnel(options: nil, completionHandler: completion) + reasserting = true + xrayLog(.info, message: "Applying network change to xray tunnel") + stopXray { } + startXray { [weak self] error in + self?.reasserting = false + completion(error) + } + } + + private func scheduleNetworkChangeHandling(for proto: TunnelProtoType, path: Network.NWPath) { + guard proto == .xray else { return } + + pendingNetworkChangeWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + + if self.isApplyingNetworkChange { + xrayLog(.debug, message: "Skipping network change while restart is already in progress") + return + } + + self.isApplyingNetworkChange = true + DispatchQueue.main.async { + self.handle(networkChange: path) { [weak self] _ in + self?.networkChangeQueue.async { + self?.isApplyingNetworkChange = false + } + } + } + } + + pendingNetworkChangeWorkItem = workItem + networkChangeQueue.asyncAfter(deadline: .now() + 1.0, execute: workItem) } } From ec3ab2a03ce27fba08fb28f7fad1f1956a2b5bc6 Mon Sep 17 00:00:00 2001 From: vkamn Date: Fri, 20 Mar 2026 20:04:13 +0700 Subject: [PATCH 02/11] chore: update licnese file (#2376) --- README.md | 2 +- THIRD_PARTY_LICENSES.md | 149 ++++++++++++++++++++++++++++++++++++++++ client/3rd-prebuilt | 2 +- 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 THIRD_PARTY_LICENSES.md diff --git a/README.md b/README.md index f2327b33b..36c3f1173 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ You may face compiling issues in QT Creator after you've worked in Android Studi ## License -GPL v3.0 +This project is licensed under the GNU General Public License v3.0 (see LICENSE) and also includes third-party components distributed under their own terms (see THIRD_PARTY_LICENSES.md). ## Donate diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 000000000..aa631bb1a --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,149 @@ +# Third-Party Licenses + +This project is licensed under the GNU General Public License v3.0. +This file lists third-party software components used by this repository. +Each component is distributed under its own license as linked below. + +--- + +## QtKeychain + +- Source: https://github.com/frankosterfeld/qtkeychain +- License: BSD License +- License Text: https://www.gnu.org/licenses/license-list.html#ModifiedBSD + +--- + +## QSimpleCrypto + +- Source: https://github.com/n1flh31mur/QSimpleCrypto +- License: Apache License 2.0 +- License Text: https://github.com/n1flh31mur/QSimpleCrypto/blob/master/LICENSE + +--- + +## SortFilterProxyModel + +- Source: https://github.com/oKcerG/SortFilterProxyModel +- License: MIT License +- License Text: https://github.com/oKcerG/SortFilterProxyModel/blob/master/LICENSE + +--- + +## QJsonStruct + +- Source: https://github.com/Qv2ray/QJsonStruct +- License: MIT License +- License Text: https://github.com/Qv2ray/QJsonStruct/blob/master/LICENSE + +--- + +## QR Code Generator (qrcodegen) + +- Source: https://github.com/nayuki/QR-Code-generator +- License: MIT License +- License Text: https://www.nayuki.io/page/qr-code-generator-library + +--- + +## Qt Gamepad + +- Source: https://github.com/qt/qtgamepad +- License: GNU General Public License v3.0 (GPL-3.0) +- License Text: https://www.gnu.org/licenses/gpl-3.0.en.html + +--- + +## AmneziaWG Apple (WireGuard) + +- Source: https://github.com/amnezia-vpn/amneziawg-apple +- License: MIT License +- License Text: https://github.com/amnezia-vpn/amneziawg-apple/blob/master/COPYING + +--- + +## AmneziaWG Android + +- Source: https://github.com/amnezia-vpn/amneziawg-go +- License: MIT License +- License Text: https://github.com/amnezia-vpn/amneziawg-go/blob/master/LICENSE + +--- + +## Xray Core + +- Source: https://github.com/XTLS/Xray-core +- License: Mozilla Public License 2.0 (MPL-2.0) +- License Text: https://github.com/XTLS/Xray-core/blob/main/LICENSE + +--- + +## Cloak + +- Source: https://github.com/cbeuw/Cloak +- License: GNU General Public License v3.0 (GPL-3.0) +- License Text: https://github.com/cbeuw/Cloak/blob/master/LICENSE + +--- + +## Shadowsocks + +- Source: https://github.com/shadowsocks/shadowsocks-libev +- License: GPL-3.0-or-later +- License Text: http://www.gnu.org/licenses/ + +--- + +## OpenSSL + +- Source: https://github.com/openssl/openssl +- License: Apache License 2.0 +- License Text: https://www.openssl.org/source/license.html + +--- + +## libssh + +- Source: https://www.libssh.org/ +- License: GNU Lesser General Public License (LGPL) +- License Text: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html + +--- + +## OpenVPNAdapter + +- Source: https://github.com/ss-abramchuk/OpenVPNAdapter +- License: GNU Affero General Public License v3.0 (AGPL-3.0) +- License Text: https://github.com/ss-abramchuk/OpenVPNAdapter/blob/master/LICENSE + +--- + +## Wintun + +- Source: https://www.wintun.net/ +- License: Prebuilt Binaries License +- License Text: https://github.com/WireGuard/wintun/blob/master/prebuilt-binaries-license.txt + +--- + +## Mullvad Split Tunnel Driver + +- Source: https://github.com/mullvad/win-split-tunnel +- License: GNU General Public License v3.0 (GPL-3.0) and Mozilla Public License Version 2.0 +- License Text: https://github.com/mullvad/win-split-tunnel/blob/master/LICENSE-GPL.md https://github.com/mullvad/win-split-tunnel/blob/master/LICENSE-MPL.txt + +--- + +## tun2socks + +- Source: https://github.com/eycorsican/go-tun2socks +- License: MIT License +- License Text: https://github.com/eycorsican/go-tun2socks/blob/master/LICENSE + +--- + +## TAP-Windows Driver + +- Source: https://github.com/OpenVPN/tap-windows6 +- License: tap-windows6 license +- License Text: https://github.com/OpenVPN/tap-windows6/blob/master/COPYING diff --git a/client/3rd-prebuilt b/client/3rd-prebuilt index 568b8d720..51bb4703a 160000 --- a/client/3rd-prebuilt +++ b/client/3rd-prebuilt @@ -1 +1 @@ -Subproject commit 568b8d720dedf3c58e215a029280eb8d0e2fa70e +Subproject commit 51bb4703a4049e4d28ef7e28c2ec87db1bbb0d1e From 40e39895c9c03bb923e0b796f1973c0cd29143c5 Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Sat, 21 Mar 2026 06:46:46 +0300 Subject: [PATCH 03/11] fix openfile deadlock (#2373) --- client/android/src/org/amnezia/vpn/AmneziaActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index daedfda3f..a28d531a3 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -816,7 +816,7 @@ class AmneziaActivity : QtActivity() { @Suppress("unused") fun getFd(fileName: String): Int { Log.v(TAG, "Get fd for $fileName") - return blockingCall { + return blockingCall(Dispatchers.IO) { try { pfd = contentResolver.openFileDescriptor(Uri.parse(fileName), "r") pfd?.fd ?: -1 From c57162c4ccbfa35f722785000d4f8228111c260f Mon Sep 17 00:00:00 2001 From: vkamn Date: Tue, 24 Mar 2026 09:29:51 +0700 Subject: [PATCH 04/11] feat: add base amnezia trial support (#2366) * feat: add base amnezia trial support * feat: add external-trial --- client/core/api/apiDefs.h | 4 +- client/core/api/apiUtils.cpp | 13 +- .../controllers/api/apiConfigsController.cpp | 2 +- client/ui/models/api/apiAccountInfoModel.cpp | 4 +- client/ui/models/api/apiServicesModel.cpp | 7 +- .../Pages2/PageSetupWizardApiServiceInfo.qml | 452 +++++++++--------- 6 files changed, 249 insertions(+), 233 deletions(-) diff --git a/client/core/api/apiDefs.h b/client/core/api/apiDefs.h index 8ec919b8c..84ef0e68d 100644 --- a/client/core/api/apiDefs.h +++ b/client/core/api/apiDefs.h @@ -10,8 +10,10 @@ namespace apiDefs AmneziaFreeV3, AmneziaPremiumV1, AmneziaPremiumV2, + AmneziaTrialV2, SelfHosted, - ExternalPremium + ExternalPremium, + ExternalTrial }; enum ConfigSource { diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index b2bee8be5..2d16c384c 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -58,18 +58,24 @@ apiDefs::ConfigType apiUtils::getConfigType(const QJsonObject &serverConfigObjec }; case apiDefs::ConfigSource::AmneziaGateway: { constexpr QLatin1String servicePremium("amnezia-premium"); + constexpr QLatin1String serviceTrial("amnezia-trial"); constexpr QLatin1String serviceFree("amnezia-free"); constexpr QLatin1String serviceExternalPremium("external-premium"); + constexpr QLatin1String serviceExternalTrial("external-trial"); auto apiConfigObject = serverConfigObject.value(apiDefs::key::apiConfig).toObject(); auto serviceType = apiConfigObject.value(apiDefs::key::serviceType).toString(); if (serviceType == servicePremium) { return apiDefs::ConfigType::AmneziaPremiumV2; + } else if (serviceType == serviceTrial) { + return apiDefs::ConfigType::AmneziaTrialV2; } else if (serviceType == serviceFree) { return apiDefs::ConfigType::AmneziaFreeV3; } else if (serviceType == serviceExternalPremium) { return apiDefs::ConfigType::ExternalPremium; + } else if (serviceType == serviceExternalTrial) { + return apiDefs::ConfigType::ExternalTrial; } } default: { @@ -133,7 +139,8 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl bool apiUtils::isPremiumServer(const QJsonObject &serverConfigObject) { static const QSet premiumTypes = { apiDefs::ConfigType::AmneziaPremiumV1, apiDefs::ConfigType::AmneziaPremiumV2, - apiDefs::ConfigType::ExternalPremium }; + apiDefs::ConfigType::AmneziaTrialV2, apiDefs::ConfigType::ExternalPremium, + apiDefs::ConfigType::ExternalTrial }; return premiumTypes.contains(getConfigType(serverConfigObject)); } @@ -177,7 +184,9 @@ QString apiUtils::getPremiumV1VpnKey(const QJsonObject &serverConfigObject) QString apiUtils::getPremiumV2VpnKey(const QJsonObject &serverConfigObject) { - if (apiUtils::getConfigType(serverConfigObject) != apiDefs::ConfigType::AmneziaPremiumV2) { + auto configType = apiUtils::getConfigType(serverConfigObject); + if (configType != apiDefs::ConfigType::AmneziaPremiumV2 && configType != apiDefs::ConfigType::AmneziaTrialV2 + && configType != apiDefs::ConfigType::ExternalPremium && configType != apiDefs::ConfigType::ExternalTrial) { return {}; } diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index 83ca3284f..85872f59c 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -447,7 +447,7 @@ bool ApiConfigsController::importService() importSerivceFromAppStore(); return true; } - } else { + } else if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaFree) { importServiceFromGateway(); return true; } diff --git a/client/ui/models/api/apiAccountInfoModel.cpp b/client/ui/models/api/apiAccountInfoModel.cpp index 0f3a8a4ed..65fc0083f 100644 --- a/client/ui/models/api/apiAccountInfoModel.cpp +++ b/client/ui/models/api/apiAccountInfoModel.cpp @@ -52,7 +52,9 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const } case IsComponentVisibleRole: { return m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2 - || m_accountInfoData.configType == apiDefs::ConfigType::ExternalPremium; + || m_accountInfoData.configType == apiDefs::ConfigType::AmneziaTrialV2 + || m_accountInfoData.configType == apiDefs::ConfigType::ExternalPremium + || m_accountInfoData.configType == apiDefs::ConfigType::ExternalTrial; } case HasExpiredWorkerRole: { for (int i = 0; i < m_issuedConfigsInfo.size(); i++) { diff --git a/client/ui/models/api/apiServicesModel.cpp b/client/ui/models/api/apiServicesModel.cpp index 5ed9cca16..7d831f48c 100644 --- a/client/ui/models/api/apiServicesModel.cpp +++ b/client/ui/models/api/apiServicesModel.cpp @@ -41,6 +41,7 @@ namespace { constexpr char amneziaFree[] = "amnezia-free"; constexpr char amneziaPremium[] = "amnezia-premium"; + constexpr char amneziaTrial[] = "amnezia-trial"; } } @@ -69,7 +70,7 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const } case CardDescriptionRole: { auto speed = apiServiceData.serviceInfo.speed; - if (serviceType == serviceType::amneziaPremium) { + if (serviceType == serviceType::amneziaPremium || serviceType == serviceType::amneziaTrial) { return apiServiceData.serviceInfo.cardDescription.arg(speed); } else if (serviceType == serviceType::amneziaFree) { QString description = apiServiceData.serviceInfo.cardDescription; @@ -124,8 +125,10 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const case OrderRole: { if (serviceType == serviceType::amneziaPremium) { return 0; - } else if (serviceType == serviceType::amneziaFree) { + } else if (serviceType == serviceType::amneziaTrial) { return 1; + } else if (serviceType == serviceType::amneziaFree) { + return 2; } } } diff --git a/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml index c5e581af8..24308a126 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml @@ -1,226 +1,226 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import QtQuick.Dialogs - -import PageEnum 1.0 -import Style 1.0 - -import "./" -import "../Controls2" -import "../Controls2/TextTypes" -import "../Config" -import "../Components" - -PageType { - id: root - - BackButtonType { - id: backButton - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.topMargin: 20 + SettingsController.safeAreaTopMargin - - onFocusChanged: { - if (this.activeFocus) { - listView.positionViewAtBeginning() - } - } - } - - ListViewType { - id: listView - - anchors.top: backButton.bottom - anchors.bottom: parent.bottom - anchors.right: parent.right - anchors.left: parent.left - - header: ColumnLayout { - width: listView.width - - BaseHeaderType { - Layout.fillWidth: true - Layout.topMargin: 8 - Layout.rightMargin: 16 - Layout.leftMargin: 16 - Layout.bottomMargin: 32 - - headerText: ApiServicesModel.getSelectedServiceData("name") - descriptionText: ApiServicesModel.getSelectedServiceData("serviceDescription") - } - } - - model: inputFields - spacing: 0 - - delegate: ColumnLayout { - width: listView.width - - LabelWithImageType { - Layout.fillWidth: true - Layout.margins: 16 - - imageSource: imagePath - leftText: lText - rightText: rText - - visible: isVisible - } - } - - footer: ColumnLayout { - width: listView.width - - spacing: 0 - - ParagraphTextType { - Layout.fillWidth: true - Layout.rightMargin: 16 - Layout.leftMargin: 16 - - onLinkActivated: function(link) { - Qt.openUrlExternally(link) - } - textFormat: Text.RichText - text: { - var text = ApiServicesModel.getSelectedServiceData("features") - return text.replace("%1", LanguageModel.getCurrentSiteUrl("free")).replace("/free", "") // todo link should come from gateway - } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton - cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor - } - } - - ParagraphTextType { - Layout.fillWidth: true - Layout.topMargin: 16 - Layout.leftMargin: 16 - Layout.rightMargin: 16 - - visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium" - - horizontalAlignment: Text.AlignHCenter - textFormat: Text.PlainText - color: AmneziaStyle.color.mutedGray - font.pixelSize: 12 - - text: qsTr("Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.") - } - - BasicButtonType { - id: continueButton - - Layout.fillWidth: true - Layout.topMargin: 32 - Layout.bottomMargin: 16 - Layout.leftMargin: 16 - Layout.rightMargin: 16 - - text: ApiServicesModel.getSelectedServiceType() === "amnezia-premium" ? qsTr("Subscribe Now") : qsTr("Connect") - - clickedFunc: function() { - PageController.showBusyIndicator(true) - var result = ApiConfigsController.importService() - PageController.showBusyIndicator(false) - - if (!result) { - var endpoint = ApiServicesModel.getStoreEndpoint() - Qt.openUrlExternally(endpoint) - PageController.closePage() - PageController.closePage() - } - } - } - - ParagraphTextType { - Layout.fillWidth: true - Layout.topMargin: 16 - Layout.leftMargin: 16 - Layout.rightMargin: 16 - Layout.bottomMargin: 32 - - visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium" - - horizontalAlignment: Text.AlignHCenter - textFormat: Text.RichText - color: AmneziaStyle.color.mutedGray - font.pixelSize: 12 - - text: { - var termsUrl = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" - var privacyUrl = LanguageModel.getCurrentSiteUrl("policy") - return qsTr("By continuing, you agree to the Terms of Use and Privacy Policy").arg(termsUrl).arg(privacyUrl) - } - - onLinkActivated: function(link) { - Qt.openUrlExternally(link) - } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton - cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor - } - } - } - } - - property list inputFields: [ - region, - price, - timeLimit, - speed, - features - ] - - QtObject { - id: region - - readonly property string imagePath: "qrc:/images/controls/map-pin.svg" - readonly property string lText: qsTr("For the region") - readonly property string rText: ApiServicesModel.getSelectedServiceData("region") - property bool isVisible: true - } - - QtObject { - id: price - - readonly property string imagePath: "qrc:/images/controls/tag.svg" - readonly property string lText: qsTr("Price") - readonly property string rText: ApiServicesModel.getSelectedServiceData("price") - property bool isVisible: true - } - - QtObject { - id: timeLimit - - readonly property string imagePath: "qrc:/images/controls/history.svg" - readonly property string lText: qsTr("Work period") - readonly property string rText: ApiServicesModel.getSelectedServiceData("timeLimit") - property bool isVisible: rText !== "" - } - - QtObject { - id: speed - - readonly property string imagePath: "qrc:/images/controls/gauge.svg" - readonly property string lText: qsTr("Speed") - readonly property string rText: ApiServicesModel.getSelectedServiceData("speed") - property bool isVisible: true - } - - QtObject { - id: features - - readonly property string imagePath: "qrc:/images/controls/info.svg" - readonly property string lText: qsTr("Features") - readonly property string rText: "" - property bool isVisible: true - } -} +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs + +import PageEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + SettingsController.safeAreaTopMargin + + onFocusChanged: { + if (this.activeFocus) { + listView.positionViewAtBeginning() + } + } + } + + ListViewType { + id: listView + + anchors.top: backButton.bottom + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.left: parent.left + + header: ColumnLayout { + width: listView.width + + BaseHeaderType { + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.rightMargin: 16 + Layout.leftMargin: 16 + Layout.bottomMargin: 32 + + headerText: ApiServicesModel.getSelectedServiceData("name") + descriptionText: ApiServicesModel.getSelectedServiceData("serviceDescription") + } + } + + model: inputFields + spacing: 0 + + delegate: ColumnLayout { + width: listView.width + + LabelWithImageType { + Layout.fillWidth: true + Layout.margins: 16 + + imageSource: imagePath + leftText: lText + rightText: rText + + visible: isVisible + } + } + + footer: ColumnLayout { + width: listView.width + + spacing: 0 + + ParagraphTextType { + Layout.fillWidth: true + Layout.rightMargin: 16 + Layout.leftMargin: 16 + + onLinkActivated: function(link) { + Qt.openUrlExternally(link) + } + textFormat: Text.RichText + text: { + var text = ApiServicesModel.getSelectedServiceData("features") + return text.replace("%1", LanguageModel.getCurrentSiteUrl("free")).replace("/free", "") // todo link should come from gateway + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + } + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + + visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium" + + horizontalAlignment: Text.AlignHCenter + textFormat: Text.PlainText + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + + text: qsTr("Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.") + } + + BasicButtonType { + id: continueButton + + Layout.fillWidth: true + Layout.topMargin: 32 + Layout.bottomMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + + text: ApiServicesModel.getSelectedServiceType() === "amnezia-premium" ? qsTr("Subscribe Now") : (ApiServicesModel.getSelectedServiceType() === "amnezia-trial" ? qsTr("Try Trial") : qsTr("Connect")) + + clickedFunc: function() { + PageController.showBusyIndicator(true) + var result = ApiConfigsController.importService() + PageController.showBusyIndicator(false) + + if (!result) { + var endpoint = ApiServicesModel.getStoreEndpoint() + Qt.openUrlExternally(endpoint) + PageController.closePage() + PageController.closePage() + } + } + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 32 + + visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) && ApiServicesModel.getSelectedServiceType() === "amnezia-premium" + + horizontalAlignment: Text.AlignHCenter + textFormat: Text.RichText + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + + text: { + var termsUrl = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" + var privacyUrl = LanguageModel.getCurrentSiteUrl("policy") + return qsTr("By continuing, you agree to the Terms of Use and Privacy Policy").arg(termsUrl).arg(privacyUrl) + } + + onLinkActivated: function(link) { + Qt.openUrlExternally(link) + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + } + } + } + } + + property list inputFields: [ + region, + price, + timeLimit, + speed, + features + ] + + QtObject { + id: region + + readonly property string imagePath: "qrc:/images/controls/map-pin.svg" + readonly property string lText: qsTr("For the region") + readonly property string rText: ApiServicesModel.getSelectedServiceData("region") + property bool isVisible: true + } + + QtObject { + id: price + + readonly property string imagePath: "qrc:/images/controls/tag.svg" + readonly property string lText: qsTr("Price") + readonly property string rText: ApiServicesModel.getSelectedServiceData("price") + property bool isVisible: true + } + + QtObject { + id: timeLimit + + readonly property string imagePath: "qrc:/images/controls/history.svg" + readonly property string lText: qsTr("Work period") + readonly property string rText: ApiServicesModel.getSelectedServiceData("timeLimit") + property bool isVisible: rText !== "" + } + + QtObject { + id: speed + + readonly property string imagePath: "qrc:/images/controls/gauge.svg" + readonly property string lText: qsTr("Speed") + readonly property string rText: ApiServicesModel.getSelectedServiceData("speed") + property bool isVisible: true + } + + QtObject { + id: features + + readonly property string imagePath: "qrc:/images/controls/info.svg" + readonly property string lText: qsTr("Features") + readonly property string rText: "" + property bool isVisible: true + } +} From dbbc7119ec126bbd1085acaa32717b06c10c68f9 Mon Sep 17 00:00:00 2001 From: Mitternacht822 Date: Tue, 24 Mar 2026 12:06:40 +0400 Subject: [PATCH 05/11] feat: add warning info for ssh keys (#2252) * fix: fixed da typo * feat: added warning about available ssh keys info --- .../ui/qml/Pages2/PageSetupWizardCredentials.qml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/ui/qml/Pages2/PageSetupWizardCredentials.qml b/client/ui/qml/Pages2/PageSetupWizardCredentials.qml index 4ce5bc31c..8b67754d7 100644 --- a/client/ui/qml/Pages2/PageSetupWizardCredentials.qml +++ b/client/ui/qml/Pages2/PageSetupWizardCredentials.qml @@ -79,11 +79,23 @@ PageType { } textField.onTextChanged: { - if (headerText == qsTr("Password or SSH private key")) { + if (headerText === qsTr("Password or SSH private key")) { buttonImageSource = textField.text !== "" ? imageSource : "" } } } + + WarningType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + + visible: title === qsTr("Password or SSH private key") + backGroundColor: AmneziaStyle.color.translucentWhite + iconPath: "qrc:/images/controls/alert-circle.svg" + textString: qsTr("SSH key requirements: supported ED25519 or RSA in PEM. Paste the private key including BEGIN/END lines. If your key doesn’t work, generate a compatible one.") + } } footer: ColumnLayout { From aaf2c9ddeb52912dc24de20fd96479e76df0a78e Mon Sep 17 00:00:00 2001 From: yyy-amnezia Date: Tue, 24 Mar 2026 10:07:36 +0200 Subject: [PATCH 06/11] feat: add Xray split tunnel support for iOS PacketTunnelProvider (#2332) --- .../ios/PacketTunnelProvider+Xray.swift | 39 +++++++++++++++++++ client/platforms/ios/XrayConfig.swift | 2 + client/platforms/ios/ios_controller.mm | 9 +++++ 3 files changed, 50 insertions(+) diff --git a/client/platforms/ios/PacketTunnelProvider+Xray.swift b/client/platforms/ios/PacketTunnelProvider+Xray.swift index 6a08bb6a9..4d3d723ca 100644 --- a/client/platforms/ios/PacketTunnelProvider+Xray.swift +++ b/client/platforms/ios/PacketTunnelProvider+Xray.swift @@ -21,6 +21,44 @@ extension Constants { } extension PacketTunnelProvider { + private func applyXraySplitTunnel(_ xrayConfig: XrayConfig, + settings: NEPacketTunnelNetworkSettings) { + guard let splitTunnelType = xrayConfig.splitTunnelType else { + return + } + + guard let splitTunnelSites = xrayConfig.splitTunnelSites else { + xrayLog(.error, message: "Split tunnel sites are not set") + return + } + + if splitTunnelType == 1 { + var ipv4IncludedRoutes = [NEIPv4Route]() + + for allowedIPString in splitTunnelSites { + if let allowedIP = IPAddressRange(from: allowedIPString) { + ipv4IncludedRoutes.append(NEIPv4Route( + destinationAddress: "\(allowedIP.address)", + subnetMask: "\(allowedIP.subnetMask())")) + } + } + + settings.ipv4Settings?.includedRoutes = ipv4IncludedRoutes + } else if splitTunnelType == 2 { + var ipv4ExcludedRoutes = [NEIPv4Route]() + + for excludedIPString in splitTunnelSites { + if let excludedIP = IPAddressRange(from: excludedIPString) { + ipv4ExcludedRoutes.append(NEIPv4Route( + destinationAddress: "\(excludedIP.address)", + subnetMask: "\(excludedIP.subnetMask())")) + } + } + + settings.ipv4Settings?.excludedRoutes = ipv4ExcludedRoutes + } + } + func startXray(completionHandler: @escaping (Error?) -> Void) { // Xray configuration @@ -72,6 +110,7 @@ extension PacketTunnelProvider { settings.dnsSettings = !dnsArray.isEmpty ? NEDNSSettings(servers: dnsArray) : NEDNSSettings(servers: ["1.1.1.1"]) + applyXraySplitTunnel(xrayConfig, settings: settings) let xrayConfigData = xrayConfig.config.data(using: .utf8) diff --git a/client/platforms/ios/XrayConfig.swift b/client/platforms/ios/XrayConfig.swift index 9c47a2a18..9c533a93e 100644 --- a/client/platforms/ios/XrayConfig.swift +++ b/client/platforms/ios/XrayConfig.swift @@ -3,5 +3,7 @@ import Foundation struct XrayConfig: Decodable { let dns1: String? let dns2: String? + let splitTunnelType: Int? + let splitTunnelSites: [String]? let config: String } diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index 9302680bd..fc9498d02 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -684,6 +684,15 @@ bool IosController::setupXray() QJsonObject finalConfig; finalConfig.insert(config_key::dns1, m_rawConfig[config_key::dns1].toString()); finalConfig.insert(config_key::dns2, m_rawConfig[config_key::dns2].toString()); + finalConfig.insert(config_key::splitTunnelType, m_rawConfig[config_key::splitTunnelType]); + + QJsonArray splitTunnelSites = m_rawConfig[config_key::splitTunnelSites].toArray(); + + for(int index = 0; index < splitTunnelSites.count(); index++) { + splitTunnelSites[index] = splitTunnelSites[index].toString().remove(" "); + } + + finalConfig.insert(config_key::splitTunnelSites, splitTunnelSites); finalConfig.insert(config_key::config, xrayConfigStr); QJsonDocument finalConfigDoc(finalConfig); From fa69da6d564f7e6f48d5ec6d41fa0c01386c1dc6 Mon Sep 17 00:00:00 2001 From: vkamn Date: Tue, 24 Mar 2026 19:25:04 +0700 Subject: [PATCH 07/11] chore: send app version in services request (#2403) --- client/core/api/apiDefs.h | 1 + client/ui/controllers/api/apiConfigsController.cpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/client/core/api/apiDefs.h b/client/core/api/apiDefs.h index 84ef0e68d..78e8031fc 100644 --- a/client/core/api/apiDefs.h +++ b/client/core/api/apiDefs.h @@ -34,6 +34,7 @@ namespace apiDefs constexpr QLatin1String stackType("stack_type"); constexpr QLatin1String serviceType("service_type"); constexpr QLatin1String cliVersion("cli_version"); + constexpr QLatin1String cliName("cli_name"); constexpr QLatin1String supportedProtocols("supported_protocols"); constexpr QLatin1String vpnKey("vpn_key"); diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index 85872f59c..c86342941 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -366,6 +366,8 @@ bool ApiConfigsController::fillAvailableServices() { QJsonObject apiPayload; apiPayload[configKey::osVersion] = QSysInfo::productType(); + apiPayload[configKey::appVersion] = QString(APP_VERSION); + apiPayload[apiDefs::key::cliName] = QString(APPLICATION_NAME); apiPayload[apiDefs::key::appLanguage] = m_settings->getAppLanguage().name().split("_").first(); QByteArray responseBody; From 4103c5bbcfb5792c361fe6a10540e3dde1bafe2a Mon Sep 17 00:00:00 2001 From: yyy-amnezia Date: Tue, 24 Mar 2026 16:12:59 +0200 Subject: [PATCH 08/11] refactor: extract and simplify OpenVPN reachability and network change handling logic (#2402) --- .../ios/PacketTunnelProvider+OpenVPN.swift | 8 +- .../platforms/ios/PacketTunnelProvider.swift | 102 ++++++++++++++++-- 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift b/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift index 6f534e8a8..882ad578d 100644 --- a/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift +++ b/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift @@ -126,13 +126,7 @@ extension PacketTunnelProvider { } vpnReachability.startTracking { [weak self] status in - switch status { - case .reachableViaWiFi, .reachableViaWWAN: - ovpnLog(.info, message: "Reachability changed, reconnecting OpenVPN session") - self?.ovpnAdapter?.reconnect(afterTimeInterval: 1) - default: - break - } + self?.handleOpenVPNReachabilityChange(status) } startHandler = completionHandler diff --git a/client/platforms/ios/PacketTunnelProvider.swift b/client/platforms/ios/PacketTunnelProvider.swift index 1825b816c..e80bbb05d 100644 --- a/client/platforms/ios/PacketTunnelProvider.swift +++ b/client/platforms/ios/PacketTunnelProvider.swift @@ -46,8 +46,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private var didReceiveInitialPathUpdate = false private var currentPath: Network.NWPath? private var currentPathSignature: String? + private var pendingOpenVPNReconnectWorkItem: DispatchWorkItem? private var pendingNetworkChangeWorkItem: DispatchWorkItem? private var isApplyingNetworkChange = false + private var lastOpenVPNReachabilityStatus: OpenVPNReachabilityStatus? var splitTunnelType: Int? var splitTunnelSites: [String]? @@ -81,9 +83,18 @@ class PacketTunnelProvider: NEPacketTunnelProvider { guard hasMeaningfulChange, let proto = self.protoType else { return } - // OpenVPN and WireGuard/AWG handle network changes internally. - // Restarting them here can race their own reconnect logic and break tunnel setup. - if proto == .wireguard || proto == .openvpn { + // WireGuard/AWG manages network changes internally in its own adapter. + if proto == .wireguard { + return + } + + if proto == .openvpn { + self.scheduleOpenVPNReconnect(reason: "NWPath changed") + return + } + + if self.isApplyingNetworkChange || self.reasserting { + xrayLog(.debug, message: "Ignoring path change while xray restart is in progress") return } @@ -199,6 +210,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } + cancelPendingOpenVPNReconnect() + cancelPendingNetworkChangeHandling() didReceiveInitialPathUpdate = false updateActiveInterfaceIndexForCurrentPath() @@ -217,6 +230,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + cancelPendingOpenVPNReconnect() + cancelPendingNetworkChangeHandling() + guard let protoType else { completionHandler() return @@ -284,8 +300,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let workItem = DispatchWorkItem { [weak self] in guard let self else { return } + self.pendingNetworkChangeWorkItem = nil - if self.isApplyingNetworkChange { + if self.isApplyingNetworkChange || self.reasserting { xrayLog(.debug, message: "Skipping network change while restart is already in progress") return } @@ -303,6 +320,69 @@ class PacketTunnelProvider: NEPacketTunnelProvider { pendingNetworkChangeWorkItem = workItem networkChangeQueue.asyncAfter(deadline: .now() + 1.0, execute: workItem) } + + private func scheduleOpenVPNReconnect(reason: String) { + guard protoType == .openvpn else { return } + + pendingOpenVPNReconnectWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.pendingOpenVPNReconnectWorkItem = nil + + guard self.protoType == .openvpn else { return } + + if self.reasserting { + ovpnLog(.debug, message: "Skipping OpenVPN reconnect while session is already reasserting") + return + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + guard !self.reasserting else { + ovpnLog(.debug, message: "Skipping OpenVPN reconnect while session is already reasserting") + return + } + + ovpnLog(.info, message: "\(reason), reconnecting OpenVPN session") + self.ovpnAdapter?.reconnect(afterTimeInterval: 1) + } + } + + pendingOpenVPNReconnectWorkItem = workItem + networkChangeQueue.asyncAfter(deadline: .now() + 1.0, execute: workItem) + } + + func handleOpenVPNReachabilityChange(_ status: OpenVPNReachabilityStatus) { + defer { lastOpenVPNReachabilityStatus = status } + + guard let previousStatus = lastOpenVPNReachabilityStatus else { + return + } + + guard previousStatus != status else { + return + } + + switch status { + case .reachableViaWiFi, .reachableViaWWAN: + scheduleOpenVPNReconnect(reason: "Reachability changed") + default: + break + } + } + + private func cancelPendingOpenVPNReconnect() { + pendingOpenVPNReconnectWorkItem?.cancel() + pendingOpenVPNReconnectWorkItem = nil + lastOpenVPNReachabilityStatus = nil + } + + private func cancelPendingNetworkChangeHandling() { + pendingNetworkChangeWorkItem?.cancel() + pendingNetworkChangeWorkItem = nil + isApplyingNetworkChange = false + } } private extension PacketTunnelProvider { @@ -311,8 +391,14 @@ private extension PacketTunnelProvider { signatureComponents.append(path.isExpensive ? "exp" : "noexp") signatureComponents.append(path.isConstrained ? "con" : "nocon") - let preferredTypes: [NWInterface.InterfaceType] = [.wiredEthernet, .wifi, .cellular, .loopback, .other] - let sortedInterfaces = path.availableInterfaces.sorted { lhs, rhs in + // Ignore loopback and tunnel-style `.other` interfaces so Xray does not + // react to its own utun lifecycle as if the physical uplink changed. + let preferredTypes: [NWInterface.InterfaceType] = [.wiredEthernet, .wifi, .cellular] + let externalInterfaces = path.availableInterfaces.filter { interface in + interface.type == .wiredEthernet || interface.type == .wifi || interface.type == .cellular + } + + let sortedInterfaces = externalInterfaces.sorted { lhs, rhs in if lhs.type == rhs.type { return lhs.index < rhs.index } @@ -333,8 +419,8 @@ private extension PacketTunnelProvider { case .wiredEthernet: typeName = "ethernet" case .wifi: typeName = "wifi" case .cellular: typeName = "cellular" - case .loopback: typeName = "loopback" - case .other: typeName = "other" + case .loopback, .other: + continue @unknown default: typeName = "unknown" } signatureComponents.append("\(typeName):\(interface.index)") From 36b1a863bf076cfbb48e087cfe49c9aacb813992 Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Tue, 24 Mar 2026 17:13:31 +0300 Subject: [PATCH 09/11] fix: black screen resume / pause (#2400) --- .../src/org/amnezia/vpn/AmneziaActivity.kt | 10 ++++++++ .../org/amnezia/vpn/qt/QtAndroidController.kt | 3 +++ .../platforms/android/android_controller.cpp | 23 ++++++++++++++++++- client/platforms/android/android_controller.h | 4 ++++ client/ui/controllers/settingsController.cpp | 2 ++ client/ui/controllers/settingsController.h | 3 +++ client/ui/qml/main2.qml | 20 +++++++++++----- 7 files changed, 58 insertions(+), 7 deletions(-) diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index a28d531a3..1d2e09ccb 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -343,12 +343,22 @@ class AmneziaActivity : QtActivity() { resumeHandler.removeCallbacksAndMessages(null) openFileDeliveryScheduled = false Log.d(TAG, "Pause Amnezia activity") + // Notify Qt to stop rendering before the EGL surface is disconnected + mainScope.launch { + qtInitialized.await() + QtAndroidController.onActivityPaused() + } } override fun onResume() { super.onResume() isActivityResumed = true Log.d(TAG, "Resume Amnezia activity") + // Notify Qt to resume rendering after surface reconnects + mainScope.launch { + qtInitialized.await() + QtAndroidController.onActivityResumed() + } if (pendingOpenFileUri != null && !openFileDeliveryScheduled) { val uri = pendingOpenFileUri!! diff --git a/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt b/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt index b77e77d66..ec143635d 100644 --- a/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt +++ b/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt @@ -31,4 +31,7 @@ object QtAndroidController { external fun onImeInsetsChanged(heightDp: Int) external fun onSystemBarsInsetsChanged(navBarHeightDp: Int, statusBarHeightDp: Int) + + external fun onActivityPaused() + external fun onActivityResumed() } \ No newline at end of file diff --git a/client/platforms/android/android_controller.cpp b/client/platforms/android/android_controller.cpp index 2d60ad849..c6f538bd9 100644 --- a/client/platforms/android/android_controller.cpp +++ b/client/platforms/android/android_controller.cpp @@ -101,7 +101,9 @@ bool AndroidController::initialize() {"onAuthResult", "(Z)V", reinterpret_cast(onAuthResult)}, {"decodeQrCode", "(Ljava/lang/String;)Z", reinterpret_cast(decodeQrCode)}, {"onImeInsetsChanged", "(I)V", reinterpret_cast(onImeInsetsChanged)}, - {"onSystemBarsInsetsChanged", "(II)V", reinterpret_cast(onSystemBarsInsetsChanged)} + {"onSystemBarsInsetsChanged", "(II)V", reinterpret_cast(onSystemBarsInsetsChanged)}, + {"onActivityPaused", "()V", reinterpret_cast(onActivityPaused)}, + {"onActivityResumed", "()V", reinterpret_cast(onActivityResumed)} }; QJniEnvironment env; @@ -558,3 +560,22 @@ void AndroidController::onSystemBarsInsetsChanged(JNIEnv *env, jobject thiz, jin emit AndroidController::instance()->systemBarsInsetsChanged(navBarHeightDp, statusBarHeightDp); } +// static +void AndroidController::onActivityPaused(JNIEnv *env, jobject thiz) +{ + Q_UNUSED(env); + Q_UNUSED(thiz); + + emit AndroidController::instance()->activityPaused(); +} + +// static +void AndroidController::onActivityResumed(JNIEnv *env, jobject thiz) +{ + Q_UNUSED(env); + Q_UNUSED(thiz); + + emit AndroidController::instance()->activityResumed(); +} + + diff --git a/client/platforms/android/android_controller.h b/client/platforms/android/android_controller.h index a5a4b3d23..49360f02c 100644 --- a/client/platforms/android/android_controller.h +++ b/client/platforms/android/android_controller.h @@ -75,6 +75,8 @@ signals: void authenticationResult(bool result); void imeInsetsChanged(int heightDp); void systemBarsInsetsChanged(int navBarHeightDp, int statusBarHeightDp); + void activityPaused(); + void activityResumed(); private: bool isWaitingStatus = true; @@ -105,6 +107,8 @@ private: static bool decodeQrCode(JNIEnv *env, jobject thiz, jstring data); static void onImeInsetsChanged(JNIEnv *env, jobject thiz, jint heightDp); static void onSystemBarsInsetsChanged(JNIEnv *env, jobject thiz, jint navBarHeightDp, jint statusBarHeightDp); + static void onActivityPaused(JNIEnv *env, jobject thiz); + static void onActivityResumed(JNIEnv *env, jobject thiz); template static auto callActivityMethod(const char *methodName, const char *signature, Args &&...args); diff --git a/client/ui/controllers/settingsController.cpp b/client/ui/controllers/settingsController.cpp index 8b456f3a6..5363ab220 100644 --- a/client/ui/controllers/settingsController.cpp +++ b/client/ui/controllers/settingsController.cpp @@ -45,6 +45,8 @@ SettingsController::SettingsController(const QSharedPointer &serve emit safeAreaBottomMarginChanged(); emit safeAreaTopMarginChanged(); }); + connect(AndroidController::instance(), &AndroidController::activityPaused, this, &SettingsController::activityPaused); + connect(AndroidController::instance(), &AndroidController::activityResumed, this, &SettingsController::activityResumed); #endif m_isDevModeEnabled = m_settings->isDevGatewayEnv(); diff --git a/client/ui/controllers/settingsController.h b/client/ui/controllers/settingsController.h index fa50bc23c..ed20a1e6b 100644 --- a/client/ui/controllers/settingsController.h +++ b/client/ui/controllers/settingsController.h @@ -141,6 +141,9 @@ signals: void safeAreaTopMarginChanged(); void safeAreaBottomMarginChanged(); + void activityPaused(); + void activityResumed(); + void isHomeAdLabelVisibleChanged(bool visible); void startMinimizedChanged(); diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index a95044d91..89b2bb98c 100644 --- a/client/ui/qml/main2.qml +++ b/client/ui/qml/main2.qml @@ -23,17 +23,25 @@ Window { if (Qt.application.state === Qt.ApplicationActive) { root.visible = true refreshTimer.restart() - } else if (Qt.application.state === Qt.ApplicationSuspended) { - // Hide window to stop the Qt render loop and prevent - // eglSwapBuffers from being called on a lost EGL context. - // NOTE: Do NOT hide on ApplicationInactive — that fires on any - // focus change (IME, notifications) and would blank the screen. - root.visible = false } } } } + // Hide the window immediately when Android Activity.onPause() fires so that + // Qt's render loop stops before the EGL surface is disconnected. This + // prevents "QRhiGles2: Failed to make context current" and the resulting + // black screen that appears after swiping home and returning. + Connections { + target: SettingsController + function onActivityPaused() { + if (Qt.platform.os === "android") root.visible = false + } + function onActivityResumed() { + if (Qt.platform.os === "android") root.visible = true + } + } + Timer { id: refreshTimer interval: 150 From f0f0f7c5bee5a53f16c121f31ba26c1c36ba69e3 Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Tue, 24 Mar 2026 17:45:02 +0300 Subject: [PATCH 10/11] feat: add subscription renewal (#2389) * feat: add renewal subsribe * fix: after review --- client/core/api/apiUtils.cpp | 3 + client/core/controllers/coreController.cpp | 2 + client/resources.qrc | 1 + .../controllers/api/apiConfigsController.cpp | 6 +- .../ui/controllers/api/apiConfigsController.h | 1 + .../controllers/api/apiSettingsController.cpp | 37 ++++++ .../controllers/api/apiSettingsController.h | 2 + client/ui/models/api/apiAccountInfoModel.cpp | 25 ++++ client/ui/models/api/apiAccountInfoModel.h | 6 +- .../Components/SubscriptionExpiredDrawer.qml | 113 ++++++++++++++++++ .../qml/Pages2/PageSettingsApiServerInfo.qml | 62 ++++++++++ client/ui/qml/main2.qml | 28 +++++ 12 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 client/ui/qml/Components/SubscriptionExpiredDrawer.qml diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index 2d16c384c..92d1e9854 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -96,6 +96,7 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl const int httpStatusCodeConflict = 409; const int httpStatusCodeNotFound = 404; const int httpStatusCodeNotImplemented = 501; + const int httpStatusCodeUnprocessableEntity = 422; if (!sslErrors.empty()) { qDebug().noquote() << sslErrors; @@ -128,6 +129,8 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl return amnezia::ErrorCode::ApiNotFoundError; } else if (httpStatusFromBody == httpStatusCodeNotImplemented) { return amnezia::ErrorCode::ApiUpdateRequestError; + } else if (httpStatusFromBody == httpStatusCodeUnprocessableEntity) { + return amnezia::ErrorCode::ApiSubscriptionExpiredError; } return amnezia::ErrorCode::ApiConfigDownloadError; } diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index c8ea65e5d..390baf5a8 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -153,6 +153,8 @@ void CoreController::initControllers() m_apiConfigsController.reset(new ApiConfigsController(m_serversModel, m_apiServicesModel, m_settings)); m_engine->rootContext()->setContextProperty("ApiConfigsController", m_apiConfigsController.get()); + connect(m_apiConfigsController.get(), &ApiConfigsController::subscriptionExpiredOnServer, + m_apiAccountInfoModel.get(), &ApiAccountInfoModel::setSubscriptionExpiredByServer); m_apiNewsController.reset(new ApiNewsController(m_newsModel, m_settings, m_serversModel, this)); m_engine->rootContext()->setContextProperty("ApiNewsController", m_apiNewsController.get()); diff --git a/client/resources.qrc b/client/resources.qrc index c050650eb..a1e4c656d 100644 --- a/client/resources.qrc +++ b/client/resources.qrc @@ -135,6 +135,7 @@ ui/qml/Components/InstalledAppsDrawer.qml ui/qml/Components/QuestionDrawer.qml ui/qml/Components/SelectLanguageDrawer.qml + ui/qml/Components/SubscriptionExpiredDrawer.qml ui/qml/Components/ServersListView.qml ui/qml/Components/SettingsContainersListView.qml ui/qml/Components/TransportProtoSelector.qml diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index c86342941..df0d16837 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -758,7 +758,11 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const } return true; } else { - emit errorOccurred(errorCode); + if (errorCode == ErrorCode::ApiSubscriptionExpiredError) { + emit subscriptionExpiredOnServer(); + } else { + emit errorOccurred(errorCode); + } return false; } } diff --git a/client/ui/controllers/api/apiConfigsController.h b/client/ui/controllers/api/apiConfigsController.h index dc6546426..8ca775b86 100644 --- a/client/ui/controllers/api/apiConfigsController.h +++ b/client/ui/controllers/api/apiConfigsController.h @@ -43,6 +43,7 @@ public slots: signals: void errorOccurred(ErrorCode errorCode); + void subscriptionExpiredOnServer(); void installServerFromApiFinished(const QString &message); void changeApiCountryFinished(const QString &message); diff --git a/client/ui/controllers/api/apiSettingsController.cpp b/client/ui/controllers/api/apiSettingsController.cpp index 59a68fd88..4e343a98b 100644 --- a/client/ui/controllers/api/apiSettingsController.cpp +++ b/client/ui/controllers/api/apiSettingsController.cpp @@ -1,6 +1,7 @@ #include "apiSettingsController.h" #include +#include #include #include "core/api/apiUtils.h" @@ -85,6 +86,42 @@ bool ApiSettingsController::getAccountInfo(bool reload) return true; } +void ApiSettingsController::getRenewalLink() +{ + auto processedIndex = m_serversModel->getProcessedServerIndex(); + auto serverConfig = m_serversModel->getServerConfig(processedIndex); + auto apiConfig = serverConfig.value(configKey::apiConfig).toObject(); + auto authData = serverConfig.value(configKey::authData).toObject(); + + bool isTestPurchase = apiConfig.value(apiDefs::key::isTestPurchase).toBool(false); + auto gatewayController = QSharedPointer::create(m_settings->getGatewayEndpoint(isTestPurchase), + m_settings->isDevGatewayEnv(isTestPurchase), + requestTimeoutMsecs, + m_settings->isStrictKillSwitchEnabled()); + + QJsonObject apiPayload; + apiPayload[configKey::userCountryCode] = apiConfig.value(configKey::userCountryCode).toString(); + apiPayload[configKey::serviceType] = apiConfig.value(configKey::serviceType).toString(); + apiPayload[configKey::authData] = authData; + apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION); + apiPayload[apiDefs::key::appLanguage] = m_settings->getAppLanguage().name().split("_").first(); + + auto future = gatewayController->postAsync(QString("%1v1/renewal_link"), apiPayload); + future.then(this, [this, gatewayController](QPair result) { + auto [errorCode, responseBody] = result; + if (errorCode != ErrorCode::NoError) { + emit errorOccurred(errorCode); + return; + } + + QJsonObject responseJson = QJsonDocument::fromJson(responseBody).object(); + QString url = responseJson.value("url").toString(); + if (!url.isEmpty()) { + emit renewalLinkReceived(url); + } + }); +} + void ApiSettingsController::updateApiCountryModel() { m_apiCountryModel->updateModel(m_apiAccountInfoModel->getAvailableCountries(), ""); diff --git a/client/ui/controllers/api/apiSettingsController.h b/client/ui/controllers/api/apiSettingsController.h index afe9a5705..5853fbd87 100644 --- a/client/ui/controllers/api/apiSettingsController.h +++ b/client/ui/controllers/api/apiSettingsController.h @@ -21,9 +21,11 @@ public slots: bool getAccountInfo(bool reload); void updateApiCountryModel(); void updateApiDevicesModel(); + void getRenewalLink(); signals: void errorOccurred(ErrorCode errorCode); + void renewalLinkReceived(const QString &url); private: QSharedPointer m_serversModel; diff --git a/client/ui/models/api/apiAccountInfoModel.cpp b/client/ui/models/api/apiAccountInfoModel.cpp index 65fc0083f..6c59fc907 100644 --- a/client/ui/models/api/apiAccountInfoModel.cpp +++ b/client/ui/models/api/apiAccountInfoModel.cpp @@ -1,5 +1,6 @@ #include "apiAccountInfoModel.h" +#include #include #include "core/api/apiUtils.h" @@ -75,6 +76,19 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const } return false; } + case IsSubscriptionExpiredRole: { + if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) return false; + if (m_isSubscriptionExpiredByServer) return true; + if (m_accountInfoData.subscriptionEndDate.isEmpty()) return false; + return apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate); + } + case IsSubscriptionExpiringSoonRole: { + if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) return false; + if (m_accountInfoData.subscriptionEndDate.isEmpty()) return false; + if (apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate)) return false; + QDateTime endDate = QDateTime::fromString(m_accountInfoData.subscriptionEndDate, Qt::ISODateWithMs); + return endDate <= QDateTime::currentDateTimeUtc().addDays(30); + } } return QVariant(); @@ -84,6 +98,8 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons { beginResetModel(); + m_isSubscriptionExpiredByServer = false; + AccountInfoData accountInfoData; m_availableCountries = accountInfoObject.value(apiDefs::key::availableCountries).toArray(); @@ -108,6 +124,13 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons endResetModel(); } +void ApiAccountInfoModel::setSubscriptionExpiredByServer() +{ + beginResetModel(); + m_isSubscriptionExpiredByServer = true; + endResetModel(); +} + QVariant ApiAccountInfoModel::data(const QString &roleString) { QModelIndex modelIndex = index(0); @@ -166,6 +189,8 @@ QHash ApiAccountInfoModel::roleNames() const roles[IsComponentVisibleRole] = "isComponentVisible"; roles[HasExpiredWorkerRole] = "hasExpiredWorker"; roles[IsProtocolSelectionSupportedRole] = "isProtocolSelectionSupported"; + roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired"; + roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon"; return roles; } diff --git a/client/ui/models/api/apiAccountInfoModel.h b/client/ui/models/api/apiAccountInfoModel.h index 836bc8926..fb04079c6 100644 --- a/client/ui/models/api/apiAccountInfoModel.h +++ b/client/ui/models/api/apiAccountInfoModel.h @@ -19,7 +19,9 @@ public: EndDateRole, IsComponentVisibleRole, HasExpiredWorkerRole, - IsProtocolSelectionSupportedRole + IsProtocolSelectionSupportedRole, + IsSubscriptionExpiredRole, + IsSubscriptionExpiringSoonRole }; explicit ApiAccountInfoModel(QObject *parent = nullptr); @@ -31,6 +33,7 @@ public: public slots: void updateModel(const QJsonObject &accountInfoObject, const QJsonObject &serverConfig); QVariant data(const QString &roleString); + void setSubscriptionExpiredByServer(); QJsonArray getAvailableCountries(); QJsonArray getIssuedConfigsInfo(); @@ -59,6 +62,7 @@ private: }; AccountInfoData m_accountInfoData; + bool m_isSubscriptionExpiredByServer = false; QJsonArray m_availableCountries; QJsonArray m_issuedConfigsInfo; QJsonObject m_supportInfo; diff --git a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml new file mode 100644 index 000000000..2a1adceda --- /dev/null +++ b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml @@ -0,0 +1,113 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +import PageEnum 1.0 +import Style 1.0 + +import "../Controls2" +import "../Controls2/TextTypes" + +DrawerType2 { + id: root + + expandedStateContent: ColumnLayout { + id: content + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + spacing: 0 + + onImplicitHeightChanged: { + root.expandedHeight = content.implicitHeight + 32 + SettingsController.safeAreaBottomMargin + } + + Item { + Layout.fillWidth: true + Layout.topMargin: 24 + Layout.rightMargin: 16 + Layout.leftMargin: 16 + implicitHeight: titleText.implicitHeight + + Header2TextType { + id: titleText + anchors.left: parent.left + anchors.right: icon.left + anchors.rightMargin: 8 + + text: qsTr("Amnezia Premium subscription has expired") + horizontalAlignment: Text.AlignLeft + } + + Image { + id: icon + anchors.right: parent.right + anchors.top: parent.top + width: 40 + height: 40 + source: "qrc:/images/controls/history.svg" + fillMode: Image.PreserveAspectFit + visible: false + } + + ColorOverlay { + anchors.fill: icon + source: icon + color: AmneziaStyle.color.goldenApricot + } + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.rightMargin: 16 + Layout.leftMargin: 16 + + text: qsTr("Renew your subscription to continue using VPN") + horizontalAlignment: Text.AlignLeft + } + + BasicButtonType { + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.rightMargin: 16 + Layout.leftMargin: 16 + + text: qsTr("Renew") + + defaultColor: AmneziaStyle.color.paleGray + hoveredColor: AmneziaStyle.color.lightGray + pressedColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.midnightBlack + + clickedFunc: function() { + ApiSettingsController.getRenewalLink() + } + } + + BasicButtonType { + Layout.fillWidth: true + Layout.topMargin: 4 + Layout.bottomMargin: 8 + Layout.rightMargin: 16 + Layout.leftMargin: 16 + + defaultColor: AmneziaStyle.color.transparent + hoveredColor: AmneziaStyle.color.translucentWhite + pressedColor: AmneziaStyle.color.sheerWhite + textColor: AmneziaStyle.color.goldenApricot + + text: qsTr("Support") + + clickedFunc: function() { + root.closeTriggered() + PageController.goToPage(PageEnum.PageSettingsApiSupport) + } + } + } +} diff --git a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml index 532ab6a10..140b17f29 100644 --- a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml +++ b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml @@ -52,6 +52,26 @@ PageType { property var processedServer + property bool isSubscriptionExpired: false + property bool isSubscriptionExpiringSoon: false + + function updateSubscriptionState() { + root.isSubscriptionExpired = ApiAccountInfoModel.data("isSubscriptionExpired") + root.isSubscriptionExpiringSoon = ApiAccountInfoModel.data("isSubscriptionExpiringSoon") + } + + Component.onCompleted: { + root.updateSubscriptionState() + } + + Connections { + target: ApiAccountInfoModel + + function onModelReset() { + root.updateSubscriptionState() + } + } + Connections { target: ServersModel @@ -114,6 +134,48 @@ PageType { serverNameEditDrawer.openTriggered() } } + + Text { + visible: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon + + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 4 + + text: root.isSubscriptionExpired + ? qsTr("Subscription expired") + : qsTr("Subscription expiring soon") + + color: root.isSubscriptionExpired + ? AmneziaStyle.color.vibrantRed + : AmneziaStyle.color.goldenApricot + + font.pixelSize: 14 + font.weight: Font.Medium + wrapMode: Text.WordWrap + } + + BasicButtonType { + visible: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon + + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + Layout.bottomMargin: 8 + + text: qsTr("Renew subscription") + + defaultColor: AmneziaStyle.color.paleGray + hoveredColor: AmneziaStyle.color.lightGray + pressedColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.midnightBlack + + clickedFunc: function() { + ApiSettingsController.getRenewalLink() + } + } } delegate: ColumnLayout { diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index 89b2bb98c..147f90b8b 100644 --- a/client/ui/qml/main2.qml +++ b/client/ui/qml/main2.qml @@ -288,6 +288,34 @@ Window { } } + Item { + objectName: "subscriptionExpiredDrawerItem" + + anchors.fill: parent + + SubscriptionExpiredDrawer { + id: subscriptionExpiredDrawer + + anchors.fill: parent + } + } + + Connections { + target: ApiConfigsController + + function onSubscriptionExpiredOnServer() { + subscriptionExpiredDrawer.openTriggered() + } + } + + Connections { + target: ApiSettingsController + + function onRenewalLinkReceived(url) { + Qt.openUrlExternally(url) + } + } + Item { objectName: "busyIndicatorItem" From 9a0222aee3f1e98f0298af827227c30c396990ca Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Wed, 25 Mar 2026 07:34:42 +0300 Subject: [PATCH 11/11] fix: ui fixes for renewal subscription (#2406) --- .../src/org/amnezia/vpn/AmneziaActivity.kt | 16 ++--- client/core/controllers/gatewayController.cpp | 3 + client/ui/models/api/apiAccountInfoModel.cpp | 2 +- client/ui/qml/Components/ServersListView.qml | 28 ++++++++ .../Components/SubscriptionExpiredDrawer.qml | 28 ++------ .../ui/qml/Controls2/LabelWithButtonType.qml | 10 +++ .../PageSettingsApiAvailableCountries.qml | 72 ++++++++++++++++++- .../qml/Pages2/PageSettingsApiServerInfo.qml | 71 +++++++++++++++++- 8 files changed, 193 insertions(+), 37 deletions(-) diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index 1d2e09ccb..ccdc22140 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -337,26 +337,26 @@ class AmneziaActivity : QtActivity() { private external fun nativeGamepadKeyEvent(deviceId: Int, keyCode: Int, pressed: Boolean) override fun onPause() { + // Notify Qt to stop rendering BEFORE super.onPause() destroys the EGL surface. + // Using a coroutine here would be too late — the surface is gone by the time + // the coroutine runs. A direct synchronous call gives Qt's render thread the + // best chance to process visible=false before surface destruction. + if (qtInitialized.isCompleted) { + QtAndroidController.onActivityPaused() + } super.onPause() isActivityResumed = false // Cancel all pending operations when activity pauses resumeHandler.removeCallbacksAndMessages(null) openFileDeliveryScheduled = false Log.d(TAG, "Pause Amnezia activity") - // Notify Qt to stop rendering before the EGL surface is disconnected - mainScope.launch { - qtInitialized.await() - QtAndroidController.onActivityPaused() - } } override fun onResume() { super.onResume() isActivityResumed = true Log.d(TAG, "Resume Amnezia activity") - // Notify Qt to resume rendering after surface reconnects - mainScope.launch { - qtInitialized.await() + if (qtInitialized.isCompleted) { QtAndroidController.onActivityResumed() } diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 25a40c460..4a2fe8d22 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -46,6 +46,7 @@ namespace constexpr int httpStatusCodeConflict = 409; constexpr int httpStatusCodeNotImplemented = 501; + constexpr int httpStatusCodeUnprocessableEntity = 422; } GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs, @@ -451,6 +452,8 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep } } else if (httpStatus == httpStatusCodeConflict) { return false; + } else if (httpStatus == httpStatusCodeUnprocessableEntity) { + return false; } else if (replyError != QNetworkReply::NetworkError::NoError) { qDebug() << replyError; return true; diff --git a/client/ui/models/api/apiAccountInfoModel.cpp b/client/ui/models/api/apiAccountInfoModel.cpp index 6c59fc907..3bd6c80ca 100644 --- a/client/ui/models/api/apiAccountInfoModel.cpp +++ b/client/ui/models/api/apiAccountInfoModel.cpp @@ -33,7 +33,7 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const } return apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate) ? tr("

Inactive") - : tr("Active"); + : tr("

Active"); } case EndDateRole: { if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) { diff --git a/client/ui/qml/Components/ServersListView.qml b/client/ui/qml/Components/ServersListView.qml index 4417e0b29..47eceb3f8 100644 --- a/client/ui/qml/Components/ServersListView.qml +++ b/client/ui/qml/Components/ServersListView.qml @@ -19,6 +19,15 @@ ListViewType { id: root property int selectedIndex: ServersModel.defaultIndex + property int expiredServerIndex: -1 + property bool expiringSoon: false + + Connections { + target: ApiAccountInfoModel + function onModelReset() { + root.expiringSoon = ApiAccountInfoModel.data("isSubscriptionExpiringSoon") + } + } anchors.top: serversMenuHeader.bottom anchors.right: parent.right @@ -35,6 +44,13 @@ ListViewType { } } + Connections { + target: ApiConfigsController + function onSubscriptionExpiredOnServer() { + root.expiredServerIndex = ServersModel.defaultIndex + } + } + delegate: Item { id: menuContentDelegate objectName: "menuContentDelegate" @@ -126,6 +142,18 @@ ListViewType { } } + CaptionTextType { + visible: isServerFromGatewayApi && (index === root.expiredServerIndex || (root.expiringSoon && index === root.selectedIndex && index !== root.expiredServerIndex)) + + Layout.fillWidth: true + Layout.leftMargin: 64 + Layout.bottomMargin: 8 + + text: index === root.expiredServerIndex ? qsTr("Subscription expired. Please renew.") : qsTr("Subscription expiring soon.") + color: index === root.expiredServerIndex ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot + wrapMode: Text.WordWrap + } + DividerType { Layout.fillWidth: true Layout.leftMargin: 0 diff --git a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml index 2a1adceda..97fb01b85 100644 --- a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml +++ b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml @@ -37,29 +37,11 @@ DrawerType2 { Header2TextType { id: titleText anchors.left: parent.left - anchors.right: icon.left - anchors.rightMargin: 8 + anchors.right: parent.right text: qsTr("Amnezia Premium subscription has expired") horizontalAlignment: Text.AlignLeft } - - Image { - id: icon - anchors.right: parent.right - anchors.top: parent.top - width: 40 - height: 40 - source: "qrc:/images/controls/history.svg" - fillMode: Image.PreserveAspectFit - visible: false - } - - ColorOverlay { - anchors.fill: icon - source: icon - color: AmneziaStyle.color.goldenApricot - } } ParagraphTextType { @@ -91,11 +73,11 @@ DrawerType2 { } BasicButtonType { - Layout.fillWidth: true - Layout.topMargin: 4 + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 8 Layout.bottomMargin: 8 - Layout.rightMargin: 16 - Layout.leftMargin: 16 + + implicitHeight: 25 defaultColor: AmneziaStyle.color.transparent hoveredColor: AmneziaStyle.color.translucentWhite diff --git a/client/ui/qml/Controls2/LabelWithButtonType.qml b/client/ui/qml/Controls2/LabelWithButtonType.qml index 6a4a88106..24844bda5 100644 --- a/client/ui/qml/Controls2/LabelWithButtonType.qml +++ b/client/ui/qml/Controls2/LabelWithButtonType.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import Qt5Compat.GraphicalEffects import Style 1.0 @@ -37,6 +38,7 @@ Item { property int borderFocusedWidth: 1 property string rightImageColor: AmneziaStyle.color.paleGray + property string leftImageColor: "" property bool descriptionOnTop: false property bool hideDescription: true @@ -140,6 +142,14 @@ Item { anchors.centerIn: parent source: leftImageSource + visible: leftImageColor === "" + } + + ColorOverlay { + anchors.fill: leftImage + source: leftImage + color: leftImageColor + visible: leftImageColor !== "" } } diff --git a/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml b/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml index d2787f590..93599d960 100644 --- a/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml +++ b/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml @@ -18,6 +18,22 @@ PageType { id: root property var processedServer + property bool subscriptionExpired: false + property bool subscriptionExpiringSoon: false + function updateSubscriptionState() { + root.subscriptionExpiringSoon = ApiAccountInfoModel.data("isSubscriptionExpiringSoon") + } + + Component.onCompleted: { + root.updateSubscriptionState() + } + + Connections { + target: ApiAccountInfoModel + function onModelReset() { + root.updateSubscriptionState() + } + } Connections { target: ServersModel @@ -27,6 +43,15 @@ PageType { } } + Connections { + target: ApiConfigsController + + function onSubscriptionExpiredOnServer() { + root.subscriptionExpired = true + root.subscriptionExpiringSoon = false + } + } + SortFilterProxyModel { id: proxyServersModel objectName: "proxyServersModel" @@ -76,12 +101,11 @@ PageType { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.bottomMargin: 10 + Layout.bottomMargin: 4 actionButtonImage: "qrc:/images/controls/settings.svg" headerText: root.processedServer.name - descriptionText: qsTr("Location for connection") actionButtonFunction: function() { PageController.showBusyIndicator(true) @@ -94,6 +118,50 @@ PageType { PageController.goToPage(PageEnum.PageSettingsApiServerInfo) } } + + CaptionTextType { + visible: root.subscriptionExpired || root.subscriptionExpiringSoon + + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 4 + + text: root.subscriptionExpired ? qsTr("Subscription expired") : qsTr("Subscription expiring soon") + color: root.subscriptionExpired ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot + } + + BasicButtonType { + visible: root.subscriptionExpired || root.subscriptionExpiringSoon + + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + Layout.bottomMargin: 4 + + defaultColor: AmneziaStyle.color.paleGray + hoveredColor: AmneziaStyle.color.lightGray + pressedColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.midnightBlack + + text: qsTr("Renew subscription") + + clickedFunc: function() { + ApiSettingsController.getRenewalLink() + } + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: (root.subscriptionExpired || root.subscriptionExpiringSoon) ? 8 : 4 + Layout.bottomMargin: 8 + + text: qsTr("Location for connection") + color: AmneziaStyle.color.mutedGray + } } delegate: ColumnLayout { diff --git a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml index 140b17f29..7e44138a5 100644 --- a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml +++ b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQuick.Dialogs +import Qt5Compat.GraphicalEffects import SortFilterProxyModel 0.2 @@ -128,7 +129,6 @@ PageType { actionButtonImage: "qrc:/images/controls/edit-3.svg" headerText: root.processedServer.name - descriptionText: ApiAccountInfoModel.data("serviceDescription") actionButtonFunction: function() { serverNameEditDrawer.openTriggered() @@ -156,6 +156,19 @@ PageType { wrapMode: Text.WordWrap } + ParagraphTextType { + visible: ApiAccountInfoModel.data("serviceDescription") !== "" + + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon ? 0 : 10 + + text: ApiAccountInfoModel.data("serviceDescription") + color: AmneziaStyle.color.mutedGray + } + BasicButtonType { visible: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon @@ -213,6 +226,54 @@ PageType { readonly property bool isVisibleForAmneziaFree: ApiAccountInfoModel.data("isComponentVisible") + Item { + visible: !root.isSubscriptionExpired && !root.isSubscriptionExpiringSoon + + Layout.fillWidth: true + implicitHeight: renewRow.implicitHeight + 32 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: ApiSettingsController.getRenewalLink() + } + + Row { + id: renewRow + anchors.centerIn: parent + spacing: 12 + + Item { + width: renewIcon.implicitWidth + height: renewIcon.implicitHeight + anchors.verticalCenter: parent.verticalCenter + + Image { + id: renewIcon + source: "qrc:/images/controls/refresh-cw.svg" + } + + ColorOverlay { + anchors.fill: renewIcon + source: renewIcon + color: AmneziaStyle.color.goldenApricot + } + } + + Text { + text: qsTr("Renew subscription") + color: AmneziaStyle.color.goldenApricot + font.pixelSize: 18 + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + } + + DividerType { + visible: !root.isSubscriptionExpired && !root.isSubscriptionExpiringSoon + } + SwitcherType { id: switcher @@ -239,10 +300,14 @@ PageType { } } + DividerType { + visible: footer.isVisibleForAmneziaFree + } + WarningType { id: warning - Layout.topMargin: 32 + Layout.topMargin: 24 Layout.rightMargin: 16 Layout.leftMargin: 16 Layout.fillWidth: true @@ -266,7 +331,7 @@ PageType { id: vpnKey Layout.fillWidth: true - Layout.topMargin: warning.visible ? 16 : 32 + Layout.topMargin: warning.visible ? 16 : 0 visible: footer.isVisibleForAmneziaFree