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 diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index daedfda3f..ccdc22140 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -337,6 +337,13 @@ 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 @@ -349,6 +356,9 @@ class AmneziaActivity : QtActivity() { super.onResume() isActivityResumed = true Log.d(TAG, "Resume Amnezia activity") + if (qtInitialized.isCompleted) { + QtAndroidController.onActivityResumed() + } if (pendingOpenFileUri != null && !openFileDeliveryScheduled) { val uri = pendingOpenFileUri!! @@ -816,7 +826,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 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/core/api/apiDefs.h b/client/core/api/apiDefs.h index 8ec919b8c..78e8031fc 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 { @@ -32,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/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index 3ea56438b..67deab6b8 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: { @@ -91,6 +97,7 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl const int httpStatusCodeNotFound = 404; const int httpStatusCodeNotImplemented = 501; const int httpStatusCodePaymentRequired = 402; + const int httpStatusCodeUnprocessableEntity = 422; if (!sslErrors.empty()) { qDebug().noquote() << sslErrors; @@ -127,6 +134,9 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl if (httpStatusFromBody == httpStatusCodeNotImplemented) { return amnezia::ErrorCode::ApiUpdateRequestError; } + if (httpStatusFromBody == httpStatusCodeUnprocessableEntity) { + return amnezia::ErrorCode::ApiSubscriptionExpiredError; + } if (httpStatusFromBody == httpStatusCodePaymentRequired) { return amnezia::ErrorCode::ApiSubscriptionNotActiveError; } @@ -140,7 +150,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)); } @@ -184,7 +195,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/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/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 8b82e56c7..16a03b0af 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 httpStatusCodePaymentRequired = 402; + constexpr int httpStatusCodeUnprocessableEntity = 422; } GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs, @@ -333,9 +334,14 @@ QStringList GatewayController::getProxyUrls(const QString &serviceType, const QS QStringList baseUrls; if (m_isDevEnvironment) { - baseUrls = QString(DEV_S3_ENDPOINT).split(", "); + baseUrls = QString(DEV_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); } else { - baseUrls = QString(PROD_S3_ENDPOINT).split(", "); + baseUrls = QString(PROD_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); + } + + if (baseUrls.empty()) { + qDebug() << "empty storage endpoint list"; + return {}; } std::random_device randomDevice; std::mt19937 generator(randomDevice()); @@ -431,10 +437,12 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep qDebug() << "timeout occurred"; qDebug() << replyError; return true; - } else if (responseBody.contains("html")) { + } + if (responseBody.contains("html")) { qDebug() << "the response contains an html tag"; return true; - } else if (httpStatus == httpStatusCodeNotFound) { + } + if (httpStatus == httpStatusCodeNotFound) { if (responseBody.contains(errorResponsePattern1) || responseBody.contains(errorResponsePattern2) || responseBody.contains(errorResponsePattern3)) { return false; @@ -442,18 +450,25 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep qDebug() << replyError; return true; } - } else if (httpStatus == httpStatusCodeNotImplemented) { + } + if (httpStatus == httpStatusCodeNotImplemented) { if (responseBody.contains(updateRequestResponsePattern)) { return false; } else { qDebug() << replyError; return true; } - } else if (httpStatus == httpStatusCodeConflict) { + } + if (httpStatus == httpStatusCodeConflict) { return false; - } else if (httpStatus == httpStatusCodePaymentRequired) { + } + if (httpStatus == httpStatusCodePaymentRequired) { return false; - } else if (replyError != QNetworkReply::NetworkError::NoError) { + } + if (httpStatus == httpStatusCodeUnprocessableEntity) { + return false; + } + if (replyError != QNetworkReply::NetworkError::NoError) { qDebug() << replyError; return true; } 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/platforms/ios/PacketTunnelProvider+OpenVPN.swift b/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift index 118545c2c..882ad578d 100644 --- a/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift +++ b/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift @@ -126,8 +126,7 @@ extension PacketTunnelProvider { } vpnReachability.startTracking { [weak self] status in - guard status == .reachableViaWiFi else { return } - self?.ovpnAdapter?.reconnect(afterTimeInterval: 5) + self?.handleOpenVPNReachabilityChange(status) } startHandler = completionHandler 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/PacketTunnelProvider.swift b/client/platforms/ios/PacketTunnelProvider.swift index 8a6784133..e80bbb05d 100644 --- a/client/platforms/ios/PacketTunnelProvider.swift +++ b/client/platforms/ios/PacketTunnelProvider.swift @@ -41,10 +41,15 @@ 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 pendingOpenVPNReconnectWorkItem: DispatchWorkItem? + private var pendingNetworkChangeWorkItem: DispatchWorkItem? + private var isApplyingNetworkChange = false + private var lastOpenVPNReachabilityStatus: OpenVPNReachabilityStatus? var splitTunnelType: Int? var splitTunnelSites: [String]? @@ -78,14 +83,22 @@ class PacketTunnelProvider: NEPacketTunnelProvider { guard hasMeaningfulChange, let proto = self.protoType else { return } - // WireGuard/AWG manages network changes internally; avoid restarting the tunnel here. + // WireGuard/AWG manages network changes internally in its own adapter. if proto == .wireguard { return } - DispatchQueue.main.async { - self.handle(networkChange: path) { _ in } + 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 + } + + self.scheduleNetworkChangeHandling(for: proto, path: path) } pathMonitor.start(queue: pathMonitorQueue) @@ -197,6 +210,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } + cancelPendingOpenVPNReconnect() + cancelPendingNetworkChangeHandling() didReceiveInitialPathUpdate = false updateActiveInterfaceIndexForCurrentPath() @@ -215,6 +230,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + cancelPendingOpenVPNReconnect() + cancelPendingNetworkChangeHandling() + guard let protoType else { completionHandler() return @@ -259,9 +277,111 @@ 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 } + self.pendingNetworkChangeWorkItem = nil + + if self.isApplyingNetworkChange || self.reasserting { + 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) + } + + 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 } } @@ -271,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 } @@ -293,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)") 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 9d5a172e9..251f0034a 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -690,6 +690,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); 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 f5b85cc64..a41b40320 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; @@ -446,7 +448,7 @@ bool ApiConfigsController::importService() if (isIosOrMacOsNe) { return importSerivceFromAppStore(); } - } else { + } else if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaFree) { importServiceFromGateway(); return true; } @@ -776,7 +778,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 10549bb9f..5d26e30da 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, int preferredDefaultServerIndex = -1); 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/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/models/api/apiAccountInfoModel.cpp b/client/ui/models/api/apiAccountInfoModel.cpp index 0f3a8a4ed..3bd6c80ca 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" @@ -32,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) { @@ -52,7 +53,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++) { @@ -73,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(); @@ -82,6 +98,8 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons { beginResetModel(); + m_isSubscriptionExpiredByServer = false; + AccountInfoData accountInfoData; m_availableCountries = accountInfoObject.value(apiDefs::key::availableCountries).toArray(); @@ -106,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); @@ -164,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/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/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 new file mode 100644 index 000000000..97fb01b85 --- /dev/null +++ b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml @@ -0,0 +1,95 @@ +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: parent.right + + text: qsTr("Amnezia Premium subscription has expired") + horizontalAlignment: Text.AlignLeft + } + } + + 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.alignment: Qt.AlignHCenter + Layout.topMargin: 8 + Layout.bottomMargin: 8 + + implicitHeight: 25 + + 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/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 532ab6a10..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 @@ -52,6 +53,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 @@ -108,12 +129,66 @@ PageType { actionButtonImage: "qrc:/images/controls/edit-3.svg" headerText: root.processedServer.name - descriptionText: ApiAccountInfoModel.data("serviceDescription") actionButtonFunction: function() { 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 + } + + 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 + + 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 { @@ -151,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 @@ -177,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 @@ -204,7 +331,7 @@ PageType { id: vpnKey Layout.fillWidth: true - Layout.topMargin: warning.visible ? 16 : 32 + Layout.topMargin: warning.visible ? 16 : 0 visible: footer.isVisibleForAmneziaFree 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 + } +} 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 { diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index a95044d91..147f90b8b 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 @@ -280,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"