From c86a641e05dce7df38f5990d00eece44a973eb19 Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Tue, 3 Mar 2026 10:14:51 +0300 Subject: [PATCH 01/36] fix: add suppord android 9 gamepad and remote control (#2302) --- .../src/org/amnezia/vpn/AmneziaActivity.kt | 139 +++++++++++------- 1 file changed, 82 insertions(+), 57 deletions(-) diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index 293f21ea..e8613d58 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -92,7 +92,7 @@ class AmneziaActivity : QtActivity() { private val actionResultHandlers = mutableMapOf() private val permissionRequestHandlers = mutableMapOf() - + private var isActivityResumed = false private var hasWindowFocus = false private val resumeHandler = Handler(Looper.getMainLooper()) @@ -295,7 +295,7 @@ class AmneziaActivity : QtActivity() { super.onWindowFocusChanged(hasFocus) hasWindowFocus = hasFocus Log.d(TAG, "Window focus changed: hasFocus=$hasFocus") - + // Cancel pending operations if window loses focus if (!hasFocus) { resumeHandler.removeCallbacksAndMessages(null) @@ -309,68 +309,93 @@ class AmneziaActivity : QtActivity() { val source = event.source if (deviceId < 0 && pressed) { - when (keyCode) { - KeyEvent.KEYCODE_BUTTON_A, - KeyEvent.KEYCODE_BUTTON_B, - KeyEvent.KEYCODE_BUTTON_X, - KeyEvent.KEYCODE_BUTTON_Y, - KeyEvent.KEYCODE_BUTTON_START, - KeyEvent.KEYCODE_BUTTON_SELECT -> { - nativeGamepadKeyEvent(0, keyCode, true) - nativeGamepadKeyEvent(0, keyCode, false) - return true - } - KeyEvent.KEYCODE_DPAD_CENTER -> { - if (isOnTv()) { - val down = KeyEvent( - event.downTime, - event.eventTime, - KeyEvent.ACTION_DOWN, - KeyEvent.KEYCODE_ENTER, - 0, - event.metaState, - 0, - event.scanCode, - event.flags, - event.source - ) - val up = KeyEvent( - event.downTime, - event.eventTime, - KeyEvent.ACTION_UP, - KeyEvent.KEYCODE_ENTER, - 0, - event.metaState, - 0, - event.scanCode, - event.flags, - event.source - ) - super.dispatchKeyEvent(down) - super.dispatchKeyEvent(up) - return true - } - nativeGamepadKeyEvent(0, keyCode, true) - nativeGamepadKeyEvent(0, keyCode, false) - return true - } - } + when (keyCode) { + KeyEvent.KEYCODE_BUTTON_A, + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BUTTON_X, + KeyEvent.KEYCODE_BUTTON_Y, + KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_BUTTON_SELECT -> { + nativeGamepadKeyEvent(0, keyCode, true) + nativeGamepadKeyEvent(0, keyCode, false) + return true + } + KeyEvent.KEYCODE_DPAD_CENTER -> { + if (isOnTv()) { + val down = KeyEvent( + event.downTime, + event.eventTime, + KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_ENTER, + 0, + event.metaState, + 0, + event.scanCode, + event.flags, + event.source + ) + val up = KeyEvent( + event.downTime, + event.eventTime, + KeyEvent.ACTION_UP, + KeyEvent.KEYCODE_ENTER, + 0, + event.metaState, + 0, + event.scanCode, + event.flags, + event.source + ) + super.dispatchKeyEvent(down) + super.dispatchKeyEvent(up) + return true + } + nativeGamepadKeyEvent(0, keyCode, true) + nativeGamepadKeyEvent(0, keyCode, false) + return true + } + } } - // Real gamepad events (deviceId >= 0) + // Real devices (remotes and gamepads) have deviceId >= 0. if (deviceId >= 0) { val isGamepad = (source and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD val isJoystick = (source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK val isDpad = (source and InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD + if (isGamepad || isJoystick || isDpad) { - nativeGamepadKeyEvent(deviceId, keyCode, pressed) - return true + when (keyCode) { + KeyEvent.KEYCODE_BUTTON_A, + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BUTTON_X, + KeyEvent.KEYCODE_BUTTON_Y, + KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_BUTTON_SELECT, + KeyEvent.KEYCODE_DPAD_CENTER -> { + nativeGamepadKeyEvent(0, keyCode, pressed) + return true + } + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT -> { + val synthetic = KeyEvent( + event.downTime, event.eventTime, event.action, event.keyCode, + event.repeatCount, event.metaState, -1, event.scanCode, + event.flags, InputDevice.SOURCE_KEYBOARD + ) + return super.dispatchKeyEvent(synthetic) + } + } + return super.dispatchKeyEvent(event) } } return super.dispatchKeyEvent(event) } + + private external fun nativeGamepadKeyEvent(deviceId: Int, keyCode: Int, pressed: Boolean) override fun onPause() { @@ -412,13 +437,13 @@ class AmneziaActivity : QtActivity() { sendTouch(1f, 1f) } }, 100) - + resumeHandler.postDelayed({ if (isActivityResumed && hasWindowFocus && !isFinishing && !isDestroyed) { sendTouch(2f, 2f) } }, 200) - + resumeHandler.postDelayed({ if (isActivityResumed && hasWindowFocus && !isFinishing && !isDestroyed) { requestLayout() @@ -464,25 +489,25 @@ class AmneziaActivity : QtActivity() { ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { view, windowInsets -> val imeInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime()) val imeVisible = windowInsets.isVisible(WindowInsetsCompat.Type.ime()) - + val imeHeight = if (imeVisible) imeInsets.bottom else 0 val density = resources.displayMetrics.density val imeHeightDp = (imeHeight / density).toInt() - + // Also track system bars (navigation bar, status bar) changes val systemBarsInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) val navBarHeight = systemBarsInsets.bottom val navBarHeightDp = (navBarHeight / density).toInt() val statusBarHeight = systemBarsInsets.top val statusBarHeightDp = (statusBarHeight / density).toInt() - + mainScope.launch { qtInitialized.await() QtAndroidController.onImeInsetsChanged(imeHeightDp) QtAndroidController.onSystemBarsInsetsChanged(navBarHeightDp, statusBarHeightDp) } - + // Return windowInsets instead of CONSUMED to allow proper handling windowInsets } From 1892db4375ddcb0cd8367f5eca87931174816556 Mon Sep 17 00:00:00 2001 From: vkamn Date: Tue, 3 Mar 2026 19:58:32 +0700 Subject: [PATCH 02/36] fix: remove nested qeventloop from isConfigValid (also rename to validateConfig) (#2305) * fix: remove nested qeventloop from isConfigValid (also rename to validateConfig) * chore: bump version --- CMakeLists.txt | 4 +- client/cmake/macos_ne.cmake | 2 +- client/core/controllers/coreController.cpp | 6 +- client/ui/controllers/installController.cpp | 111 +++++++++++--------- client/ui/controllers/installController.h | 3 +- 5 files changed, 73 insertions(+), 53 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 07808e0d..78279e1f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR) set(PROJECT AmneziaVPN) -set(AMNEZIAVPN_VERSION 4.8.14.1) +set(AMNEZIAVPN_VERSION 4.8.14.3) project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION} DESCRIPTION "AmneziaVPN" @@ -12,7 +12,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d") set(RELEASE_DATE "${CURRENT_DATE}") set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}) -set(APP_ANDROID_VERSION_CODE 2112) +set(APP_ANDROID_VERSION_CODE 2114) if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux") set(MZ_PLATFORM_NAME "linux") diff --git a/client/cmake/macos_ne.cmake b/client/cmake/macos_ne.cmake index 74905375..02dfb412 100644 --- a/client/cmake/macos_ne.cmake +++ b/client/cmake/macos_ne.cmake @@ -163,7 +163,7 @@ add_custom_command(TARGET ${PROJECT} POST_BUILD COMMAND ${CMAKE_COMMAND} -E make_directory $/Contents/Frameworks COMMAND /usr/bin/find "$/Contents/Frameworks/OpenVPNAdapter.framework" -name "*.sha256" -delete - COMMAND /usr/bin/codesign --force --sign "Apple Distribution" + COMMAND /usr/bin/codesign --force --sign "Apple Distribution: Privacy Technologies OU" "$/Contents/Frameworks/OpenVPNAdapter.framework/Versions/Current/OpenVPNAdapter" COMMAND ${QT_BIN_DIR_DETECTED}/macdeployqt $ -appstore-compliant -qmldir=${CMAKE_CURRENT_SOURCE_DIR} COMMENT "Signing OpenVPNAdapter framework" diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index 47880ee9..c8ea65e5 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -368,7 +368,11 @@ void CoreController::initPrepareConfigHandler() return; } - if (!m_installController->isConfigValid()) { + m_installController->validateConfig(); + }); + + connect(m_installController.get(), &InstallController::configValidated, this, [this](bool isValid) { + if (!isValid) { emit m_vpnConnection->connectionStateChanged(Vpn::ConnectionState::Disconnected); return; } diff --git a/client/ui/controllers/installController.cpp b/client/ui/controllers/installController.cpp index e15f5db2..8e1e198f 100644 --- a/client/ui/controllers/installController.cpp +++ b/client/ui/controllers/installController.cpp @@ -987,79 +987,94 @@ void InstallController::addEmptyServer() emit installServerFinished(tr("Server added successfully")); } -bool InstallController::isConfigValid() +void InstallController::validateConfig() { int serverIndex = m_serversModel->getDefaultServerIndex(); QJsonObject serverConfigObject = m_serversModel->getServerConfig(serverIndex); if (apiUtils::isServerFromApi(serverConfigObject)) { - return true; + emit configValidated(true); + return; } if (!m_serversModel->data(serverIndex, ServersModel::Roles::HasInstalledContainers).toBool()) { emit noInstalledContainers(); - return false; + emit configValidated(false); + return; } DockerContainer container = qvariant_cast(m_serversModel->data(serverIndex, ServersModel::Roles::DefaultContainerRole)); if (container == DockerContainer::None) { emit installationErrorOccurred(ErrorCode::NoInstalledContainersError); - return false; + emit configValidated(false); + return; } - QSharedPointer serverController(new ServerController(m_settings)); - VpnConfigurationsController vpnConfigurationController(m_settings, serverController); - QJsonObject containerConfig = m_containersModel->getContainerConfig(container); ServerCredentials credentials = m_serversModel->getServerCredentials(serverIndex); + QSharedPointer serverController(new ServerController(m_settings)); - QFutureWatcher watcher; + auto isProtocolConfigExists = [](const QJsonObject &containerConfig, const DockerContainer container) { + for (Proto protocol : ContainerProps::protocolsForContainer(container)) { + QString protocolConfig = + containerConfig.value(ProtocolProps::protoToString(protocol)).toObject().value(config_key::last_config).toString(); - QFuture future = QtConcurrent::run([this, container, &credentials, &containerConfig, &serverController]() { - ErrorCode errorCode = ErrorCode::NoError; - - auto isProtocolConfigExists = [](const QJsonObject &containerConfig, const DockerContainer container) { - for (Proto protocol : ContainerProps::protocolsForContainer(container)) { - QString protocolConfig = - containerConfig.value(ProtocolProps::protoToString(protocol)).toObject().value(config_key::last_config).toString(); - - if (protocolConfig.isEmpty()) { - return false; - } - } - return true; - }; - - if (!isProtocolConfigExists(containerConfig, container)) { - VpnConfigurationsController vpnConfigurationController(m_settings, serverController); - errorCode = vpnConfigurationController.createProtocolConfigForContainer(credentials, container, containerConfig); - if (errorCode != ErrorCode::NoError) { - return errorCode; - } - m_serversModel->updateContainerConfig(container, containerConfig); - - errorCode = m_clientManagementModel->appendClient(container, credentials, containerConfig, - QString("Admin [%1]").arg(QSysInfo::prettyProductName()), serverController); - if (errorCode != ErrorCode::NoError) { - return errorCode; + if (protocolConfig.isEmpty()) { + return false; } } - return errorCode; - }); + return true; + }; - QEventLoop wait; - connect(&watcher, &QFutureWatcher::finished, &wait, &QEventLoop::quit); - watcher.setFuture(future); - wait.exec(); - - ErrorCode errorCode = watcher.result(); - - if (errorCode != ErrorCode::NoError) { - emit installationErrorOccurred(errorCode); - return false; + if (isProtocolConfigExists(containerConfig, container)) { + emit configValidated(true); + return; } - return true; + + struct ValidationResult { + ErrorCode errorCode = ErrorCode::NoError; + QJsonObject containerConfig; + }; + + QFuture future = + QtConcurrent::run([settings = m_settings, serverController, credentials, containerConfig, container]() mutable { + ValidationResult result; + result.containerConfig = containerConfig; + + VpnConfigurationsController vpnConfigurationController(settings, serverController); + result.errorCode = vpnConfigurationController.createProtocolConfigForContainer(credentials, container, + result.containerConfig); + return result; + }); + + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, + [this, watcher, container, credentials, serverController]() { + auto result = watcher->result(); + watcher->deleteLater(); + + if (result.errorCode != ErrorCode::NoError) { + emit installationErrorOccurred(result.errorCode); + emit configValidated(false); + return; + } + + m_serversModel->updateContainerConfig(container, result.containerConfig); + + ErrorCode appendError = m_clientManagementModel->appendClient( + container, credentials, result.containerConfig, + QString("Admin [%1]").arg(QSysInfo::prettyProductName()), serverController); + + if (appendError != ErrorCode::NoError) { + emit installationErrorOccurred(appendError); + emit configValidated(false); + return; + } + + emit configValidated(true); + }); + watcher->setFuture(future); } bool InstallController::isUpdateDockerContainerRequired(const DockerContainer container, const QJsonObject &oldConfig, diff --git a/client/ui/controllers/installController.h b/client/ui/controllers/installController.h index d18ba946..034aa849 100644 --- a/client/ui/controllers/installController.h +++ b/client/ui/controllers/installController.h @@ -50,9 +50,10 @@ public slots: void addEmptyServer(); - bool isConfigValid(); + void validateConfig(); signals: + void configValidated(bool isValid); void installContainerFinished(const QString &finishMessage, bool isServiceInstall); void installServerFinished(const QString &finishMessage); From aea8ff4961ecbad52bf03c51d2c7ef5ce1362d70 Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Tue, 3 Mar 2026 17:04:45 +0300 Subject: [PATCH 03/36] fix: add handle handleContextCreationFailure (#2309) --- client/ui/qml/main2.qml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index bb7a675a..1ab54960 100644 --- a/client/ui/qml/main2.qml +++ b/client/ui/qml/main2.qml @@ -56,6 +56,11 @@ Window { PageController.closeWindow() } + onSceneGraphError: function(error, message) { + // Prevent qFatal crash on Android when EGL context is lost + console.warn("Scene graph error:", error, message) + } + title: "AmneziaVPN" Item { // This item is needed for focus handling From 83d045af6428706a09e69868c03cdb8ac89d53ce Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Fri, 6 Mar 2026 12:05:16 +0300 Subject: [PATCH 04/36] fix: GP requrements (#2312) --- .../src/org/amnezia/vpn/AmneziaActivity.kt | 104 ++++-------------- 1 file changed, 22 insertions(+), 82 deletions(-) diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index e8613d58..550e5f7b 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -303,91 +303,31 @@ class AmneziaActivity : QtActivity() { } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - val deviceId = event.deviceId val keyCode = event.keyCode val pressed = event.action == KeyEvent.ACTION_DOWN - val source = event.source - if (deviceId < 0 && pressed) { - when (keyCode) { - KeyEvent.KEYCODE_BUTTON_A, - KeyEvent.KEYCODE_BUTTON_B, - KeyEvent.KEYCODE_BUTTON_X, - KeyEvent.KEYCODE_BUTTON_Y, - KeyEvent.KEYCODE_BUTTON_START, - KeyEvent.KEYCODE_BUTTON_SELECT -> { - nativeGamepadKeyEvent(0, keyCode, true) - nativeGamepadKeyEvent(0, keyCode, false) - return true - } - KeyEvent.KEYCODE_DPAD_CENTER -> { - if (isOnTv()) { - val down = KeyEvent( - event.downTime, - event.eventTime, - KeyEvent.ACTION_DOWN, - KeyEvent.KEYCODE_ENTER, - 0, - event.metaState, - 0, - event.scanCode, - event.flags, - event.source - ) - val up = KeyEvent( - event.downTime, - event.eventTime, - KeyEvent.ACTION_UP, - KeyEvent.KEYCODE_ENTER, - 0, - event.metaState, - 0, - event.scanCode, - event.flags, - event.source - ) - super.dispatchKeyEvent(down) - super.dispatchKeyEvent(up) - return true - } - nativeGamepadKeyEvent(0, keyCode, true) - nativeGamepadKeyEvent(0, keyCode, false) - return true - } - } - } - - // Real devices (remotes and gamepads) have deviceId >= 0. - if (deviceId >= 0) { - val isGamepad = (source and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD - val isJoystick = (source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK - val isDpad = (source and InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD - - if (isGamepad || isJoystick || isDpad) { - when (keyCode) { - KeyEvent.KEYCODE_BUTTON_A, - KeyEvent.KEYCODE_BUTTON_B, - KeyEvent.KEYCODE_BUTTON_X, - KeyEvent.KEYCODE_BUTTON_Y, - KeyEvent.KEYCODE_BUTTON_START, - KeyEvent.KEYCODE_BUTTON_SELECT, - KeyEvent.KEYCODE_DPAD_CENTER -> { - nativeGamepadKeyEvent(0, keyCode, pressed) - return true - } - KeyEvent.KEYCODE_DPAD_UP, - KeyEvent.KEYCODE_DPAD_DOWN, - KeyEvent.KEYCODE_DPAD_LEFT, - KeyEvent.KEYCODE_DPAD_RIGHT -> { - val synthetic = KeyEvent( - event.downTime, event.eventTime, event.action, event.keyCode, - event.repeatCount, event.metaState, -1, event.scanCode, - event.flags, InputDevice.SOURCE_KEYBOARD - ) - return super.dispatchKeyEvent(synthetic) - } - } - return super.dispatchKeyEvent(event) + when (keyCode) { + KeyEvent.KEYCODE_BUTTON_A, + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BUTTON_X, + KeyEvent.KEYCODE_BUTTON_Y, + KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_BUTTON_SELECT -> { + nativeGamepadKeyEvent(0, keyCode, pressed) + return true + } + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT -> { + val syntheticKeyCode = if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) KeyEvent.KEYCODE_ENTER else keyCode + val synthetic = KeyEvent( + event.downTime, event.eventTime, event.action, syntheticKeyCode, + event.repeatCount, event.metaState, -1, event.scanCode, + event.flags, InputDevice.SOURCE_KEYBOARD + ) + return super.dispatchKeyEvent(synthetic) } } From ca639d293d6cf199d2d4d308ab71d6ad9193f239 Mon Sep 17 00:00:00 2001 From: vkamn Date: Fri, 6 Mar 2026 22:11:03 +0700 Subject: [PATCH 05/36] chore: bump version (#2319) --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 78279e1f..48bcd138 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,7 +12,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d") set(RELEASE_DATE "${CURRENT_DATE}") set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}) -set(APP_ANDROID_VERSION_CODE 2114) +set(APP_ANDROID_VERSION_CODE 2115) if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux") set(MZ_PLATFORM_NAME "linux") From 9963359948acc44874e8ba11d760ce378bdaf79c Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Mon, 9 Mar 2026 12:39:50 +0300 Subject: [PATCH 06/36] fix: disable gamepad for GP (#2321) --- .github/workflows/deploy.yml | 128 ++++++++++++++++++ .../src/org/amnezia/vpn/AmneziaActivity.kt | 25 ++-- 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 99e24b3a..77f071b1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -711,6 +711,134 @@ jobs: compression-level: 0 retention-days: 7 + Build-Android-Gamepad: + runs-on: android-runner + + env: + ANDROID_BUILD_PLATFORM: android-36 + QT_VERSION: 6.10.1 + QT_MODULES: 'qtremoteobjects qt5compat qtimageformats qtshadertools' + PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }} + PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }} + DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} + DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} + DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} + FREE_V2_ENDPOINT: ${{ secrets.FREE_V2_ENDPOINT }} + PREM_V1_ENDPOINT: ${{ secrets.PREM_V1_ENDPOINT }} + + steps: + - name: 'Install desktop Qt' + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: 'linux' + target: 'desktop' + arch: 'linux_gcc_64' + modules: ${{ env.QT_MODULES }} + dir: ${{ runner.temp }} + py7zrversion: '==0.22.*' + extra: '--base ${{ env.QT_MIRROR }}' + + - name: 'Install android_armv7 Qt' + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: 'linux' + target: 'android' + arch: 'android_armv7' + modules: ${{ env.QT_MODULES }} + dir: ${{ runner.temp }} + py7zrversion: '==0.22.*' + extra: '--base ${{ env.QT_MIRROR }}' + + - name: 'Install android_arm64_v8a Qt' + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: 'linux' + target: 'android' + arch: 'android_arm64_v8a' + modules: ${{ env.QT_MODULES }} + dir: ${{ runner.temp }} + py7zrversion: '==0.22.*' + extra: '--base ${{ env.QT_MIRROR }}' + + - name: 'Grant execute permission for qt-cmake' + shell: bash + run: | + chmod +x ${{ runner.temp }}/Qt/${{ env.QT_VERSION }}/android_arm64_v8a/bin/qt-cmake + + - name: 'Get sources' + uses: actions/checkout@v4 + with: + submodules: 'true' + + - name: 'Get version from CMakeLists.txt' + id: get_version + run: | + VERSION=$(grep 'set(AMNEZIAVPN_VERSION' CMakeLists.txt | sed -E 's/.*AMNEZIAVPN_VERSION ([0-9]+.[0-9]+.[0-9]+.[0-9]+)\)/\1/') + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "Version: $VERSION" + + - name: 'Enable gamepad support' + run: | + sed -i 's/private const val GAMEPAD_ENABLED = false/private const val GAMEPAD_ENABLED = true/' \ + client/android/src/org/amnezia/vpn/AmneziaActivity.kt + + - name: 'Setup Java' + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: 'Setup Android NDK' + id: setup-ndk + uses: nttld/setup-ndk@v1 + with: + ndk-version: 'r26b' + + - name: 'Decode keystore secret to file' + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }} + shell: bash + run: | + echo $KEYSTORE_BASE64 | base64 --decode > android.keystore + + - name: 'Build project' + env: + ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} + QT_HOST_PATH: ${{ runner.temp }}/Qt/${{ env.QT_VERSION }}/gcc_64 + ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android.keystore + ANDROID_KEYSTORE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_ALIAS }} + ANDROID_KEYSTORE_KEY_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_PASS }} + shell: bash + run: ./deploy/build_android.sh --apk "arm64-v8a;armeabi-v7a" --build-platform ${{ env.ANDROID_BUILD_PLATFORM }} + + - name: 'Rename Android APKs' + run: | + cd deploy/build + mv AmneziaVPN-arm64-v8a-release.apk AmneziaVPN_${VERSION}_gamepad_arm64-v8a.apk + mv AmneziaVPN-armeabi-v7a-release.apk AmneziaVPN_${VERSION}_gamepad_armeabi-v7a.apk + cd ../.. + + - name: 'Upload arm64-v8a apk' + uses: actions/upload-artifact@v4 + with: + name: AmneziaVPN_${{ env.VERSION }}_gamepad_arm64-v8a.apk + path: deploy/build/AmneziaVPN_${{ env.VERSION }}_gamepad_arm64-v8a.apk + compression-level: 0 + retention-days: 7 + + - name: 'Upload armeabi-v7a apk' + uses: actions/upload-artifact@v4 + with: + name: AmneziaVPN_${{ env.VERSION }}_gamepad_armeabi-v7a.apk + path: deploy/build/AmneziaVPN_${{ env.VERSION }}_gamepad_armeabi-v7a.apk + compression-level: 0 + retention-days: 7 + +# ------------------------------------------------------ + Extra: runs-on: ubuntu-latest steps: diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index 550e5f7b..6042c2b0 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -74,6 +74,9 @@ private const val CREATE_FILE_ACTION_CODE = 2 private const val OPEN_FILE_ACTION_CODE = 3 private const val CHECK_NOTIFICATION_PERMISSION_ACTION_CODE = 4 +private const val GAMEPAD_ENABLED = false +private const val DPAD_SYNTHETIC_ENABLED = GAMEPAD_ENABLED + private const val PREFS_NOTIFICATION_PERMISSION_ASKED = "NOTIFICATION_PERMISSION_ASKED" private const val OPEN_FILE_AFTER_RESUME_DELAY_MS = 400L private const val KEY_PENDING_OPEN_FILE_URI = "pending_open_file_uri" @@ -313,21 +316,25 @@ class AmneziaActivity : QtActivity() { KeyEvent.KEYCODE_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_START, KeyEvent.KEYCODE_BUTTON_SELECT -> { - nativeGamepadKeyEvent(0, keyCode, pressed) - return true + if (GAMEPAD_ENABLED) { + nativeGamepadKeyEvent(0, keyCode, pressed) + return true + } } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> { - val syntheticKeyCode = if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) KeyEvent.KEYCODE_ENTER else keyCode - val synthetic = KeyEvent( - event.downTime, event.eventTime, event.action, syntheticKeyCode, - event.repeatCount, event.metaState, -1, event.scanCode, - event.flags, InputDevice.SOURCE_KEYBOARD - ) - return super.dispatchKeyEvent(synthetic) + if (DPAD_SYNTHETIC_ENABLED) { + val syntheticKeyCode = if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) KeyEvent.KEYCODE_ENTER else keyCode + val synthetic = KeyEvent( + event.downTime, event.eventTime, event.action, syntheticKeyCode, + event.repeatCount, event.metaState, -1, event.scanCode, + event.flags, InputDevice.SOURCE_KEYBOARD + ) + return super.dispatchKeyEvent(synthetic) + } } } From b0ca16d861faf03de36a9a34a0b161ac89339be4 Mon Sep 17 00:00:00 2001 From: vkamn Date: Mon, 9 Mar 2026 17:29:56 +0700 Subject: [PATCH 07/36] chore: bump version (#2331) --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 48bcd138..0594eed1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR) set(PROJECT AmneziaVPN) -set(AMNEZIAVPN_VERSION 4.8.14.3) +set(AMNEZIAVPN_VERSION 4.8.14.4) project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION} DESCRIPTION "AmneziaVPN" @@ -12,7 +12,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d") set(RELEASE_DATE "${CURRENT_DATE}") set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}) -set(APP_ANDROID_VERSION_CODE 2115) +set(APP_ANDROID_VERSION_CODE 2116) if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux") set(MZ_PLATFORM_NAME "linux") From f969fcdbb8dac7a09aa7f903ece235bbe013a50a Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Tue, 10 Mar 2026 17:19:55 +0300 Subject: [PATCH 08/36] fix: restore dpad functionality ATV (#2335) --- .github/workflows/deploy.yml | 128 ------------------ .../src/org/amnezia/vpn/AmneziaActivity.kt | 9 -- client/ui/qml/Controls2/PageType.qml | 5 +- client/ui/qml/Pages2/PageSetupWizardStart.qml | 19 +++ 4 files changed, 22 insertions(+), 139 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 77f071b1..99e24b3a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -711,134 +711,6 @@ jobs: compression-level: 0 retention-days: 7 - Build-Android-Gamepad: - runs-on: android-runner - - env: - ANDROID_BUILD_PLATFORM: android-36 - QT_VERSION: 6.10.1 - QT_MODULES: 'qtremoteobjects qt5compat qtimageformats qtshadertools' - PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }} - PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }} - DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} - DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} - DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} - FREE_V2_ENDPOINT: ${{ secrets.FREE_V2_ENDPOINT }} - PREM_V1_ENDPOINT: ${{ secrets.PREM_V1_ENDPOINT }} - - steps: - - name: 'Install desktop Qt' - uses: jurplel/install-qt-action@v4 - with: - version: ${{ env.QT_VERSION }} - host: 'linux' - target: 'desktop' - arch: 'linux_gcc_64' - modules: ${{ env.QT_MODULES }} - dir: ${{ runner.temp }} - py7zrversion: '==0.22.*' - extra: '--base ${{ env.QT_MIRROR }}' - - - name: 'Install android_armv7 Qt' - uses: jurplel/install-qt-action@v4 - with: - version: ${{ env.QT_VERSION }} - host: 'linux' - target: 'android' - arch: 'android_armv7' - modules: ${{ env.QT_MODULES }} - dir: ${{ runner.temp }} - py7zrversion: '==0.22.*' - extra: '--base ${{ env.QT_MIRROR }}' - - - name: 'Install android_arm64_v8a Qt' - uses: jurplel/install-qt-action@v4 - with: - version: ${{ env.QT_VERSION }} - host: 'linux' - target: 'android' - arch: 'android_arm64_v8a' - modules: ${{ env.QT_MODULES }} - dir: ${{ runner.temp }} - py7zrversion: '==0.22.*' - extra: '--base ${{ env.QT_MIRROR }}' - - - name: 'Grant execute permission for qt-cmake' - shell: bash - run: | - chmod +x ${{ runner.temp }}/Qt/${{ env.QT_VERSION }}/android_arm64_v8a/bin/qt-cmake - - - name: 'Get sources' - uses: actions/checkout@v4 - with: - submodules: 'true' - - - name: 'Get version from CMakeLists.txt' - id: get_version - run: | - VERSION=$(grep 'set(AMNEZIAVPN_VERSION' CMakeLists.txt | sed -E 's/.*AMNEZIAVPN_VERSION ([0-9]+.[0-9]+.[0-9]+.[0-9]+)\)/\1/') - echo "VERSION=$VERSION" >> $GITHUB_ENV - echo "Version: $VERSION" - - - name: 'Enable gamepad support' - run: | - sed -i 's/private const val GAMEPAD_ENABLED = false/private const val GAMEPAD_ENABLED = true/' \ - client/android/src/org/amnezia/vpn/AmneziaActivity.kt - - - name: 'Setup Java' - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '17' - - - name: 'Setup Android NDK' - id: setup-ndk - uses: nttld/setup-ndk@v1 - with: - ndk-version: 'r26b' - - - name: 'Decode keystore secret to file' - env: - KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }} - shell: bash - run: | - echo $KEYSTORE_BASE64 | base64 --decode > android.keystore - - - name: 'Build project' - env: - ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} - QT_HOST_PATH: ${{ runner.temp }}/Qt/${{ env.QT_VERSION }}/gcc_64 - ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android.keystore - ANDROID_KEYSTORE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_ALIAS }} - ANDROID_KEYSTORE_KEY_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_PASS }} - shell: bash - run: ./deploy/build_android.sh --apk "arm64-v8a;armeabi-v7a" --build-platform ${{ env.ANDROID_BUILD_PLATFORM }} - - - name: 'Rename Android APKs' - run: | - cd deploy/build - mv AmneziaVPN-arm64-v8a-release.apk AmneziaVPN_${VERSION}_gamepad_arm64-v8a.apk - mv AmneziaVPN-armeabi-v7a-release.apk AmneziaVPN_${VERSION}_gamepad_armeabi-v7a.apk - cd ../.. - - - name: 'Upload arm64-v8a apk' - uses: actions/upload-artifact@v4 - with: - name: AmneziaVPN_${{ env.VERSION }}_gamepad_arm64-v8a.apk - path: deploy/build/AmneziaVPN_${{ env.VERSION }}_gamepad_arm64-v8a.apk - compression-level: 0 - retention-days: 7 - - - name: 'Upload armeabi-v7a apk' - uses: actions/upload-artifact@v4 - with: - name: AmneziaVPN_${{ env.VERSION }}_gamepad_armeabi-v7a.apk - path: deploy/build/AmneziaVPN_${{ env.VERSION }}_gamepad_armeabi-v7a.apk - compression-level: 0 - retention-days: 7 - -# ------------------------------------------------------ - Extra: runs-on: ubuntu-latest steps: diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index 6042c2b0..daedfda3 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -74,9 +74,6 @@ private const val CREATE_FILE_ACTION_CODE = 2 private const val OPEN_FILE_ACTION_CODE = 3 private const val CHECK_NOTIFICATION_PERMISSION_ACTION_CODE = 4 -private const val GAMEPAD_ENABLED = false -private const val DPAD_SYNTHETIC_ENABLED = GAMEPAD_ENABLED - private const val PREFS_NOTIFICATION_PERMISSION_ASKED = "NOTIFICATION_PERMISSION_ASKED" private const val OPEN_FILE_AFTER_RESUME_DELAY_MS = 400L private const val KEY_PENDING_OPEN_FILE_URI = "pending_open_file_uri" @@ -316,17 +313,14 @@ class AmneziaActivity : QtActivity() { KeyEvent.KEYCODE_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_START, KeyEvent.KEYCODE_BUTTON_SELECT -> { - if (GAMEPAD_ENABLED) { nativeGamepadKeyEvent(0, keyCode, pressed) return true - } } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> { - if (DPAD_SYNTHETIC_ENABLED) { val syntheticKeyCode = if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) KeyEvent.KEYCODE_ENTER else keyCode val synthetic = KeyEvent( event.downTime, event.eventTime, event.action, syntheticKeyCode, @@ -334,15 +328,12 @@ class AmneziaActivity : QtActivity() { event.flags, InputDevice.SOURCE_KEYBOARD ) return super.dispatchKeyEvent(synthetic) - } } } return super.dispatchKeyEvent(event) } - - private external fun nativeGamepadKeyEvent(deviceId: Int, keyCode: Int, pressed: Boolean) override fun onPause() { diff --git a/client/ui/qml/Controls2/PageType.qml b/client/ui/qml/Controls2/PageType.qml index d7f3317f..b8f3698e 100644 --- a/client/ui/qml/Controls2/PageType.qml +++ b/client/ui/qml/Controls2/PageType.qml @@ -8,9 +8,10 @@ Item { id: root property StackView stackView: StackView.view + property bool enableTimer: true onVisibleChanged: { - if (visible) { + if (visible && enableTimer) { timer.start() } } @@ -24,6 +25,6 @@ Item { FocusController.setFocusOnDefaultItem() } repeat: false // Stop the timer after one trigger - running: true // Start the timer + running: enableTimer // Start the timer } } diff --git a/client/ui/qml/Pages2/PageSetupWizardStart.qml b/client/ui/qml/Pages2/PageSetupWizardStart.qml index 82ff3b7c..adacd6c0 100644 --- a/client/ui/qml/Pages2/PageSetupWizardStart.qml +++ b/client/ui/qml/Pages2/PageSetupWizardStart.qml @@ -13,6 +13,7 @@ import "../Components" PageType { id: root + enableTimer: (SettingsController.isOnTv()) ? false : true ColumnLayout { id: content @@ -45,4 +46,22 @@ PageType { } } } + + Timer { + interval: 250 + running: SettingsController.isOnTv() + repeat: true + onTriggered: { + startButton.forceActiveFocus() + if (startButton.activeFocus) { + running = false + } + } + } + + onVisibleChanged: { + if (visible && SettingsController.isOnTv()) { + startButton.forceActiveFocus() + } + } } From 477afb9d852a84d324e80cf4fa7c8c8a38d7f3ac Mon Sep 17 00:00:00 2001 From: vkamn Date: Tue, 10 Mar 2026 21:22:37 +0700 Subject: [PATCH 09/36] chore: bump version (#2336) --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0594eed1..d6cdaac0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR) set(PROJECT AmneziaVPN) -set(AMNEZIAVPN_VERSION 4.8.14.4) +set(AMNEZIAVPN_VERSION 4.8.14.5) project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION} DESCRIPTION "AmneziaVPN" @@ -12,7 +12,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d") set(RELEASE_DATE "${CURRENT_DATE}") set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}) -set(APP_ANDROID_VERSION_CODE 2116) +set(APP_ANDROID_VERSION_CODE 2117) if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux") set(MZ_PLATFORM_NAME "linux") From 67bd880cdf0f0cababe50a8287e2e0033c2afd0e Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Mon, 16 Mar 2026 08:03:20 +0300 Subject: [PATCH 10/36] fix: swap buffers error (#2347) --- CMakeLists.txt | 2 +- client/amnezia_application.cpp | 10 ++++++++++ client/ui/qml/main2.qml | 10 +++++++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d6cdaac0..ca4ab5f8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,7 +12,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d") set(RELEASE_DATE "${CURRENT_DATE}") set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}) -set(APP_ANDROID_VERSION_CODE 2117) +set(APP_ANDROID_VERSION_CODE 2118) if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux") set(MZ_PLATFORM_NAME "linux") diff --git a/client/amnezia_application.cpp b/client/amnezia_application.cpp index c5305930..c6c8d672 100644 --- a/client/amnezia_application.cpp +++ b/client/amnezia_application.cpp @@ -109,6 +109,16 @@ void AmneziaApplication::init() // install filter on main window if (auto win = qobject_cast(obj)) { win->installEventFilter(this); +#ifdef Q_OS_ANDROID + QObject::connect(win, &QQuickWindow::sceneGraphError, + [](QQuickWindow::SceneGraphError, const QString &msg) { + qWarning() << "Scene graph error (suppressed):" << msg; + }); + // Keep graphics context alive across hide/show cycles to avoid + // eglSwapBuffers/makeCurrent being called on a context Android has reclaimed. + win->setPersistentSceneGraph(true); + win->setPersistentGraphics(true); +#endif win->show(); } }, diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index 1ab54960..a95044d9 100644 --- a/client/ui/qml/main2.qml +++ b/client/ui/qml/main2.qml @@ -21,10 +21,14 @@ Window { function onStateChanged() { if (Qt.platform.os === "android") { if (Qt.application.state === Qt.ApplicationActive) { + root.visible = true refreshTimer.restart() - } else if (Qt.application.state === Qt.ApplicationSuspended || - Qt.application.state === Qt.ApplicationInactive) { - console.log("QML: Application going to background, state:", Qt.application.state) + } 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 } } } From ddecfcad2612322ce10dadd0b5333ef2e9760872 Mon Sep 17 00:00:00 2001 From: yyy-amnezia Date: Fri, 20 Mar 2026 14:51:36 +0200 Subject: [PATCH 11/36] 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 118545c2..6f534e8a 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 8a678413..1825b816 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 12/36] 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 f2327b33..36c3f117 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 00000000..aa631bb1 --- /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 568b8d72..51bb4703 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 13/36] 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 daedfda3..a28d531a 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 14/36] 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 8ec919b8..84ef0e68 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 b2bee8be..2d16c384 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 83ca3284..85872f59 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 0f3a8a4e..65fc0083 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 5ed9cca1..7d831f48 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 c5e581af..24308a12 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 15/36] 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 4ce5bc31..8b67754d 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 16/36] 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 6a08bb6a..4d3d723c 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 9c47a2a1..9c533a93 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 9302680b..fc9498d0 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 17/36] 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 84ef0e68..78e8031f 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 85872f59..c8634294 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 18/36] 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 6f534e8a..882ad578 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 1825b816..e80bbb05 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 19/36] 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 a28d531a..1d2e09cc 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 b77e77d6..ec143635 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 2d60ad84..c6f538bd 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 a5a4b3d2..49360f02 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 8b456f3a..5363ab22 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 fa50bc23..ed20a1e6 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 a95044d9..89b2bb98 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 20/36] 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 2d16c384..92d1e985 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 c8ea65e5..390baf5a 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 c050650e..a1e4c656 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 c8634294..df0d1683 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 dc654642..8ca775b8 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 59a68fd8..4e343a98 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 afe9a570..5853fbd8 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 65fc0083..6c59fc90 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 836bc892..fb04079c 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 00000000..2a1adced --- /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 532ab6a1..140b17f2 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 89b2bb98..147f90b8 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 21/36] 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 1d2e09cc..ccdc2214 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 25a40c46..4a2fe8d2 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 6c59fc90..3bd6c80c 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 4417e0b2..47eceb3f 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 2a1adced..97fb01b8 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 6a4a8810..24844bda 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 d2787f59..93599d96 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 140b17f2..7e44138a 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 From bf3d11e5c4975f08f21769da6a15689e7589d4b3 Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Wed, 25 Mar 2026 14:48:32 +0300 Subject: [PATCH 22/36] feat: renewal new status logic (#2409) * fix: renewal add status logic * fix: wakeup activity resumed android --- .../src/org/amnezia/vpn/AmneziaActivity.kt | 18 ++++++++++++++- client/core/controllers/coreController.cpp | 4 ++-- .../controllers/api/apiConfigsController.cpp | 6 +++++ .../ui/controllers/api/apiConfigsController.h | 1 + .../controllers/api/apiSettingsController.cpp | 9 +++++++- client/ui/models/api/apiAccountInfoModel.cpp | 12 +--------- client/ui/models/api/apiAccountInfoModel.h | 3 --- client/ui/models/servers_model.cpp | 17 ++++++++++++++ client/ui/models/servers_model.h | 3 +++ client/ui/qml/Components/ServersListView.qml | 22 +++---------------- .../Components/SubscriptionExpiredDrawer.qml | 2 -- .../PageSettingsApiAvailableCountries.qml | 20 +++-------------- 12 files changed, 61 insertions(+), 56 deletions(-) diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index ccdc2214..e0a576ad 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -296,9 +296,25 @@ class AmneziaActivity : QtActivity() { hasWindowFocus = hasFocus Log.d(TAG, "Window focus changed: hasFocus=$hasFocus") - // Cancel pending operations if window loses focus if (!hasFocus) { + // Cancel pending operations if window loses focus resumeHandler.removeCallbacksAndMessages(null) + } else if (isActivityResumed && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + window.decorView.apply { + invalidate() + resumeHandler.postDelayed({ + if (isActivityResumed && hasWindowFocus && !isFinishing && !isDestroyed) { + sendTouch(1f, 1f) + } + }, 50) + resumeHandler.postDelayed({ + if (isActivityResumed && hasWindowFocus && !isFinishing && !isDestroyed) { + sendTouch(2f, 2f) + requestLayout() + invalidate() + } + }, 150) + } } } diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index 390baf5a..dde6ffb2 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -153,8 +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); + connect(m_apiConfigsController.get(), &ApiConfigsController::subscriptionRefreshNeeded, + this, [this]() { m_apiSettingsController->getAccountInfo(false); }); m_apiNewsController.reset(new ApiNewsController(m_newsModel, m_settings, m_serversModel, this)); m_engine->rootContext()->setContextProperty("ApiNewsController", m_apiNewsController.get()); diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index df0d1683..987d9240 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -723,6 +723,7 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const } bool isTestPurchase = apiConfig.value(apiDefs::key::isTestPurchase).toBool(false); + bool wasSubscriptionExpired = m_serversModel->data(serverIndex, ServersModel::IsSubscriptionExpiredRole).toBool(); QByteArray responseBody; ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody, isTestPurchase); @@ -749,6 +750,11 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const newServerConfig.insert(config_key::nameOverriddenByUser, true); } m_serversModel->editServer(newServerConfig, serverIndex); + + if (wasSubscriptionExpired) { + emit subscriptionRefreshNeeded(); + } + if (reloadServiceConfig) { emit reloadServerFromApiFinished(tr("API config reloaded")); } else if (newCountryName.isEmpty()) { diff --git a/client/ui/controllers/api/apiConfigsController.h b/client/ui/controllers/api/apiConfigsController.h index 8ca775b8..ca5598e1 100644 --- a/client/ui/controllers/api/apiConfigsController.h +++ b/client/ui/controllers/api/apiConfigsController.h @@ -44,6 +44,7 @@ public slots: signals: void errorOccurred(ErrorCode errorCode); void subscriptionExpiredOnServer(); + void subscriptionRefreshNeeded(); 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 4e343a98..78ac5c24 100644 --- a/client/ui/controllers/api/apiSettingsController.cpp +++ b/client/ui/controllers/api/apiSettingsController.cpp @@ -78,6 +78,13 @@ bool ApiSettingsController::getAccountInfo(bool reload) QJsonObject accountInfo = QJsonDocument::fromJson(responseBody).object(); m_apiAccountInfoModel->updateModel(accountInfo, serverConfig); + QString subscriptionEndDate = accountInfo.value(apiDefs::key::subscriptionEndDate).toString(); + if (!subscriptionEndDate.isEmpty()) { + apiConfig.insert(apiDefs::key::subscriptionEndDate, subscriptionEndDate); + serverConfig.insert(configKey::apiConfig, apiConfig); + m_serversModel->editServer(serverConfig, processedIndex); + } + if (reload) { updateApiCountryModel(); updateApiDevicesModel(); @@ -115,7 +122,7 @@ void ApiSettingsController::getRenewalLink() } QJsonObject responseJson = QJsonDocument::fromJson(responseBody).object(); - QString url = responseJson.value("url").toString(); + QString url = responseJson.value("renewal_url").toString(); if (!url.isEmpty()) { emit renewalLinkReceived(url); } diff --git a/client/ui/models/api/apiAccountInfoModel.cpp b/client/ui/models/api/apiAccountInfoModel.cpp index 3bd6c80c..b5fd6f55 100644 --- a/client/ui/models/api/apiAccountInfoModel.cpp +++ b/client/ui/models/api/apiAccountInfoModel.cpp @@ -78,7 +78,6 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const } 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); } @@ -87,7 +86,7 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const 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 endDate <= QDateTime::currentDateTimeUtc().addDays(10); } } @@ -98,8 +97,6 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons { beginResetModel(); - m_isSubscriptionExpiredByServer = false; - AccountInfoData accountInfoData; m_availableCountries = accountInfoObject.value(apiDefs::key::availableCountries).toArray(); @@ -124,13 +121,6 @@ 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); diff --git a/client/ui/models/api/apiAccountInfoModel.h b/client/ui/models/api/apiAccountInfoModel.h index fb04079c..882a9c72 100644 --- a/client/ui/models/api/apiAccountInfoModel.h +++ b/client/ui/models/api/apiAccountInfoModel.h @@ -33,8 +33,6 @@ public: public slots: void updateModel(const QJsonObject &accountInfoObject, const QJsonObject &serverConfig); QVariant data(const QString &roleString); - void setSubscriptionExpiredByServer(); - QJsonArray getAvailableCountries(); QJsonArray getIssuedConfigsInfo(); @@ -62,7 +60,6 @@ private: }; AccountInfoData m_accountInfoData; - bool m_isSubscriptionExpiredByServer = false; QJsonArray m_availableCountries; QJsonArray m_issuedConfigsInfo; QJsonObject m_supportInfo; diff --git a/client/ui/models/servers_model.cpp b/client/ui/models/servers_model.cpp index 3af2a09a..70d5541c 100644 --- a/client/ui/models/servers_model.cpp +++ b/client/ui/models/servers_model.cpp @@ -179,6 +179,20 @@ QVariant ServersModel::data(const QModelIndex &index, int role) const case AdEndpointRole: { return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::adEndpoint).toString(); } + case IsSubscriptionExpiredRole: { + if (configVersion != apiDefs::ConfigSource::AmneziaGateway) return false; + QString endDate = apiConfig.value(apiDefs::key::subscriptionEndDate).toString(); + if (endDate.isEmpty()) return false; + return apiUtils::isSubscriptionExpired(endDate); + } + case IsSubscriptionExpiringSoonRole: { + if (configVersion != apiDefs::ConfigSource::AmneziaGateway) return false; + QString endDate = apiConfig.value(apiDefs::key::subscriptionEndDate).toString(); + if (endDate.isEmpty()) return false; + if (apiUtils::isSubscriptionExpired(endDate)) return false; + QDateTime endDateTime = QDateTime::fromString(endDate, Qt::ISODateWithMs); + return endDateTime <= QDateTime::currentDateTimeUtc().addDays(10); + } } return QVariant(); @@ -443,6 +457,9 @@ QHash ServersModel::roleNames() const roles[AdDescriptionRole] = "adDescription"; roles[AdEndpointRole] = "adEndpoint"; + roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired"; + roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon"; + return roles; } diff --git a/client/ui/models/servers_model.h b/client/ui/models/servers_model.h index 66779bc2..6aba7d37 100644 --- a/client/ui/models/servers_model.h +++ b/client/ui/models/servers_model.h @@ -52,6 +52,9 @@ public: AdDescriptionRole, AdEndpointRole, + IsSubscriptionExpiredRole, + IsSubscriptionExpiringSoonRole, + HasAmneziaDns }; diff --git a/client/ui/qml/Components/ServersListView.qml b/client/ui/qml/Components/ServersListView.qml index 47eceb3f..69c6acad 100644 --- a/client/ui/qml/Components/ServersListView.qml +++ b/client/ui/qml/Components/ServersListView.qml @@ -19,15 +19,6 @@ 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 @@ -44,13 +35,6 @@ ListViewType { } } - Connections { - target: ApiConfigsController - function onSubscriptionExpiredOnServer() { - root.expiredServerIndex = ServersModel.defaultIndex - } - } - delegate: Item { id: menuContentDelegate objectName: "menuContentDelegate" @@ -143,14 +127,14 @@ ListViewType { } CaptionTextType { - visible: isServerFromGatewayApi && (index === root.expiredServerIndex || (root.expiringSoon && index === root.selectedIndex && index !== root.expiredServerIndex)) + visible: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon) 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 + text: isSubscriptionExpired ? qsTr("Subscription expired. Please renew.") : qsTr("Subscription expiring soon.") + color: isSubscriptionExpired ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot wrapMode: Text.WordWrap } diff --git a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml index 97fb01b8..8be6b805 100644 --- a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml +++ b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml @@ -3,8 +3,6 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Controls import QtQuick.Layouts -import Qt5Compat.GraphicalEffects - import PageEnum 1.0 import Style 1.0 diff --git a/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml b/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml index 93599d96..d7aded66 100644 --- a/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml +++ b/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml @@ -21,34 +21,20 @@ PageType { property bool subscriptionExpired: false property bool subscriptionExpiringSoon: false function updateSubscriptionState() { - root.subscriptionExpiringSoon = ApiAccountInfoModel.data("isSubscriptionExpiringSoon") + root.subscriptionExpired = ServersModel.getProcessedServerData("isSubscriptionExpired") + root.subscriptionExpiringSoon = ServersModel.getProcessedServerData("isSubscriptionExpiringSoon") } Component.onCompleted: { root.updateSubscriptionState() } - Connections { - target: ApiAccountInfoModel - function onModelReset() { - root.updateSubscriptionState() - } - } - Connections { target: ServersModel function onProcessedServerChanged() { root.processedServer = proxyServersModel.get(0) - } - } - - Connections { - target: ApiConfigsController - - function onSubscriptionExpiredOnServer() { - root.subscriptionExpired = true - root.subscriptionExpiringSoon = false + root.updateSubscriptionState() } } From 78f504e35c0b877c61a1b07893b61fa4fc23bd09 Mon Sep 17 00:00:00 2001 From: vkamn Date: Wed, 8 Apr 2026 11:21:12 +0700 Subject: [PATCH 23/36] feat: new services description (#2412) * feat: iap for apple now use storekit2 * fix: fixed error 101 on connection event * feat: enhance StoreKit2Helper to handle entitlements and improve restore service from App Store functionality * chore: add isInAppPurchase and isTestPurchase in primary config * refactor: use end_date from primary config for renew ui * fix: hide renew button for free * fix: hide renew button for appstore purchases * feat: add new premium info page * feat: add new free info page * chore: minor fixes * refactor: move plan and benefits into separate models * fix: fixed expired status when configs without an end date * feat: add trial api support * chore: add api message parsing for 422 error * feat: move privacy policy and term of use to gateway * feat: add iap support for new premium info page * chore: minor fixes * chore: minor fix * chore: minor fixes * feat: additional parsing for storekit subscription plans * chore: minor codestyle fixes * chore: simplify benefits * chore: hide extend buttons on external premium * feat: add trial error processing * fix: remove wrong check from tiral handler * chore: cleanup --------- Co-authored-by: spectrum --- CMakeLists.txt | 2 +- client/cmake/ios.cmake | 1 + client/cmake/macos_ne.cmake | 1 + client/core/api/apiDefs.h | 6 + client/core/api/apiUtils.cpp | 100 +++- client/core/api/apiUtils.h | 2 + client/core/controllers/coreController.cpp | 9 +- client/core/controllers/coreController.h | 4 + client/core/controllers/gatewayController.cpp | 41 +- client/core/defs.h | 3 + client/core/errorstrings.cpp | 3 + client/images/controls/globe-2.svg | 6 + client/images/controls/infinity.svg | 3 + client/images/controls/smartphone.svg | 4 + client/platforms/ios/StoreKit2Helper.swift | 178 ++++++ client/platforms/ios/StoreKitController.mm | 262 ++------- client/platforms/ios/ios_controller.mm | 110 ++-- client/resources.qrc | 12 +- .../controllers/api/apiConfigsController.cpp | 517 +++++++++++++----- .../ui/controllers/api/apiConfigsController.h | 24 +- .../controllers/api/apiSettingsController.cpp | 7 - .../ui/controllers/connectionController.cpp | 3 + client/ui/controllers/pageController.h | 5 +- client/ui/models/api/apiAccountInfoModel.cpp | 39 +- client/ui/models/api/apiAccountInfoModel.h | 6 +- client/ui/models/api/apiBenefitsModel.cpp | 112 ++++ client/ui/models/api/apiBenefitsModel.h | 43 ++ client/ui/models/api/apiServicesModel.cpp | 122 +++-- client/ui/models/api/apiServicesModel.h | 105 ++-- .../models/api/apiSubscriptionPlansModel.cpp | 131 +++++ .../ui/models/api/apiSubscriptionPlansModel.h | 53 ++ client/ui/models/servers_model.cpp | 47 +- client/ui/models/servers_model.h | 2 +- client/ui/qml/Components/BenefitRow.qml | 65 +++ client/ui/qml/Components/BenefitsPanel.qml | 40 ++ client/ui/qml/Components/ServersListView.qml | 19 +- .../Components/SubscriptionExpiredDrawer.qml | 11 + .../qml/Components/SubscriptionPlanCard.qml | 94 ++++ .../ui/qml/Components/TermsAndPrivacyText.qml | 35 ++ client/ui/qml/Controls2/CardWithIconsType.qml | 219 +++++--- .../qml/Controls2/TextTypes/BadgeTextType.qml | 15 + client/ui/qml/Modules/Style/AmneziaStyle.qml | 9 +- .../PageSettingsApiAvailableCountries.qml | 30 +- .../qml/Pages2/PageSettingsApiServerInfo.qml | 69 +-- .../qml/Pages2/PageSetupWizardApiFreeInfo.qml | 140 +++++ .../Pages2/PageSetupWizardApiPremiumInfo.qml | 198 +++++++ .../Pages2/PageSetupWizardApiServiceInfo.qml | 226 -------- .../Pages2/PageSetupWizardApiServicesList.qml | 9 +- .../Pages2/PageSetupWizardApiTrialEmail.qml | 138 +++++ .../Pages2/PageSetupWizardConfigSource.qml | 14 +- client/ui/qml/Pages2/PageStart.qml | 8 +- 51 files changed, 2372 insertions(+), 930 deletions(-) create mode 100644 client/images/controls/globe-2.svg create mode 100644 client/images/controls/infinity.svg create mode 100644 client/images/controls/smartphone.svg create mode 100644 client/platforms/ios/StoreKit2Helper.swift create mode 100644 client/ui/models/api/apiBenefitsModel.cpp create mode 100644 client/ui/models/api/apiBenefitsModel.h create mode 100644 client/ui/models/api/apiSubscriptionPlansModel.cpp create mode 100644 client/ui/models/api/apiSubscriptionPlansModel.h create mode 100644 client/ui/qml/Components/BenefitRow.qml create mode 100644 client/ui/qml/Components/BenefitsPanel.qml create mode 100644 client/ui/qml/Components/SubscriptionPlanCard.qml create mode 100644 client/ui/qml/Components/TermsAndPrivacyText.qml create mode 100644 client/ui/qml/Controls2/TextTypes/BadgeTextType.qml create mode 100644 client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml create mode 100644 client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml delete mode 100644 client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml create mode 100644 client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index ca4ab5f8..e9146dfd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR) set(PROJECT AmneziaVPN) -set(AMNEZIAVPN_VERSION 4.8.14.5) +set(AMNEZIAVPN_VERSION 4.8.15.0) project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION} DESCRIPTION "AmneziaVPN" diff --git a/client/cmake/ios.cmake b/client/cmake/ios.cmake index b605de48..c581277d 100644 --- a/client/cmake/ios.cmake +++ b/client/cmake/ios.cmake @@ -121,6 +121,7 @@ target_sources(${PROJECT} PRIVATE ${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift ${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift ${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift + ${CLIENT_ROOT_DIR}/platforms/ios/StoreKit2Helper.swift ) target_sources(${PROJECT} PRIVATE diff --git a/client/cmake/macos_ne.cmake b/client/cmake/macos_ne.cmake index 02dfb412..2ff334fd 100644 --- a/client/cmake/macos_ne.cmake +++ b/client/cmake/macos_ne.cmake @@ -131,6 +131,7 @@ target_sources(${PROJECT} PRIVATE ${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift ${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift ${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift + ${CLIENT_ROOT_DIR}/platforms/ios/StoreKit2Helper.swift ) target_sources(${PROJECT} PRIVATE diff --git a/client/core/api/apiDefs.h b/client/core/api/apiDefs.h index 78e8031f..ebcdced3 100644 --- a/client/core/api/apiDefs.h +++ b/client/core/api/apiDefs.h @@ -56,8 +56,13 @@ namespace apiDefs constexpr QLatin1String activeDeviceCount("active_device_count"); constexpr QLatin1String maxDeviceCount("max_device_count"); constexpr QLatin1String subscriptionEndDate("subscription_end_date"); + constexpr QLatin1String subscriptionExpiredByServer("subscription_expired_by_server"); + constexpr QLatin1String subscription("subscription"); + constexpr QLatin1String endDate("end_date"); constexpr QLatin1String issuedConfigs("issued_configs"); constexpr QLatin1String subscriptionDescription("subscription_description"); + constexpr QLatin1String termsOfUseUrl("terms_of_use_url"); + constexpr QLatin1String privacyPolicyUrl("privacy_policy_url"); constexpr QLatin1String supportInfo("support_info"); constexpr QLatin1String email("email"); @@ -72,6 +77,7 @@ namespace apiDefs constexpr QLatin1String transactionId("transaction_id"); constexpr QLatin1String isTestPurchase("is_test_purchase"); + constexpr QLatin1String isInAppPurchase("is_in_app_purchase"); constexpr QLatin1String userCountryCode("user_country_code"); diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index 92d1e985..f60e2d49 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -3,11 +3,33 @@ #include #include #include +#include namespace { const QByteArray AMNEZIA_CONFIG_SIGNATURE = QByteArray::fromHex("000000ff"); + constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); + constexpr QLatin1String trialAlreadyUsedMessage("trial subscription already used"); + + QDateTime subscriptionEndUtcFromString(const QString &subscriptionEndDate) + { + if (subscriptionEndDate.isEmpty()) { + return {}; + } + QDateTime endDate = QDateTime::fromString(subscriptionEndDate, Qt::ISODateWithMs).toUTC(); + if (!endDate.isValid()) { + endDate = QDateTime::fromString(subscriptionEndDate, Qt::ISODate).toUTC(); + } + return endDate; + } + + QString apiErrorMessageFromJson(const QJsonObject &jsonObj) + { + const QJsonValue value = jsonObj.value(QStringLiteral("message")); + return value.isString() ? value.toString().trimmed() : QString(); + } + QString escapeUnicode(const QString &input) { QString output; @@ -24,9 +46,30 @@ namespace bool apiUtils::isSubscriptionExpired(const QString &subscriptionEndDate) { - QDateTime now = QDateTime::currentDateTimeUtc(); - QDateTime endDate = QDateTime::fromString(subscriptionEndDate, Qt::ISODateWithMs); - return endDate < now; + if (subscriptionEndDate.isEmpty()) { + return false; + } + const QDateTime endDate = subscriptionEndUtcFromString(subscriptionEndDate); + if (!endDate.isValid()) { + return false; + } + return endDate <= QDateTime::currentDateTimeUtc(); +} + +bool apiUtils::isSubscriptionExpiringSoon(const QString &subscriptionEndDate, int withinDays) +{ + if (subscriptionEndDate.isEmpty()) { + return false; + } + const QDateTime endDate = subscriptionEndUtcFromString(subscriptionEndDate); + if (!endDate.isValid()) { + return false; + } + const QDateTime nowUtc = QDateTime::currentDateTimeUtc(); + if (endDate <= nowUtc) { + return false; + } + return endDate <= nowUtc.addDays(withinDays); } bool apiUtils::isServerFromApi(const QJsonObject &serverConfigObject) @@ -96,41 +139,54 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl const int httpStatusCodeConflict = 409; const int httpStatusCodeNotFound = 404; const int httpStatusCodeNotImplemented = 501; + const int httpStatusCodePaymentRequired = 402; const int httpStatusCodeUnprocessableEntity = 422; if (!sslErrors.empty()) { qDebug().noquote() << sslErrors; return amnezia::ErrorCode::ApiConfigSslError; - } else if (replyError == QNetworkReply::NoError) { + } + if (replyError == QNetworkReply::NoError) { return amnezia::ErrorCode::NoError; - } else if (replyError == QNetworkReply::NetworkError::OperationCanceledError - || replyError == QNetworkReply::NetworkError::TimeoutError) { + } + if (replyError == QNetworkReply::NetworkError::OperationCanceledError + || replyError == QNetworkReply::NetworkError::TimeoutError) { qDebug() << replyError; return amnezia::ErrorCode::ApiConfigTimeoutError; - } else if (replyError == QNetworkReply::NetworkError::OperationNotImplementedError) { + } + if (replyError == QNetworkReply::NetworkError::OperationNotImplementedError) { qDebug() << replyError; return amnezia::ErrorCode::ApiUpdateRequestError; - } else { - qDebug() << QString::fromUtf8(responseBody); - qDebug() << replyError; - qDebug() << replyErrorString; - qDebug() << httpStatusCode; + } - int httpStatusFromBody = -1; - QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); - if (jsonDoc.isObject()) { - QJsonObject jsonObj = jsonDoc.object(); - httpStatusFromBody = jsonObj.value("http_status").toInt(-1); - } + qDebug() << QString::fromUtf8(responseBody); + qDebug() << replyError; + qDebug() << httpStatusCode; + QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); + if (jsonDoc.isObject()) { + QJsonObject jsonObj = jsonDoc.object(); + const int httpStatusFromBody = jsonObj.value(QStringLiteral("http_status")).toInt(-1); if (httpStatusFromBody == httpStatusCodeConflict) { + if (apiErrorMessageFromJson(jsonObj).contains(trialAlreadyUsedMessage, Qt::CaseInsensitive)) { + return amnezia::ErrorCode::ApiTrialAlreadyUsedError; + } return amnezia::ErrorCode::ApiConfigLimitError; - } else if (httpStatusFromBody == httpStatusCodeNotFound) { + } + if (httpStatusFromBody == httpStatusCodeNotFound) { return amnezia::ErrorCode::ApiNotFoundError; - } else if (httpStatusFromBody == httpStatusCodeNotImplemented) { + } + if (httpStatusFromBody == httpStatusCodeNotImplemented) { return amnezia::ErrorCode::ApiUpdateRequestError; - } else if (httpStatusFromBody == httpStatusCodeUnprocessableEntity) { - return amnezia::ErrorCode::ApiSubscriptionExpiredError; + } + if (httpStatusFromBody == httpStatusCodeUnprocessableEntity) { + if (apiErrorMessageFromJson(jsonObj) == unprocessableSubscriptionMessage) { + return amnezia::ErrorCode::ApiSubscriptionExpiredError; + } + return amnezia::ErrorCode::ApiConfigDownloadError; + } + if (httpStatusFromBody == httpStatusCodePaymentRequired) { + return amnezia::ErrorCode::ApiSubscriptionNotActiveError; } return amnezia::ErrorCode::ApiConfigDownloadError; } diff --git a/client/core/api/apiUtils.h b/client/core/api/apiUtils.h index d4e1d9ce..819242a5 100644 --- a/client/core/api/apiUtils.h +++ b/client/core/api/apiUtils.h @@ -13,6 +13,8 @@ namespace apiUtils bool isSubscriptionExpired(const QString &subscriptionEndDate); + bool isSubscriptionExpiringSoon(const QString &subscriptionEndDate, int withinDays = 10); + bool isPremiumServer(const QJsonObject &serverConfigObject); apiDefs::ConfigType getConfigType(const QJsonObject &serverConfigObject); diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index dde6ffb2..42a8e203 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -91,6 +91,12 @@ void CoreController::initModels() m_apiServicesModel.reset(new ApiServicesModel(this)); m_engine->rootContext()->setContextProperty("ApiServicesModel", m_apiServicesModel.get()); + m_apiSubscriptionPlansModel.reset(new ApiSubscriptionPlansModel(this)); + m_engine->rootContext()->setContextProperty("ApiSubscriptionPlansModel", m_apiSubscriptionPlansModel.get()); + + m_apiBenefitsModel.reset(new ApiBenefitsModel(this)); + m_engine->rootContext()->setContextProperty("ApiBenefitsModel", m_apiBenefitsModel.get()); + m_apiCountryModel.reset(new ApiCountryModel(this)); m_engine->rootContext()->setContextProperty("ApiCountryModel", m_apiCountryModel.get()); @@ -151,7 +157,8 @@ void CoreController::initControllers() new ApiSettingsController(m_serversModel, m_apiAccountInfoModel, m_apiCountryModel, m_apiDevicesModel, m_settings)); m_engine->rootContext()->setContextProperty("ApiSettingsController", m_apiSettingsController.get()); - m_apiConfigsController.reset(new ApiConfigsController(m_serversModel, m_apiServicesModel, m_settings)); + m_apiConfigsController.reset( + new ApiConfigsController(m_serversModel, m_apiServicesModel, m_apiSubscriptionPlansModel, m_apiBenefitsModel, m_settings)); m_engine->rootContext()->setContextProperty("ApiConfigsController", m_apiConfigsController.get()); connect(m_apiConfigsController.get(), &ApiConfigsController::subscriptionRefreshNeeded, this, [this]() { m_apiSettingsController->getAccountInfo(false); }); diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index 998e7d8d..fd2e88ca 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -32,9 +32,11 @@ #include "ui/models/protocols/ikev2ConfigModel.h" #endif #include "ui/models/api/apiAccountInfoModel.h" +#include "ui/models/api/apiBenefitsModel.h" #include "ui/models/api/apiCountryModel.h" #include "ui/models/api/apiDevicesModel.h" #include "ui/models/api/apiServicesModel.h" +#include "ui/models/api/apiSubscriptionPlansModel.h" #include "ui/models/appSplitTunnelingModel.h" #include "ui/models/clientManagementModel.h" #include "ui/models/protocols/awgConfigModel.h" @@ -133,6 +135,8 @@ private: QSharedPointer m_clientManagementModel; QSharedPointer m_apiServicesModel; + QSharedPointer m_apiSubscriptionPlansModel; + QSharedPointer m_apiBenefitsModel; QSharedPointer m_apiCountryModel; QSharedPointer m_apiAccountInfoModel; QSharedPointer m_apiDevicesModel; diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 4a2fe8d2..30b4c572 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -44,9 +44,11 @@ namespace constexpr int httpStatusCodeNotFound = 404; constexpr int httpStatusCodeConflict = 409; - constexpr int httpStatusCodeNotImplemented = 501; + constexpr int httpStatusCodePaymentRequired = 402; constexpr int httpStatusCodeUnprocessableEntity = 422; + + constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); } GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs, @@ -334,10 +336,16 @@ 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()); std::shuffle(baseUrls.begin(), baseUrls.end(), generator); @@ -416,12 +424,14 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep { const QByteArray &responseBody = decryptedResponseBody; - int httpStatus = -1; + int apiHttpStatus = -1; + QString apiErrorMessage; if (isDecryptionSuccessful) { QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); if (jsonDoc.isObject()) { QJsonObject jsonObj = jsonDoc.object(); - httpStatus = jsonObj.value("http_status").toInt(-1); + apiHttpStatus = jsonObj.value("http_status").toInt(-1); + apiErrorMessage = jsonObj.value(QStringLiteral("message")).toString().trimmed(); } } else { qDebug() << "failed to decrypt the data"; @@ -432,10 +442,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 (apiHttpStatus == httpStatusCodeNotFound) { if (responseBody.contains(errorResponsePattern1) || responseBody.contains(errorResponsePattern2) || responseBody.contains(errorResponsePattern3)) { return false; @@ -443,18 +455,25 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep qDebug() << replyError; return true; } - } else if (httpStatus == httpStatusCodeNotImplemented) { + } + if (apiHttpStatus == httpStatusCodeNotImplemented) { if (responseBody.contains(updateRequestResponsePattern)) { return false; } else { qDebug() << replyError; return true; } - } else if (httpStatus == httpStatusCodeConflict) { + } + if (apiHttpStatus == httpStatusCodeConflict) { return false; - } else if (httpStatus == httpStatusCodeUnprocessableEntity) { + } + if (apiHttpStatus == httpStatusCodePaymentRequired) { return false; - } else if (replyError != QNetworkReply::NetworkError::NoError) { + } + if (apiHttpStatus == httpStatusCodeUnprocessableEntity) { + return apiErrorMessage != unprocessableSubscriptionMessage; + } + if (replyError != QNetworkReply::NetworkError::NoError) { qDebug() << replyError; return true; } diff --git a/client/core/defs.h b/client/core/defs.h index 5c24e76c..731af38e 100644 --- a/client/core/defs.h +++ b/client/core/defs.h @@ -123,6 +123,9 @@ namespace amnezia ApiUpdateRequestError = 1111, ApiSubscriptionExpiredError = 1112, ApiPurchaseError = 1113, + ApiSubscriptionNotActiveError = 1114, + ApiNoPurchasedSubscriptionsError = 1115, + ApiTrialAlreadyUsedError = 1116, // QFile errors OpenError = 1200, diff --git a/client/core/errorstrings.cpp b/client/core/errorstrings.cpp index 080663bc..20d094dd 100644 --- a/client/core/errorstrings.cpp +++ b/client/core/errorstrings.cpp @@ -80,6 +80,9 @@ QString errorString(ErrorCode code) { case (ErrorCode::ApiUpdateRequestError): errorMessage = QObject::tr("Please update the application to use this feature"); break; case (ErrorCode::ApiSubscriptionExpiredError): errorMessage = QObject::tr("Your Amnezia Premium subscription has expired.\n Please check your email for renewal instructions.\n If you haven't received an email, please contact our support."); break; case (ErrorCode::ApiPurchaseError): errorMessage = QObject::tr("Unable to process purchase"); break; + case (ErrorCode::ApiSubscriptionNotActiveError): errorMessage = QObject::tr("No active subscription found"); break; + case (ErrorCode::ApiNoPurchasedSubscriptionsError): errorMessage = QObject::tr("No purchased subscriptions found. Please purchase a subscription first"); break; + case (ErrorCode::ApiTrialAlreadyUsedError): errorMessage = QObject::tr("This email has already been used for trial activation"); break; // QFile errors case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break; diff --git a/client/images/controls/globe-2.svg b/client/images/controls/globe-2.svg new file mode 100644 index 00000000..9c2641b4 --- /dev/null +++ b/client/images/controls/globe-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/images/controls/infinity.svg b/client/images/controls/infinity.svg new file mode 100644 index 00000000..bf0d47f8 --- /dev/null +++ b/client/images/controls/infinity.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/images/controls/smartphone.svg b/client/images/controls/smartphone.svg new file mode 100644 index 00000000..faf03229 --- /dev/null +++ b/client/images/controls/smartphone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/platforms/ios/StoreKit2Helper.swift b/client/platforms/ios/StoreKit2Helper.swift new file mode 100644 index 00000000..c0472b38 --- /dev/null +++ b/client/platforms/ios/StoreKit2Helper.swift @@ -0,0 +1,178 @@ +import Foundation +import StoreKit + +@available(iOS 15.0, macOS 12.0, *) +@objcMembers +public class StoreKit2Helper: NSObject { + + public static let shared = StoreKit2Helper() + private static let errorDomain = "StoreKit2Helper" + + private struct EntitlementInfo { + let transactionId: UInt64 + let originalTransactionId: UInt64 + let productId: String + let purchaseDate: Date + + var dictionary: NSDictionary { + [ + "transactionId": String(transactionId), + "originalTransactionId": String(originalTransactionId), + "productId": productId + ] + } + } + + public func fetchCurrentEntitlements(completion: @escaping (Bool, [NSDictionary]?, NSError?) -> Void) { + Task { @MainActor in + do { + try await AppStore.sync() + + var entitlements: [EntitlementInfo] = [] + for await result in Transaction.currentEntitlements { + switch result { + case .verified(let transaction): + entitlements.append(EntitlementInfo(transactionId: transaction.id, + originalTransactionId: transaction.originalID, + productId: transaction.productID, + purchaseDate: transaction.purchaseDate)) + case .unverified(_, let error): + print("[IAP][StoreKit2] Unverified transaction skipped: \(error.localizedDescription)") + } + } + let sortedEntitlements = entitlements.sorted { lhs, rhs in + if lhs.purchaseDate != rhs.purchaseDate { + return lhs.purchaseDate > rhs.purchaseDate + } + return lhs.transactionId > rhs.transactionId + }.map { $0.dictionary } + completion(true, sortedEntitlements, nil) + } catch { + completion(false, nil, error as NSError) + } + } + } + + public func purchaseProduct(productIdentifier: String, completion: @escaping (Bool, String?, String?, String?, NSError?) -> Void) { + Task { + do { + let products = try await Product.products(for: [productIdentifier]) + guard let product = products.first else { + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: makeError(code: 0, description: "Product not found")) + return + } + let result = try await product.purchase() + switch result { + case .success(let verification): + switch verification { + case .verified(let transaction): + await transaction.finish() + completePurchase(completion: completion, success: true, transactionId: String(transaction.id), + productId: transaction.productID, originalTransactionId: String(transaction.originalID), error: nil) + case .unverified(_, let error): + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: error as NSError) + } + case .userCancelled: + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: makeError(code: 1, description: "Purchase cancelled")) + case .pending: + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: makeError(code: 2, description: "Purchase pending")) + @unknown default: + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: makeError(code: 3, description: "Unknown purchase result")) + } + } catch { + completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil, + error: error as NSError) + } + } + } + + private func storefrontCurrencyCode(for product: Product) -> String { + product.priceFormatStyle.locale.currencyCode ?? "" + } + + private func subscriptionBillingMonths(_ period: Product.SubscriptionPeriod) -> Double { + let periodValue = Double(period.value) + switch period.unit { + case .day: + return periodValue / 30.0 + case .week: + return periodValue * 7.0 / 30.0 + case .month: + return periodValue + case .year: + return periodValue * 12.0 + @unknown default: + return periodValue + } + } + + public func fetchProducts(identifiers: Set, completion: @escaping ([NSDictionary], [String], NSError?) -> Void) { + Task { + do { + let products = try await Product.products(for: identifiers) + let productDicts = products.map { product in productDictionary(for: product) } + let fetchedIds = Set(products.map { $0.id }) + let invalidIdentifiers = identifiers.filter { !fetchedIds.contains($0) } + DispatchQueue.main.async { completion(productDicts, Array(invalidIdentifiers), nil) } + } catch { + DispatchQueue.main.async { completion([], Array(identifiers), error as NSError) } + } + } + } + + private func makeError(code: Int, description: String) -> NSError { + NSError(domain: Self.errorDomain, code: code, userInfo: [NSLocalizedDescriptionKey: description]) + } + + private func completePurchase(completion: @escaping (Bool, String?, String?, String?, NSError?) -> Void, + success: Bool, + transactionId: String?, + productId: String?, + originalTransactionId: String?, + error: NSError?) { + DispatchQueue.main.async { + completion(success, transactionId, productId, originalTransactionId, error) + } + } + + private func productDictionary(for product: Product) -> NSDictionary { + let currencyCode = storefrontCurrencyCode(for: product) + var productData: [String: Any] = [ + "productId": product.id, + "title": product.displayName, + "description": product.description, + "price": "\(product.price)", + "displayPrice": product.displayPrice, + "currencyCode": currencyCode, + "priceAmount": NSDecimalNumber(decimal: product.price).doubleValue + ] + if let subscription = product.subscription { + let billingMonths = subscriptionBillingMonths(subscription.subscriptionPeriod) + productData["subscriptionBillingMonths"] = billingMonths + if let perMonthPrice = displayPricePerMonth(for: product, billingMonths: billingMonths, currencyCode: currencyCode) { + productData["displayPricePerMonth"] = perMonthPrice + } + } + return productData as NSDictionary + } + + private func displayPricePerMonth(for product: Product, billingMonths: Double, currencyCode: String) -> String? { + if billingMonths <= 1e-6 { + return nil + } + + let perMonthPrice = product.price / Decimal(billingMonths) + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = product.priceFormatStyle.locale + if !currencyCode.isEmpty { + formatter.currencyCode = currencyCode + } + return formatter.string(from: NSDecimalNumber(decimal: perMonthPrice)) + } +} diff --git a/client/platforms/ios/StoreKitController.mm b/client/platforms/ios/StoreKitController.mm index 0a512d02..14a1e39a 100644 --- a/client/platforms/ios/StoreKitController.mm +++ b/client/platforms/ios/StoreKitController.mm @@ -4,27 +4,20 @@ #import "StoreKitController.h" #import +#import #include #include -API_AVAILABLE(ios(15.0), macos(12.0)) -@interface StoreKitController () -@property (nonatomic, copy) void (^purchaseCompletion)(BOOL success, - NSString *_Nullable transactionId, - NSString *_Nullable productId, - NSString *_Nullable originalTransactionId, - NSError *_Nullable error); -@property (nonatomic, copy) void (^restoreCompletion)(BOOL success, - NSArray *_Nullable restoredTransactions, - NSError *_Nullable error); -@property (nonatomic, copy) void (^productsFetchCompletion)(NSArray *products, - NSArray *invalidIdentifiers, - NSError *_Nullable error); -@property (nonatomic, strong) SKProductsRequest *productsRequest; -@property (nonatomic, strong) NSMutableArray *restoredTransactions; -@end +namespace +{ +QString toQString(NSString *value) +{ + return QString::fromUtf8((value ?: @"").UTF8String); +} +} +API_AVAILABLE(ios(15.0), macos(12.0)) @implementation StoreKitController + (instancetype)sharedInstance @@ -42,17 +35,9 @@ API_AVAILABLE(ios(15.0), macos(12.0)) - (instancetype)init API_AVAILABLE(ios(15.0), macos(12.0)) { self = [super init]; - if (self) { - [[SKPaymentQueue defaultQueue] addTransactionObserver:self]; - } return self; } -- (void)dealloc -{ - [[SKPaymentQueue defaultQueue] removeTransactionObserver:self]; -} - - (void)purchaseProduct:(NSString *)productIdentifier completion:(void (^)(BOOL success, NSString *_Nullable transactionId, @@ -60,41 +45,48 @@ API_AVAILABLE(ios(15.0), macos(12.0)) NSString *_Nullable originalTransactionId, NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0)) { - self.purchaseCompletion = completion; - - qInfo().noquote() << "[IAP][StoreKit] Starting purchase for" << QString::fromUtf8(productIdentifier.UTF8String); - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [self performPurchaseAsync:productIdentifier]; - }); -} - -- (void)performPurchaseAsync:(NSString *)productIdentifier API_AVAILABLE(ios(15.0), macos(12.0)) -{ - dispatch_async(dispatch_get_main_queue(), ^{ - @try { - SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithObject:productIdentifier]]; - request.delegate = self; - [request start]; - - } @catch (NSException *exception) { - NSError *error = [NSError errorWithDomain:@"StoreKitController" - code:1 - userInfo:@{ NSLocalizedDescriptionKey : exception.reason ?: @"Purchase failed" }]; - if (self.purchaseCompletion) { - self.purchaseCompletion(NO, nil, nil, nil, error); - self.purchaseCompletion = nil; - } + qInfo().noquote() << "[IAP][StoreKit2] Starting purchase for" << QString::fromUtf8(productIdentifier.UTF8String); + [[StoreKit2Helper shared] purchaseProductWithProductIdentifier:productIdentifier + completion:^(BOOL success, + NSString *transactionId, + NSString *productId, + NSString *originalTransactionId, + NSError *error) { + if (success) { + qInfo().noquote() << "[IAP][StoreKit2] Purchase success. transactionId =" << toQString(transactionId) + << "originalTransactionId =" << toQString(originalTransactionId) << "productId =" << toQString(productId); + } else if (error) { + qWarning().noquote() << "[IAP][StoreKit2] Purchase failed:" << toQString(error.localizedDescription); } - }); + if (completion) { + completion(success, transactionId, productId, originalTransactionId, error); + } + }]; } - (void)restorePurchasesWithCompletion:(void (^)(BOOL success, NSArray *_Nullable restoredTransactions, NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0)) { - self.restoreCompletion = completion; - self.restoredTransactions = [NSMutableArray array]; - [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; + [[StoreKit2Helper shared] fetchCurrentEntitlementsWithCompletion:^(BOOL success, + NSArray *entitlements, + NSError *error) { + if (success) { + qInfo().noquote() << "[IAP][StoreKit2] currentEntitlements returned" + << (int)(entitlements ? entitlements.count : 0) << "active entitlements"; + for (NSDictionary *entitlement in entitlements) { + qInfo().noquote() << "[IAP][StoreKit2] Active entitlement:" + << "transactionId=" << toQString(entitlement[@"transactionId"]) + << "originalTransactionId=" << toQString(entitlement[@"originalTransactionId"]) + << "productId=" << toQString(entitlement[@"productId"]); + } + } else { + qWarning().noquote() << "[IAP][StoreKit2] fetchCurrentEntitlements failed:" << toQString(error.localizedDescription); + } + if (completion) { + completion(success, entitlements, error); + } + }]; } - (void)fetchProductsWithIdentifiers:(NSSet *)productIdentifiers @@ -102,163 +94,21 @@ API_AVAILABLE(ios(15.0), macos(12.0)) NSArray *invalidIdentifiers, NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0)) { - self.productsFetchCompletion = completion; - self.productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers]; - self.productsRequest.delegate = self; - [self.productsRequest start]; -} - -#pragma mark - SKProductsRequestDelegate / SKRequestDelegate - -- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response -{ - if (self.purchaseCompletion) { - SKProduct *product = response.products.firstObject; - if (!product) { - NSError *error = [NSError errorWithDomain:@"StoreKitController" - code:0 - userInfo:@{ NSLocalizedDescriptionKey : @"Product not found" }]; - self.purchaseCompletion(NO, nil, nil, nil, error); - self.purchaseCompletion = nil; - self.productsRequest = nil; - return; - } - NSString *currencyCode = [product.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @""; - NSString *priceString = [product.price stringValue] ?: @""; - qInfo().noquote() << "[IAP][StoreKit] Received product" << QString::fromUtf8(product.productIdentifier.UTF8String) - << "price=" << QString::fromUtf8(priceString.UTF8String) - << "currency=" << QString::fromUtf8(currencyCode.UTF8String); - SKPayment *payment = [SKPayment paymentWithProduct:product]; - [[SKPaymentQueue defaultQueue] addPayment:payment]; - self.productsRequest = nil; - return; - } - - if (self.productsFetchCompletion) { - NSMutableArray *productDicts = [NSMutableArray array]; - for (SKProduct *p in response.products) { - NSDictionary *productDict = @{ - @"productId": p.productIdentifier, - @"title": p.localizedTitle, - @"description": p.localizedDescription, - @"price": p.price.stringValue, - @"currencyCode": [p.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @"" - }; - [productDicts addObject:productDict]; - NSString *productCurrency = [p.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @""; - NSString *productPrice = [p.price stringValue] ?: @""; - qInfo().noquote() << "[IAP][StoreKit] Fetched product info" << QString::fromUtf8(p.productIdentifier.UTF8String) - << "price=" << QString::fromUtf8(productPrice.UTF8String) - << "currency=" << QString::fromUtf8(productCurrency.UTF8String); - } - - self.productsFetchCompletion(productDicts, response.invalidProductIdentifiers, nil); - self.productsFetchCompletion = nil; - self.productsRequest = nil; - return; - } -} - -- (void)request:(SKRequest *)request didFailWithError:(NSError *)error -{ - if (self.purchaseCompletion) { - self.purchaseCompletion(NO, nil, nil, nil, error); - self.purchaseCompletion = nil; - } - if (self.productsFetchCompletion) { - self.productsFetchCompletion(@[], @[], error); - self.productsFetchCompletion = nil; - } - self.productsRequest = nil; -} - -#pragma mark - SKPaymentTransactionObserver - -- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions -{ - for (SKPaymentTransaction *transaction in transactions) { - switch (transaction.transactionState) { - case SKPaymentTransactionStatePurchased: { - NSString *originalTransactionId = transaction.originalTransaction.transactionIdentifier ?: transaction.transactionIdentifier; - qInfo().noquote() << "[IAP][StoreKit] Transaction purchased" << QString::fromUtf8(transaction.transactionIdentifier.UTF8String) - << "original=" << QString::fromUtf8((originalTransactionId ?: @"").UTF8String) - << "product=" << QString::fromUtf8(transaction.payment.productIdentifier.UTF8String); - - if (self.purchaseCompletion) { - self.purchaseCompletion(YES, - transaction.transactionIdentifier, - transaction.payment.productIdentifier, - originalTransactionId, - nil); - self.purchaseCompletion = nil; + [[StoreKit2Helper shared] fetchProductsWithIdentifiers:productIdentifiers + completion:^(NSArray *products, + NSArray *invalidIdentifiers, + NSError *error) { + if (!error) { + for (NSDictionary *productInfo in products) { + qInfo().noquote() << "[IAP][StoreKit2] Fetched product info" << toQString(productInfo[@"productId"]) + << "price=" << toQString(productInfo[@"price"]) + << "currency=" << toQString(productInfo[@"currencyCode"]); } - [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; - break; } - case SKPaymentTransactionStateFailed: - qInfo().noquote() << "[IAP][StoreKit] Transaction failed" << QString::fromUtf8(transaction.transactionIdentifier.UTF8String) - << "product=" << QString::fromUtf8(transaction.payment.productIdentifier.UTF8String) - << "error=" << QString::fromUtf8(transaction.error.localizedDescription.UTF8String); - if (self.purchaseCompletion) { - self.purchaseCompletion(NO, - transaction.transactionIdentifier, - transaction.payment.productIdentifier, - nil, - transaction.error); - self.purchaseCompletion = nil; - } - [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; - break; - case SKPaymentTransactionStateRestored: { - if (self.restoreCompletion) { - NSString *transactionId = transaction.transactionIdentifier ?: @""; - NSString *originalTransactionId = transaction.originalTransaction.transactionIdentifier ?: transactionId; - NSString *productId = transaction.payment.productIdentifier ?: @""; - - qInfo().noquote() << "[IAP][StoreKit] Transaction restored" - << QString::fromUtf8(transactionId.UTF8String) - << "original=" - << QString::fromUtf8((originalTransactionId ?: @"").UTF8String) - << "product=" - << QString::fromUtf8((productId ?: @"").UTF8String); - - NSDictionary *info = @{ - @"transactionId": transactionId, - @"originalTransactionId": originalTransactionId ?: @"", - @"productId": productId ?: @"" - }; - if (!self.restoredTransactions) { - self.restoredTransactions = [NSMutableArray array]; - } - [self.restoredTransactions addObject:info]; - } - [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; - break; + if (completion) { + completion(products ?: @[], invalidIdentifiers ?: @[], error); } - case SKPaymentTransactionStatePurchasing: - case SKPaymentTransactionStateDeferred: - break; - } - } -} - -- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue -{ - if (self.restoreCompletion) { - NSArray *transactions = [self.restoredTransactions copy]; - self.restoreCompletion(YES, transactions, nil); - self.restoreCompletion = nil; - self.restoredTransactions = nil; - } -} - -- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error -{ - if (self.restoreCompletion) { - self.restoreCompletion(NO, nil, error); - self.restoreCompletion = nil; - self.restoredTransactions = nil; - } + }]; } @end diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index fc9498d0..b2a5dcd3 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -179,8 +179,9 @@ bool IosController::initialize() [NETunnelProviderManager loadAllFromPreferencesWithCompletionHandler:^(NSArray * _Nullable managers, NSError * _Nullable error) { @try { if (error) { - qDebug() << "IosController::initialize : Error:" << [error.localizedDescription UTF8String]; - emit connectionStateChanged(Vpn::ConnectionState::Error); + qWarning() << "IosController::initialize : loadAllFromPreferences failed:" + << [error.localizedDescription UTF8String] + << "domain:" << [error.domain UTF8String] << "code:" << error.code; ok = false; return; } @@ -397,8 +398,14 @@ void IosController::vpnStatusDidChange(void *pNotification) { NETunnelProviderSession *session = (NETunnelProviderSession *)pNotification; - if (session /* && session == TunnelManager.session */ ) { - qDebug() << "IosController::vpnStatusDidChange" << iosStatusToState(session.status) << session; + if (!session) { + return; + } + if (!m_currentTunnel || (NETunnelProviderSession *)m_currentTunnel.connection != session) { + return; + } + + qDebug() << "IosController::vpnStatusDidChange" << iosStatusToState(session.status) << session; if (session.status == NEVPNStatusDisconnected) { if (@available(iOS 16.0, *)) { @@ -512,7 +519,6 @@ void IosController::vpnStatusDidChange(void *pNotification) m_statusRequestInFlight = false; } emitConnectionStateIfChanged(nextState); - } } void IosController::vpnConfigurationDidChange(void *pNotification) @@ -844,39 +850,49 @@ void IosController::startTunnel() m_rxBytes = 0; m_txBytes = 0; - [m_currentTunnel setEnabled:YES]; + NETunnelProviderManager *tunnel = m_currentTunnel; + [tunnel setEnabled:YES]; - [m_currentTunnel saveToPreferencesWithCompletionHandler:^(NSError *saveError) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + dispatch_async(dispatch_get_main_queue(), ^{ + [tunnel saveToPreferencesWithCompletionHandler:^(NSError *saveError) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (saveError) { + qDebug().nospace() << "IosController::startTunnel" << protocolName << ": Connect " << protocolName + << " Tunnel Save Error" << saveError.localizedDescription.UTF8String << " domain:" + << saveError.domain.UTF8String << " code:" << saveError.code; + emit connectionStateChanged(Vpn::ConnectionState::Error); + return; + } - if (saveError) { - qDebug().nospace() << "IosController::startTunnel" << protocolName << ": Connect " << protocolName << " Tunnel Save Error" << saveError.localizedDescription.UTF8String; - emit connectionStateChanged(Vpn::ConnectionState::Error); - return; - } + [tunnel loadFromPreferencesWithCompletionHandler:^(NSError *loadError) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (loadError) { + qDebug().nospace() << "IosController::startTunnel :" << tunnel.localizedDescription << protocolName + << ": Connect " << protocolName << " Tunnel Load Error" + << loadError.localizedDescription.UTF8String; + emit connectionStateChanged(Vpn::ConnectionState::Error); + return; + } - [m_currentTunnel loadFromPreferencesWithCompletionHandler:^(NSError *loadError) { - if (loadError) { - qDebug().nospace() << "IosController::startTunnel :" << m_currentTunnel.localizedDescription << protocolName << ": Connect " << protocolName << " Tunnel Load Error" << loadError.localizedDescription.UTF8String; - emit connectionStateChanged(Vpn::ConnectionState::Error); - return; - } + NSError *startError = nil; + qDebug() << iosStatusToState(tunnel.connection.status); - NSError *startError = nil; - qDebug() << iosStatusToState(m_currentTunnel.connection.status); + BOOL started = [tunnel.connection startVPNTunnelWithOptions:nil andReturnError:&startError]; - BOOL started = [m_currentTunnel.connection startVPNTunnelWithOptions:nil andReturnError:&startError]; - - if (!started || startError) { - qDebug().nospace() << "IosController::startTunnel :" << m_currentTunnel.localizedDescription << protocolName << " : Connect " << protocolName << " Tunnel Start Error" - << (startError ? startError.localizedDescription.UTF8String : ""); - emit connectionStateChanged(Vpn::ConnectionState::Error); - } else { - qDebug().nospace() << "IosController::startTunnel :" << m_currentTunnel.localizedDescription << protocolName << " : Starting the tunnel succeeded"; - } - }]; - }); - }]; + if (!started || startError) { + qDebug().nospace() << "IosController::startTunnel :" << tunnel.localizedDescription << protocolName + << " : Connect " << protocolName << " Tunnel Start Error" + << (startError ? startError.localizedDescription.UTF8String : ""); + emit connectionStateChanged(Vpn::ConnectionState::Error); + } else { + qDebug().nospace() << "IosController::startTunnel :" << tunnel.localizedDescription << protocolName + << " : Starting the tunnel succeeded"; + } + }); + }]; + }); + }]; + }); } bool IosController::isOurManager(NETunnelProviderManager* manager) { @@ -1131,14 +1147,26 @@ void IosController::fetchProducts(const QStringList &productIds, NSArray * _Nonnull invalidIdentifiers, NSError * _Nullable error) { QList outProducts; - for (NSDictionary *p in products) { - QVariantMap m; - m["productId"] = QString::fromUtf8([p[@"productId"] UTF8String]); - m["title"] = QString::fromUtf8([p[@"title"] UTF8String]); - m["description"] = QString::fromUtf8([p[@"description"] UTF8String]); - m["price"] = QString::fromUtf8([p[@"price"] UTF8String]); - m["currencyCode"] = QString::fromUtf8([p[@"currencyCode"] UTF8String]); - outProducts.push_back(m); + for (NSDictionary *productInfo in products) { + QVariantMap productData; + productData["productId"] = QString::fromUtf8([productInfo[@"productId"] UTF8String]); + productData["title"] = QString::fromUtf8([productInfo[@"title"] UTF8String]); + productData["description"] = QString::fromUtf8([productInfo[@"description"] UTF8String]); + productData["price"] = QString::fromUtf8([productInfo[@"price"] UTF8String]); + if (productInfo[@"displayPrice"]) { + productData["displayPrice"] = QString::fromUtf8([productInfo[@"displayPrice"] UTF8String]); + } + productData["currencyCode"] = QString::fromUtf8([productInfo[@"currencyCode"] UTF8String]); + if (productInfo[@"priceAmount"]) { + productData["priceAmount"] = [productInfo[@"priceAmount"] doubleValue]; + } + if (productInfo[@"subscriptionBillingMonths"]) { + productData["subscriptionBillingMonths"] = [productInfo[@"subscriptionBillingMonths"] doubleValue]; + } + if (productInfo[@"displayPricePerMonth"]) { + productData["displayPricePerMonth"] = QString::fromUtf8([productInfo[@"displayPricePerMonth"] UTF8String]); + } + outProducts.push_back(productData); } QStringList invalid; diff --git a/client/resources.qrc b/client/resources.qrc index a1e4c656..51b378af 100644 --- a/client/resources.qrc +++ b/client/resources.qrc @@ -27,10 +27,12 @@ images/controls/folder-open.svg images/controls/folder-search-2.svg images/controls/gauge.svg + images/controls/globe-2.svg images/controls/github.svg images/controls/help-circle.svg images/controls/history.svg images/controls/home.svg + images/controls/infinity.svg images/controls/info.svg images/controls/mail.svg images/controls/map-pin.svg @@ -55,6 +57,7 @@ images/controls/settings-news.svg images/controls/share-2.svg images/controls/split-tunneling.svg + images/controls/smartphone.svg images/controls/tag.svg images/controls/telegram.svg images/controls/text-cursor.svg @@ -133,6 +136,10 @@ ui/qml/Components/HomeContainersListView.qml ui/qml/Components/HomeSplitTunnelingDrawer.qml ui/qml/Components/InstalledAppsDrawer.qml + ui/qml/Components/BenefitRow.qml + ui/qml/Components/BenefitsPanel.qml + ui/qml/Components/SubscriptionPlanCard.qml + ui/qml/Components/TermsAndPrivacyText.qml ui/qml/Components/QuestionDrawer.qml ui/qml/Components/SelectLanguageDrawer.qml ui/qml/Components/SubscriptionExpiredDrawer.qml @@ -181,6 +188,7 @@ ui/qml/Controls2/TextTypes/LabelTextType.qml ui/qml/Controls2/TextTypes/ListItemTitleType.qml ui/qml/Controls2/TextTypes/ParagraphTextType.qml + ui/qml/Controls2/TextTypes/BadgeTextType.qml ui/qml/Controls2/TextTypes/SmallTextType.qml ui/qml/Controls2/TopCloseButtonType.qml ui/qml/Controls2/VerticalRadioButton.qml @@ -226,7 +234,9 @@ ui/qml/Pages2/PageSettingsNewsDetail.qml ui/qml/Pages2/PageProtocolAwgClientSettings.qml ui/qml/Pages2/PageProtocolWireGuardClientSettings.qml - ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml + ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml + ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml + ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml ui/qml/Pages2/PageSetupWizardApiServicesList.qml ui/qml/Pages2/PageSetupWizardConfigSource.qml ui/qml/Pages2/PageSetupWizardCredentials.qml diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index 987d9240..bda3db97 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -9,9 +9,14 @@ #include "ui/controllers/systemController.h" #include "version.h" #include +#include #include #include +#include +#include #include +#include +#include #include "platforms/ios/ios_controller.h" @@ -39,6 +44,15 @@ namespace constexpr char serviceInfo[] = "service_info"; constexpr char serviceProtocol[] = "service_protocol"; + constexpr char services[] = "services"; + constexpr char serviceDescription[] = "service_description"; + constexpr char subscriptionPlans[] = "subscription_plans"; + constexpr char storeProductId[] = "store_product_id"; + constexpr char priceLabel[] = "price_label"; + constexpr char subtitle[] = "subtitle"; + constexpr char isTrial[] = "is_trial"; + constexpr char minPriceLabel[] = "min_price_label"; + constexpr char apiPayload[] = "api_payload"; constexpr char keyPayload[] = "key_payload"; @@ -47,9 +61,6 @@ namespace constexpr char config[] = "config"; - constexpr char subscription[] = "subscription"; - constexpr char endDate[] = "end_date"; - constexpr char isConnectEvent[] = "is_connect_event"; } @@ -241,13 +252,190 @@ namespace return ErrorCode::NoError; } + +#if defined(Q_OS_IOS) || defined(MACOS_NE) + struct StoreKitPlanQuote { + QString displayPrice; + double priceAmount = 0.0; + double subscriptionBillingMonths = 0.0; + QString displayPricePerMonth; + }; + + constexpr double kOneMonthThreshold = 1.0 + 1e-6; + constexpr double kMonthsFallbackThreshold = 1e-6; + constexpr double kMonthlyPriceEpsilon = 1e-9; + + QStringList collectPremiumStoreProductIds(const QJsonArray &services) + { + QStringList productIds; + QSet seenProductIds; + for (const QJsonValue &serviceValue : services) { + const QJsonObject serviceObject = serviceValue.toObject(); + if (serviceObject.value(configKey::serviceType).toString() != serviceType::amneziaPremium) { + continue; + } + const QJsonArray subscriptionPlans = + serviceObject.value(configKey::serviceDescription).toObject().value(configKey::subscriptionPlans).toArray(); + for (const QJsonValue &planValue : subscriptionPlans) { + if (!planValue.isObject()) { + continue; + } + const QString storeProductId = planValue.toObject().value(configKey::storeProductId).toString(); + if (storeProductId.isEmpty() || seenProductIds.contains(storeProductId)) { + continue; + } + seenProductIds.insert(storeProductId); + productIds.append(storeProductId); + } + } + return productIds; + } + + QHash buildStoreKitQuoteMap(const QList &fetchedProducts) + { + QHash quotesByProductId; + quotesByProductId.reserve(fetchedProducts.size()); + + for (const QVariantMap &productInfo : fetchedProducts) { + const QString productId = productInfo.value(QStringLiteral("productId")).toString(); + if (productId.isEmpty()) { + continue; + } + + QString displayPrice = productInfo.value(QStringLiteral("displayPrice")).toString(); + if (displayPrice.isEmpty()) { + const QString price = productInfo.value(QStringLiteral("price")).toString(); + const QString currencyCode = productInfo.value(QStringLiteral("currencyCode")).toString(); + displayPrice = currencyCode.isEmpty() ? price : (price + QLatin1Char(' ') + currencyCode); + } + + StoreKitPlanQuote quote; + quote.displayPrice = displayPrice; + quote.priceAmount = productInfo.value(QStringLiteral("priceAmount")).toDouble(); + quote.subscriptionBillingMonths = productInfo.value(QStringLiteral("subscriptionBillingMonths")).toDouble(); + quote.displayPricePerMonth = productInfo.value(QStringLiteral("displayPricePerMonth")).toString(); + quotesByProductId.insert(productId, quote); + } + + return quotesByProductId; + } + + void mergeStoreKitPricesIntoPremiumPlans(QJsonObject &data) + { + QJsonArray services = data.value(configKey::services).toArray(); + if (services.isEmpty()) { + return; + } + + const QStringList productIds = collectPremiumStoreProductIds(services); + if (productIds.isEmpty()) { + qInfo().noquote() << "[IAP] No store_product_id in premium plans; skip StoreKit merge into services payload"; + return; + } + + QList fetchedProducts; + QEventLoop loop; + IosController::Instance()->fetchProducts(productIds, + [&](const QList &products, const QStringList &invalidIds, + const QString &errorString) { + if (!errorString.isEmpty()) { + qWarning().noquote() << "[IAP] StoreKit merge fetch:" << errorString; + } + if (!invalidIds.isEmpty()) { + qWarning().noquote() << "[IAP] Unknown App Store product ids:" << invalidIds; + } + fetchedProducts = products; + loop.quit(); + }); + loop.exec(); + + const QHash quotesByProductId = buildStoreKitQuoteMap(fetchedProducts); + + for (int serviceIndex = 0; serviceIndex < services.size(); ++serviceIndex) { + QJsonObject serviceObject = services.at(serviceIndex).toObject(); + if (serviceObject.value(configKey::serviceType).toString() != serviceType::amneziaPremium) { + continue; + } + + QJsonObject descriptionObject = serviceObject.value(configKey::serviceDescription).toObject(); + const QJsonArray sourcePlans = descriptionObject.value(configKey::subscriptionPlans).toArray(); + + QJsonArray mergedPlans; + double minMonthlyAmount = std::numeric_limits::infinity(); + QString minMonthlyDisplay; + + for (const QJsonValue &planValue : sourcePlans) { + if (!planValue.isObject()) { + continue; + } + + QJsonObject planObject = planValue.toObject(); + const QString storeProductId = planObject.value(configKey::storeProductId).toString(); + if (storeProductId.isEmpty()) { + continue; + } + + const auto quoteIterator = quotesByProductId.constFind(storeProductId); + if (quoteIterator == quotesByProductId.cend()) { + continue; + } + + const bool isTrialPlan = planObject.value(configKey::isTrial).toBool(); + const StoreKitPlanQuote "e = *quoteIterator; + planObject.insert(configKey::priceLabel, quote.displayPrice); + + const double months = quote.subscriptionBillingMonths; + if (!isTrialPlan && months > kOneMonthThreshold && !quote.displayPricePerMonth.isEmpty()) { + planObject.insert( + configKey::subtitle, + QCoreApplication::translate("ApiConfigsController", "%1/mo", "IAP: price per month in plan subtitle") + .arg(quote.displayPricePerMonth)); + } + + if (!isTrialPlan && quote.priceAmount > 0.0) { + const double monthsForMin = months > kMonthsFallbackThreshold ? months : 1.0; + const double monthly = quote.priceAmount / monthsForMin; + if (monthly < minMonthlyAmount - kMonthlyPriceEpsilon) { + minMonthlyAmount = monthly; + minMonthlyDisplay = !quote.displayPricePerMonth.isEmpty() ? quote.displayPricePerMonth : quote.displayPrice; + } + } + + mergedPlans.append(planObject); + } + + descriptionObject.insert(configKey::subscriptionPlans, mergedPlans); + if (minMonthlyAmount < std::numeric_limits::infinity() && !minMonthlyDisplay.isEmpty()) { + descriptionObject.insert(configKey::minPriceLabel, + QCoreApplication::translate("ApiConfigsController", "from %1 per month", + "IAP: card footer minimum monthly price from StoreKit") + .arg(minMonthlyDisplay)); + } + serviceObject.insert(configKey::serviceDescription, descriptionObject); + services.replace(serviceIndex, serviceObject); + } + data.insert(configKey::services, services); + } +#endif } ApiConfigsController::ApiConfigsController(const QSharedPointer &serversModel, const QSharedPointer &apiServicesModel, + const QSharedPointer &subscriptionPlansModel, + const QSharedPointer &benefitsModel, const std::shared_ptr &settings, QObject *parent) - : QObject(parent), m_serversModel(serversModel), m_apiServicesModel(apiServicesModel), m_settings(settings) + : QObject(parent) + , m_serversModel(serversModel) + , m_apiServicesModel(apiServicesModel) + , m_subscriptionPlansModel(subscriptionPlansModel) + , m_benefitsModel(benefitsModel) + , m_settings(settings) { + connect(m_apiServicesModel.data(), &ApiServicesModel::serviceSelectionChanged, this, [this]() { + const ApiServicesModel::ApiServicesData serviceData = m_apiServicesModel->selectedServiceData(); + m_subscriptionPlansModel->updateModel(serviceData.subscriptionPlansJson); + m_benefitsModel->updateModel(serviceData.benefits); + }); } bool ApiConfigsController::exportVpnKey(const QString &fileName) @@ -384,51 +572,11 @@ bool ApiConfigsController::fillAvailableServices() } QJsonObject data = QJsonDocument::fromJson(responseBody).object(); - + #if defined(Q_OS_IOS) || defined(MACOS_NE) - QEventLoop waitProducts; - bool productsFetched = false; - QString productPrice; - QString productCurrency; - - IosController::Instance()->fetchProducts(QStringList() << QStringLiteral("amnezia_premium_6_month"), - [&](const QList &products, - const QStringList &invalidIds, - const QString &errorString) { - if (!errorString.isEmpty() || products.isEmpty()) { - qWarning().noquote() << "[IAP] Failed to fetch product price:" << errorString; - } else { - const auto &product = products.first(); - productPrice = product.value("price").toString(); - productCurrency = product.value("currencyCode").toString(); - productsFetched = true; - qInfo().noquote() << "[IAP] Fetched product price:" << productPrice << productCurrency; - } - waitProducts.quit(); - }); - waitProducts.exec(); - - if (productsFetched && !productPrice.isEmpty()) { - QJsonArray services = data.value("services").toArray(); - for (int i = 0; i < services.size(); ++i) { - QJsonObject service = services[i].toObject(); - if (service.value(configKey::serviceType).toString() == serviceType::amneziaPremium) { - QJsonObject serviceInfo = service.value(configKey::serviceInfo).toObject(); - QString formattedPrice = productPrice; - if (!productCurrency.isEmpty()) { - formattedPrice += " " + productCurrency; - } - serviceInfo["price"] = formattedPrice; - service[configKey::serviceInfo] = serviceInfo; - services[i] = service; - data["services"] = services; - qInfo().noquote() << "[IAP] Updated premium service price in data:" << formattedPrice; - break; - } - } - } + mergeStoreKitPricesIntoPremiumPlans(data); #endif - + m_apiServicesModel->updateModel(data); if (m_apiServicesModel->rowCount() > 0) { m_apiServicesModel->setServiceIndex(0); @@ -439,39 +587,42 @@ bool ApiConfigsController::fillAvailableServices() bool ApiConfigsController::importService() { #if defined(Q_OS_IOS) || defined(MACOS_NE) - bool isIosOrMacOsNe = true; + const bool isIosOrMacOsNe = true; #else - bool isIosOrMacOsNe = false; + const bool isIosOrMacOsNe = false; #endif if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaPremium) { if (isIosOrMacOsNe) { - importSerivceFromAppStore(); - return true; + return importPremiumFromAppStore(QString()); } } else if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaFree) { - importServiceFromGateway(); - return true; + return importFreeFromGateway(); } return false; } -bool ApiConfigsController::importSerivceFromAppStore() +bool ApiConfigsController::importPremiumFromAppStore(const QString &storeProductId) { #if defined(Q_OS_IOS) || defined(MACOS_NE) + QString productId = storeProductId.trimmed(); + if (productId.isEmpty()) { + productId = QStringLiteral("amnezia_premium_6_month"); + } + bool purchaseOk = false; QString originalTransactionId; QString storeTransactionId; - QString storeProductId; + QString purchasedStoreProductId; QString purchaseError; QEventLoop waitPurchase; - IosController::Instance()->purchaseProduct(QStringLiteral("amnezia_premium_6_month"), - [&](bool success, const QString &txId, const QString &purchasedProductId, - const QString &originalTxId, const QString &errorString) { + IosController::Instance()->purchaseProduct(productId, + [&](bool success, const QString &transactionId, const QString &purchasedProductId, + const QString &originalTransactionIdResponse, const QString &errorString) { purchaseOk = success; - originalTransactionId = originalTxId; - storeTransactionId = txId; - storeProductId = purchasedProductId; + originalTransactionId = originalTransactionIdResponse; + storeTransactionId = transactionId; + purchasedStoreProductId = purchasedProductId; purchaseError = errorString; waitPurchase.quit(); }); @@ -483,7 +634,7 @@ bool ApiConfigsController::importSerivceFromAppStore() return false; } qInfo().noquote() << "[IAP] Purchase success. transactionId =" << storeTransactionId - << "originalTransactionId =" << originalTransactionId << "productId =" << storeProductId; + << "originalTransactionId =" << originalTransactionId << "productId =" << purchasedStoreProductId; GatewayRequestData gatewayRequestData { QSysInfo::productType(), QString(APP_VERSION), @@ -507,18 +658,26 @@ bool ApiConfigsController::importSerivceFromAppStore() return false; } - errorCode = importServiceFromBilling(responseBody, isTestPurchase); + int duplicateServerIndex = -1; + errorCode = importServiceFromBilling(responseBody, isTestPurchase, duplicateServerIndex); + if (errorCode == ErrorCode::ApiConfigAlreadyAdded) { + emit installServerFromApiFinished(tr("This subscription is already in the app."), duplicateServerIndex); + return true; + } if (errorCode != ErrorCode::NoError) { emit errorOccurred(errorCode); return false; } - - emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); -#endif + emit installServerFromApiFinished( + tr("%1 was added to the app.").arg(m_apiServicesModel->getSelectedServiceName())); return true; +#else + Q_UNUSED(storeProductId); + return false; +#endif } -bool ApiConfigsController::restoreSerivceFromAppStore() +bool ApiConfigsController::restoreServiceFromAppStore() { #if defined(Q_OS_IOS) || defined(MACOS_NE) const QString premiumServiceType = QStringLiteral("amnezia-premium"); @@ -534,20 +693,12 @@ bool ApiConfigsController::restoreSerivceFromAppStore() return false; } - // Ensure we have a valid premium selection for gateway requests - bool premiumSelected = false; - for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) { - m_apiServicesModel->setServiceIndex(i); - if (m_apiServicesModel->getSelectedServiceType() == premiumServiceType) { - premiumSelected = true; - break; - } - } - - if (!premiumSelected) { + const int premiumServiceIndex = m_apiServicesModel->serviceIndexForType(premiumServiceType); + if (premiumServiceIndex < 0) { emit errorOccurred(ErrorCode::ApiServicesMissingError); return false; } + m_apiServicesModel->setServiceIndex(premiumServiceIndex); bool restoreSuccess = false; QList restoredTransactions; @@ -569,15 +720,23 @@ bool ApiConfigsController::restoreSerivceFromAppStore() } if (restoredTransactions.isEmpty()) { - qInfo().noquote() << "[IAP] Restore completed, but no transactions were returned"; - emit errorOccurred(ErrorCode::ApiPurchaseError); + qInfo().noquote() << "[IAP] Restore completed, but no active entitlements found"; + emit errorOccurred(ErrorCode::ApiNoPurchasedSubscriptionsError); return false; } + const bool isTestPurchase = IosController::Instance()->isTestFlight(); + const QString serviceType = m_apiServicesModel->getSelectedServiceType(); + const QString serviceProtocol = m_apiServicesModel->getSelectedServiceProtocol(); + const QString countryCode = m_apiServicesModel->getCountryCode(); + const QString appLanguage = m_settings->getAppLanguage().name().split("_").first(); + const QString installationUuid = m_settings->getInstallationUuid(true); + bool hasInstalledConfig = false; bool duplicateConfigAlreadyPresent = false; - int duplicateCount = 0; - QSet processedTransactions; + int duplicateServerIndex = -1; + QSet processedOriginalTransactionIds; + for (const QVariantMap &transaction : restoredTransactions) { const QString originalTransactionId = transaction.value(QStringLiteral("originalTransactionId")).toString(); const QString transactionId = transaction.value(QStringLiteral("transactionId")).toString(); @@ -588,28 +747,28 @@ bool ApiConfigsController::restoreSerivceFromAppStore() continue; } - if (processedTransactions.contains(originalTransactionId)) { - duplicateCount++; + if (processedOriginalTransactionIds.contains(originalTransactionId)) { + qInfo().noquote() << "[IAP] Skipping duplicate restored transaction" << originalTransactionId; continue; } - processedTransactions.insert(originalTransactionId); + processedOriginalTransactionIds.insert(originalTransactionId); qInfo().noquote() << "[IAP] Restoring subscription. transactionId =" << transactionId << "originalTransactionId =" << originalTransactionId << "productId =" << productId; GatewayRequestData gatewayRequestData { QSysInfo::productType(), QString(APP_VERSION), - m_settings->getAppLanguage().name().split("_").first(), - m_settings->getInstallationUuid(true), - m_apiServicesModel->getCountryCode(), + appLanguage, + installationUuid, + countryCode, "", - m_apiServicesModel->getSelectedServiceType(), - m_apiServicesModel->getSelectedServiceProtocol(), + serviceType, + serviceProtocol, QJsonObject() }; QJsonObject apiPayload = gatewayRequestData.toJsonObject(); apiPayload[apiDefs::key::transactionId] = originalTransactionId; - auto isTestPurchase = IosController::Instance()->isTestFlight(); + QByteArray responseBody; ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase); if (errorCode != ErrorCode::NoError) { @@ -618,34 +777,42 @@ bool ApiConfigsController::restoreSerivceFromAppStore() continue; } - ErrorCode installError = importServiceFromBilling(responseBody, isTestPurchase); + int currentDuplicateServerIndex = -1; + errorCode = importServiceFromBilling(responseBody, isTestPurchase, currentDuplicateServerIndex); if (errorCode == ErrorCode::ApiConfigAlreadyAdded) { duplicateConfigAlreadyPresent = true; - qInfo().noquote() << "[IAP] Skipping restored transaction" << originalTransactionId - << "because subscription config with the same vpn_key already exists"; - } else if (errorCode != ErrorCode::NoError) { - qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction" << originalTransactionId; - } else { - hasInstalledConfig = true; + if (duplicateServerIndex < 0) { + duplicateServerIndex = currentDuplicateServerIndex; + } + qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists" << originalTransactionId; + continue; } + + if (errorCode != ErrorCode::NoError) { + qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction" << originalTransactionId + << "errorCode =" << static_cast(errorCode); + continue; + } + + hasInstalledConfig = true; } if (!hasInstalledConfig) { - const ErrorCode restoreError = duplicateConfigAlreadyPresent ? ErrorCode::ApiConfigAlreadyAdded : ErrorCode::ApiPurchaseError; - emit errorOccurred(restoreError); + if (duplicateConfigAlreadyPresent) { + emit installServerFromApiFinished(tr("This subscription is already in the app."), duplicateServerIndex); + return true; + } + + emit errorOccurred(ErrorCode::ApiPurchaseError); return false; } emit installServerFromApiFinished(tr("Subscription restored successfully.")); - if (duplicateCount > 0) { - qInfo().noquote() << "[IAP] Skipped" << duplicateCount - << "duplicate restored transactions for original transaction IDs already processed"; - } #endif return true; } -bool ApiConfigsController::importServiceFromGateway() +bool ApiConfigsController::importFreeFromGateway() { GatewayRequestData gatewayRequestData { QSysInfo::productType(), QString(APP_VERSION), @@ -697,6 +864,72 @@ bool ApiConfigsController::importServiceFromGateway() } } +bool ApiConfigsController::importTrialFromGateway(const QString &email) +{ + emit trialEmailError(QString()); + + const QString trimmedEmail = email.trimmed(); + if (trimmedEmail.isEmpty()) { + emit errorOccurred(ErrorCode::ApiConfigEmptyError); + return false; + } + + GatewayRequestData gatewayRequestData { QSysInfo::productType(), + QString(APP_VERSION), + m_settings->getAppLanguage().name().split("_").first(), + m_settings->getInstallationUuid(true), + m_apiServicesModel->getCountryCode(), + "", + m_apiServicesModel->getSelectedServiceType(), + m_apiServicesModel->getSelectedServiceProtocol(), + QJsonObject() }; + + ProtocolData protocolData = generateProtocolData(gatewayRequestData.serviceProtocol); + + QJsonObject apiPayload = gatewayRequestData.toJsonObject(); + appendProtocolDataToApiPayload(gatewayRequestData.serviceProtocol, protocolData, apiPayload); + apiPayload.insert(apiDefs::key::email, trimmedEmail); + + QByteArray responseBody; + ErrorCode errorCode = executeRequest(QString("%1v1/trial"), apiPayload, responseBody); + if (errorCode != ErrorCode::NoError) { + if (errorCode == ErrorCode::ApiTrialAlreadyUsedError) { + emit trialEmailError(tr("This email has already been used for trial activation. If you like the service, you can buy Premium.")); + return false; + } + emit errorOccurred(errorCode); + return false; + } + + QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object(); + QString key = responseObject.value(apiDefs::key::config).toString(); + if (key.isEmpty()) { + qWarning().noquote() << "[Trial] trial response does not contain config field"; + emit errorOccurred(ErrorCode::ApiConfigEmptyError); + return false; + } + + key.replace(QStringLiteral("vpn://"), QString()); + QByteArray configBytes = QByteArray::fromBase64(key.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); + QByteArray uncompressed = qUncompress(configBytes); + if (!uncompressed.isEmpty()) { + configBytes = uncompressed; + } + if (configBytes.isEmpty()) { + qWarning().noquote() << "[Trial] trial response config payload is empty"; + emit errorOccurred(ErrorCode::ApiConfigEmptyError); + return false; + } + + QJsonObject configObject = QJsonDocument::fromJson(configBytes).object(); + quint16 crc = qChecksum(QJsonDocument(configObject).toJson()); + configObject.insert(config_key::crc, crc); + m_serversModel->addServer(configObject); + + emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); + return true; +} + bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName, bool reloadServiceConfig) { @@ -740,6 +973,12 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const newApiConfig.insert(configKey::serviceType, apiConfig.value(configKey::serviceType)); newApiConfig.insert(configKey::serviceProtocol, apiConfig.value(configKey::serviceProtocol)); newApiConfig.insert(apiDefs::key::vpnKey, apiConfig.value(apiDefs::key::vpnKey)); + if (apiConfig.contains(apiDefs::key::isInAppPurchase)) { + newApiConfig.insert(apiDefs::key::isInAppPurchase, apiConfig.value(apiDefs::key::isInAppPurchase)); + } + if (apiConfig.contains(apiDefs::key::isTestPurchase)) { + newApiConfig.insert(apiDefs::key::isTestPurchase, apiConfig.value(apiDefs::key::isTestPurchase)); + } newServerConfig.insert(configKey::apiConfig, newApiConfig); newServerConfig.insert(configKey::authData, gatewayRequestData.authData); @@ -765,7 +1004,14 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const return true; } else { if (errorCode == ErrorCode::ApiSubscriptionExpiredError) { - emit subscriptionExpiredOnServer(); + if (!apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false)) { + apiConfig.insert(apiDefs::key::subscriptionExpiredByServer, true); + serverConfig.insert(configKey::apiConfig, apiConfig); + m_serversModel->editServer(serverConfig, serverIndex); + emit subscriptionExpiredOnServer(); + } else { + emit errorOccurred(errorCode); + } } else { emit errorOccurred(errorCode); } @@ -954,43 +1200,63 @@ QString ApiConfigsController::getVpnKey() return m_vpnKey; } -ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase) +ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase, + int &duplicateServerIndex) { -#ifdef Q_OS_IOS +#if defined(Q_OS_IOS) || defined(MACOS_NE) + duplicateServerIndex = -1; QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object(); - QString key = responseObject.value(QStringLiteral("key")).toString(); - if (key.isEmpty()) { + const QString rawVpnKey = responseObject.value(QStringLiteral("key")).toString(); + if (rawVpnKey.isEmpty()) { qWarning().noquote() << "[IAP] Subscription response does not contain a key field"; return ErrorCode::ApiPurchaseError; } - if (m_serversModel->hasServerWithVpnKey(key)) { + QString normalizedVpnKey = rawVpnKey; + normalizedVpnKey.replace(QStringLiteral("vpn://"), QString()); + + duplicateServerIndex = m_serversModel->indexOfServerWithVpnKey(normalizedVpnKey); + if (duplicateServerIndex >= 0) { qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists"; return ErrorCode::ApiConfigAlreadyAdded; } - QString normalizedKey = key; - normalizedKey.replace(QStringLiteral("vpn://"), QString()); - - QByteArray configString = QByteArray::fromBase64(normalizedKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); - QByteArray configUncompressed = qUncompress(configString); - if (!configUncompressed.isEmpty()) { - configString = configUncompressed; + QByteArray configPayload = + QByteArray::fromBase64(normalizedVpnKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); + QByteArray configUncompressed = qUncompress(configPayload); + const bool payloadWasCompressed = !configUncompressed.isEmpty(); + if (payloadWasCompressed) { + configPayload = configUncompressed; } - if (configString.isEmpty()) { + if (configPayload.isEmpty()) { qWarning().noquote() << "[IAP] Subscription response config payload is empty"; return ErrorCode::ApiPurchaseError; } - QJsonObject configObject = QJsonDocument::fromJson(configString).object(); + QJsonObject configObject = QJsonDocument::fromJson(configPayload).object(); + + auto apiConfig = configObject.value(apiDefs::key::apiConfig).toObject(); + apiConfig.insert(apiDefs::key::isTestPurchase, isTestPurchase); + apiConfig.insert(apiDefs::key::isInAppPurchase, true); + configObject.insert(apiDefs::key::apiConfig, apiConfig); + + configPayload = QJsonDocument(configObject).toJson(); + if (payloadWasCompressed) { + configPayload = qCompress(configPayload, 8); + } + normalizedVpnKey = QString(configPayload.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); + + duplicateServerIndex = m_serversModel->indexOfServerWithVpnKey(normalizedVpnKey); + if (duplicateServerIndex >= 0) { + qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists"; + return ErrorCode::ApiConfigAlreadyAdded; + } + + apiConfig.insert(apiDefs::key::vpnKey, normalizedVpnKey); + configObject.insert(apiDefs::key::apiConfig, apiConfig); quint16 crc = qChecksum(QJsonDocument(configObject).toJson()); - auto apiConfig = configObject.value(apiDefs::key::apiConfig).toObject(); - apiConfig[apiDefs::key::vpnKey] = normalizedKey; - apiConfig[apiDefs::key::isTestPurchase] = isTestPurchase; - - configObject.insert(apiDefs::key::apiConfig, apiConfig); configObject.insert(config_key::crc, crc); m_serversModel->addServer(configObject); @@ -998,6 +1264,7 @@ ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &respo #else Q_UNUSED(responseBody) Q_UNUSED(isTestPurchase) + duplicateServerIndex = -1; return ErrorCode::NoError; #endif } diff --git a/client/ui/controllers/api/apiConfigsController.h b/client/ui/controllers/api/apiConfigsController.h index ca5598e1..68f6565d 100644 --- a/client/ui/controllers/api/apiConfigsController.h +++ b/client/ui/controllers/api/apiConfigsController.h @@ -1,10 +1,13 @@ #ifndef APICONFIGSCONTROLLER_H #define APICONFIGSCONTROLLER_H +#include #include #include "configurators/openvpn_configurator.h" +#include "ui/models/api/apiBenefitsModel.h" #include "ui/models/api/apiServicesModel.h" +#include "ui/models/api/apiSubscriptionPlansModel.h" #include "ui/models/servers_model.h" class ApiConfigsController : public QObject @@ -12,7 +15,9 @@ class ApiConfigsController : public QObject Q_OBJECT public: ApiConfigsController(const QSharedPointer &serversModel, const QSharedPointer &apiServicesModel, - const std::shared_ptr &settings, QObject *parent = nullptr); + const QSharedPointer &subscriptionPlansModel, + const QSharedPointer &benefitsModel, const std::shared_ptr &settings, + QObject *parent = nullptr); Q_PROPERTY(QList qrCodes READ getQrCodes NOTIFY vpnKeyExportReady) Q_PROPERTY(int qrCodesCount READ getQrCodesCount NOTIFY vpnKeyExportReady) @@ -27,9 +32,10 @@ public slots: bool fillAvailableServices(); bool importService(); - bool importSerivceFromAppStore(); - bool restoreSerivceFromAppStore(); - bool importServiceFromGateway(); + bool importPremiumFromAppStore(const QString &storeProductId); + bool restoreServiceFromAppStore(); + bool importFreeFromGateway(); + bool importTrialFromGateway(const QString &email); bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName, bool reloadServiceConfig = false); bool updateServiceFromTelegram(const int serverIndex); @@ -43,10 +49,11 @@ public slots: signals: void errorOccurred(ErrorCode errorCode); + void trialEmailError(const QString &message); void subscriptionExpiredOnServer(); void subscriptionRefreshNeeded(); - void installServerFromApiFinished(const QString &message); + void installServerFromApiFinished(const QString &message, int preferredDefaultServerIndex = -1); void changeApiCountryFinished(const QString &message); void reloadServerFromApiFinished(const QString &message); void updateServerFromApiFinished(); @@ -59,7 +66,7 @@ private: QString getVpnKey(); ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase = false); - ErrorCode importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase); + ErrorCode importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase, int &duplicateServerIndex); QList m_qrCodes; QString m_vpnKey; @@ -67,6 +74,9 @@ private: QSharedPointer m_serversModel; QSharedPointer m_apiServicesModel; std::shared_ptr m_settings; + + QSharedPointer m_subscriptionPlansModel; + QSharedPointer m_benefitsModel; }; -#endif // APICONFIGSCONTROLLER_H +#endif diff --git a/client/ui/controllers/api/apiSettingsController.cpp b/client/ui/controllers/api/apiSettingsController.cpp index 78ac5c24..d9643766 100644 --- a/client/ui/controllers/api/apiSettingsController.cpp +++ b/client/ui/controllers/api/apiSettingsController.cpp @@ -78,13 +78,6 @@ bool ApiSettingsController::getAccountInfo(bool reload) QJsonObject accountInfo = QJsonDocument::fromJson(responseBody).object(); m_apiAccountInfoModel->updateModel(accountInfo, serverConfig); - QString subscriptionEndDate = accountInfo.value(apiDefs::key::subscriptionEndDate).toString(); - if (!subscriptionEndDate.isEmpty()) { - apiConfig.insert(apiDefs::key::subscriptionEndDate, subscriptionEndDate); - serverConfig.insert(configKey::apiConfig, apiConfig); - m_serversModel->editServer(serverConfig, processedIndex); - } - if (reload) { updateApiCountryModel(); updateApiDevicesModel(); diff --git a/client/ui/controllers/connectionController.cpp b/client/ui/controllers/connectionController.cpp index 23f43bc1..d84b9c89 100644 --- a/client/ui/controllers/connectionController.cpp +++ b/client/ui/controllers/connectionController.cpp @@ -6,6 +6,7 @@ #include #endif +#include "amnezia_application.h" #include "utilities.h" #include "core/controllers/vpnConfigurationController.h" #include "version.h" @@ -81,6 +82,8 @@ void ConnectionController::onConnectionStateChanged(Vpn::ConnectionState state) m_connectionStateText = tr("Connecting..."); switch (state) { case Vpn::ConnectionState::Connected: { + amnApp->networkManager()->clearConnectionCache(); + m_isConnectionInProgress = false; m_isConnected = true; m_connectionStateText = tr("Connected"); diff --git a/client/ui/controllers/pageController.h b/client/ui/controllers/pageController.h index 52910634..001d86d4 100644 --- a/client/ui/controllers/pageController.h +++ b/client/ui/controllers/pageController.h @@ -59,7 +59,7 @@ namespace PageLoader PageSetupWizardViewConfig, PageSetupWizardQrReader, PageSetupWizardApiServicesList, - PageSetupWizardApiServiceInfo, + PageSetupWizardApiFreeInfo, PageProtocolOpenVpnSettings, PageProtocolShadowSocksSettings, @@ -76,6 +76,9 @@ namespace PageLoader PageShareFullAccess, PageShareConnection, + PageSetupWizardApiPremiumInfo, + PageSetupWizardApiTrialEmail, + PageDevMenu }; Q_ENUM_NS(PageEnum) diff --git a/client/ui/models/api/apiAccountInfoModel.cpp b/client/ui/models/api/apiAccountInfoModel.cpp index b5fd6f55..4e37a98c 100644 --- a/client/ui/models/api/apiAccountInfoModel.cpp +++ b/client/ui/models/api/apiAccountInfoModel.cpp @@ -57,6 +57,11 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const || m_accountInfoData.configType == apiDefs::ConfigType::ExternalPremium || m_accountInfoData.configType == apiDefs::ConfigType::ExternalTrial; } + case IsSubscriptionRenewalAvailableRole: { + return m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2 + || m_accountInfoData.configType == apiDefs::ConfigType::AmneziaTrialV2 + || m_accountInfoData.configType == apiDefs::ConfigType::ExternalTrial; + } case HasExpiredWorkerRole: { for (int i = 0; i < m_issuedConfigsInfo.size(); i++) { QJsonObject issuedConfigObject = m_issuedConfigsInfo.at(i).toObject(); @@ -77,16 +82,31 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const return false; } case IsSubscriptionExpiredRole: { - if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) return false; - if (m_accountInfoData.subscriptionEndDate.isEmpty()) return false; + if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) { + return false; + } + if (m_accountInfoData.isInAppPurchase) { + return false; + } + 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(10); + if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) { + return false; + } + if (m_accountInfoData.isInAppPurchase) { + return false; + } + if (m_accountInfoData.subscriptionEndDate.isEmpty()) { + return false; + } + return apiUtils::isSubscriptionExpiringSoon(m_accountInfoData.subscriptionEndDate); + } + case IsInAppPurchaseRole: { + return m_accountInfoData.isInAppPurchase; } } @@ -108,6 +128,9 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons accountInfoData.configType = apiUtils::getConfigType(serverConfig); + const QJsonObject apiConfig = serverConfig.value(apiDefs::key::apiConfig).toObject(); + accountInfoData.isInAppPurchase = apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false); + accountInfoData.subscriptionDescription = accountInfoObject.value(apiDefs::key::subscriptionDescription).toString(); for (const auto &protocol : accountInfoObject.value(apiDefs::key::supportedProtocols).toArray()) { @@ -177,10 +200,12 @@ QHash ApiAccountInfoModel::roleNames() const roles[ConnectedDevicesRole] = "connectedDevices"; roles[ServiceDescriptionRole] = "serviceDescription"; roles[IsComponentVisibleRole] = "isComponentVisible"; + roles[IsSubscriptionRenewalAvailableRole] = "isSubscriptionRenewalAvailable"; roles[HasExpiredWorkerRole] = "hasExpiredWorker"; roles[IsProtocolSelectionSupportedRole] = "isProtocolSelectionSupported"; roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired"; roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon"; + roles[IsInAppPurchaseRole] = "isInAppPurchase"; return roles; } diff --git a/client/ui/models/api/apiAccountInfoModel.h b/client/ui/models/api/apiAccountInfoModel.h index 882a9c72..fec24cb2 100644 --- a/client/ui/models/api/apiAccountInfoModel.h +++ b/client/ui/models/api/apiAccountInfoModel.h @@ -18,10 +18,12 @@ public: ServiceDescriptionRole, EndDateRole, IsComponentVisibleRole, + IsSubscriptionRenewalAvailableRole, HasExpiredWorkerRole, IsProtocolSelectionSupportedRole, IsSubscriptionExpiredRole, - IsSubscriptionExpiringSoonRole + IsSubscriptionExpiringSoonRole, + IsInAppPurchaseRole }; explicit ApiAccountInfoModel(QObject *parent = nullptr); @@ -57,6 +59,8 @@ private: QStringList supportedProtocols; QString subscriptionDescription; + + bool isInAppPurchase = false; }; AccountInfoData m_accountInfoData; diff --git a/client/ui/models/api/apiBenefitsModel.cpp b/client/ui/models/api/apiBenefitsModel.cpp new file mode 100644 index 00000000..42b79c9b --- /dev/null +++ b/client/ui/models/api/apiBenefitsModel.cpp @@ -0,0 +1,112 @@ +#include "apiBenefitsModel.h" + +#include +#include +#include +#include + +namespace +{ +namespace configKey +{ + constexpr char title[] = "title"; + constexpr char body[] = "body"; + constexpr char icon[] = "icon"; + constexpr char accent[] = "accent"; +} + +QString gatewayIconKeyToUrl(const QString &iconKey) +{ + if (iconKey.startsWith(QLatin1String("qrc:"))) { + return iconKey; + } + static const QHash map = { + { QStringLiteral("globe-2"), QStringLiteral("qrc:/images/controls/globe-2.svg") }, + { QStringLiteral("smartphone"), QStringLiteral("qrc:/images/controls/smartphone.svg") }, + { QStringLiteral("gauge"), QStringLiteral("qrc:/images/controls/gauge.svg") }, + { QStringLiteral("infinity"), QStringLiteral("qrc:/images/controls/infinity.svg") }, + { QStringLiteral("tag"), QStringLiteral("qrc:/images/controls/tag.svg") }, + { QStringLiteral("history"), QStringLiteral("qrc:/images/controls/history.svg") }, + { QStringLiteral("info"), QStringLiteral("qrc:/images/controls/info.svg") }, + { QStringLiteral("app"), QStringLiteral("qrc:/images/controls/app.svg") }, + { QStringLiteral("download"), QStringLiteral("qrc:/images/controls/download.svg") }, + { QStringLiteral("help-circle"), QStringLiteral("qrc:/images/controls/help-circle.svg") }, + }; + return map.value(iconKey, QStringLiteral("qrc:/images/controls/info.svg")); +} +} + +ApiBenefitsModel::ApiBenefitsModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int ApiBenefitsModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_serviceBenefits.size(); +} + +QVariant ApiBenefitsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_serviceBenefits.size()) { + return {}; + } + const ServiceBenefitItem &item = m_serviceBenefits.at(index.row()); + switch (role) { + case IconRole: + return item.icon; + case TitleRole: + return item.title; + case BodyRole: + return item.body; + case AccentRole: + return item.accent; + default: + return {}; + } +} + +QHash ApiBenefitsModel::roleNames() const +{ + return { + { IconRole, "icon" }, + { TitleRole, "title" }, + { BodyRole, "body" }, + { AccentRole, "accent" }, + }; +} + +void ApiBenefitsModel::updateModel(const QJsonArray &benefits) +{ + beginResetModel(); + m_serviceBenefits.clear(); + for (const QJsonValue &benefitValue : benefits) { + if (!benefitValue.isObject()) { + continue; + } + const QJsonObject benefitObject = benefitValue.toObject(); + QString title = benefitObject.value(configKey::title).toString(); + QString body = benefitObject.value(configKey::body).toString(); + const QString iconKey = benefitObject.value(configKey::icon).toString(); + if (title.isEmpty() && body.isEmpty()) { + continue; + } + ServiceBenefitItem item; + item.icon = gatewayIconKeyToUrl(iconKey); + item.title = std::move(title); + item.body = std::move(body); + item.accent = benefitObject.value(configKey::accent).toBool(); + m_serviceBenefits.append(std::move(item)); + } + endResetModel(); +} + +void ApiBenefitsModel::clear() +{ + beginResetModel(); + m_serviceBenefits.clear(); + endResetModel(); +} diff --git a/client/ui/models/api/apiBenefitsModel.h b/client/ui/models/api/apiBenefitsModel.h new file mode 100644 index 00000000..c6b8465f --- /dev/null +++ b/client/ui/models/api/apiBenefitsModel.h @@ -0,0 +1,43 @@ +#ifndef APIBENEFITSMODEL_H +#define APIBENEFITSMODEL_H + +#include +#include +#include +#include + +class ApiBenefitsModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles { + IconRole = Qt::UserRole + 1, + TitleRole, + BodyRole, + AccentRole + }; + Q_ENUM(Roles) + + explicit ApiBenefitsModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void updateModel(const QJsonArray &benefits); + void clear(); + +private: + struct ServiceBenefitItem + { + QString icon; + QString title; + QString body; + bool accent = false; + }; + + QVector m_serviceBenefits; +}; + +#endif diff --git a/client/ui/models/api/apiServicesModel.cpp b/client/ui/models/api/apiServicesModel.cpp index 7d831f48..d309afd8 100644 --- a/client/ui/models/api/apiServicesModel.cpp +++ b/client/ui/models/api/apiServicesModel.cpp @@ -1,7 +1,11 @@ #include "apiServicesModel.h" +#include +#include +#include #include +#include "core/api/apiDefs.h" #include "logger.h" namespace @@ -17,15 +21,9 @@ namespace constexpr char serviceProtocol[] = "service_protocol"; constexpr char serviceDescription[] = "service_description"; - constexpr char name[] = "name"; - constexpr char price[] = "price"; - constexpr char speed[] = "speed"; - constexpr char timelimit[] = "timelimit"; - constexpr char region[] = "region"; - constexpr char description[] = "description"; constexpr char cardDescription[] = "card_description"; - constexpr char features[] = "features"; + constexpr char serviceName[] = "service_name"; constexpr char availableCountries[] = "available_countries"; @@ -33,19 +31,21 @@ namespace constexpr char isAvailable[] = "is_available"; - constexpr char subscription[] = "subscription"; - constexpr char endDate[] = "end_date"; + constexpr char subscriptionPlans[] = "subscription_plans"; + constexpr char minPriceLabel[] = "min_price_label"; + constexpr char benefits[] = "benefits"; } namespace serviceType { constexpr char amneziaFree[] = "amnezia-free"; constexpr char amneziaPremium[] = "amnezia-premium"; - constexpr char amneziaTrial[] = "amnezia-trial"; } } -ApiServicesModel::ApiServicesModel(QObject *parent) : QAbstractListModel(parent) +ApiServicesModel::ApiServicesModel(QObject *parent) + : QAbstractListModel(parent) + , m_selectedServiceIndex(0) { } @@ -69,9 +69,8 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const return apiServiceData.serviceInfo.name; } case CardDescriptionRole: { - auto speed = apiServiceData.serviceInfo.speed; - if (serviceType == serviceType::amneziaPremium || serviceType == serviceType::amneziaTrial) { - return apiServiceData.serviceInfo.cardDescription.arg(speed); + if (serviceType == serviceType::amneziaPremium) { + return apiServiceData.serviceInfo.cardDescription; } else if (serviceType == serviceType::amneziaFree) { QString description = apiServiceData.serviceInfo.cardDescription; if (!isServiceAvailable) { @@ -92,44 +91,29 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const } return true; } - case SpeedRole: { - return tr("%1 MBit/s").arg(apiServiceData.serviceInfo.speed); - } - case TimeLimitRole: { - auto timeLimit = apiServiceData.serviceInfo.timeLimit; - if (timeLimit == "0") { - return ""; - } - return tr("%1 days").arg(timeLimit); - } - case RegionRole: { - return apiServiceData.serviceInfo.region; - } - case FeaturesRole: { - return apiServiceData.serviceInfo.features; - } case PriceRole: { - auto price = apiServiceData.serviceInfo.price; - if (price == "free") { - return tr("Free"); - } -#if defined(Q_OS_IOS) || defined(MACOS_NE) - return tr("%1 $").arg(price); -#else - return tr("%1 $/month").arg(price); -#endif + return apiServiceData.minPriceLabel; } case EndDateRole: { return QDateTime::fromString(apiServiceData.subscription.endDate, Qt::ISODate).toLocalTime().toString("d MMM yyyy"); } + case TermsOfUseUrlRole: { + return apiServiceData.serviceInfo.termsOfUseUrl; + } + case PrivacyPolicyUrlRole: { + return apiServiceData.serviceInfo.privacyPolicyUrl; + } + case ShowRecommendedRole: { + return serviceType == serviceType::amneziaPremium; + } case OrderRole: { if (serviceType == serviceType::amneziaPremium) { return 0; - } else if (serviceType == serviceType::amneziaTrial) { - return 1; - } else if (serviceType == serviceType::amneziaFree) { - return 2; } + if (serviceType == serviceType::amneziaFree) { + return 1; + } + return QVariant(); } } @@ -155,12 +139,27 @@ void ApiServicesModel::updateModel(const QJsonObject &data) } } + if (!m_services.isEmpty() && m_selectedServiceIndex >= m_services.size()) { + m_selectedServiceIndex = 0; + } + endResetModel(); + + emit serviceSelectionChanged(); } void ApiServicesModel::setServiceIndex(const int index) { m_selectedServiceIndex = index; + emit serviceSelectionChanged(); +} + +ApiServicesModel::ApiServicesData ApiServicesModel::selectedServiceData() const +{ + if (m_services.isEmpty() || m_selectedServiceIndex < 0 || m_selectedServiceIndex >= m_services.size()) { + return {}; + } + return m_services.at(m_selectedServiceIndex); } QJsonObject ApiServicesModel::getSelectedServiceInfo() @@ -217,6 +216,16 @@ QVariant ApiServicesModel::getSelectedServiceData(const QString roleString) return {}; } +int ApiServicesModel::serviceIndexForType(const QString &type) const +{ + for (int serviceIndex = 0; serviceIndex < m_services.size(); ++serviceIndex) { + if (m_services.at(serviceIndex).type == type) { + return serviceIndex; + } + } + return -1; +} + QHash ApiServicesModel::roleNames() const { QHash roles; @@ -224,12 +233,11 @@ QHash ApiServicesModel::roleNames() const roles[CardDescriptionRole] = "cardDescription"; roles[ServiceDescriptionRole] = "serviceDescription"; roles[IsServiceAvailableRole] = "isServiceAvailable"; - roles[SpeedRole] = "speed"; - roles[TimeLimitRole] = "timeLimit"; - roles[RegionRole] = "region"; - roles[FeaturesRole] = "features"; roles[PriceRole] = "price"; roles[EndDateRole] = "endDate"; + roles[TermsOfUseUrlRole] = "termsOfUseUrl"; + roles[PrivacyPolicyUrlRole] = "privacyPolicyUrl"; + roles[ShowRecommendedRole] = "showRecommended"; roles[OrderRole] = "order"; return roles; @@ -243,18 +251,22 @@ ApiServicesModel::ApiServicesData ApiServicesModel::getApiServicesData(const QJs auto availableCountries = data.value(configKey::availableCountries).toArray(); auto serviceDescription = data.value(configKey::serviceDescription).toObject(); - auto subscriptionObject = data.value(configKey::subscription).toObject(); + auto subscriptionObject = data.value(apiDefs::key::subscription).toObject(); ApiServicesData serviceData; - serviceData.serviceInfo.name = serviceInfo.value(configKey::name).toString(); - serviceData.serviceInfo.price = serviceInfo.value(configKey::price).toString(); - serviceData.serviceInfo.region = serviceInfo.value(configKey::region).toString(); - serviceData.serviceInfo.speed = serviceInfo.value(configKey::speed).toString(); - serviceData.serviceInfo.timeLimit = serviceInfo.value(configKey::timelimit).toString(); + serviceData.serviceInfo.name = serviceDescription.value(configKey::serviceName).toString(); serviceData.serviceInfo.cardDescription = serviceDescription.value(configKey::cardDescription).toString(); serviceData.serviceInfo.description = serviceDescription.value(configKey::description).toString(); - serviceData.serviceInfo.features = serviceDescription.value(configKey::features).toString(); + serviceData.serviceInfo.termsOfUseUrl = serviceDescription.value(apiDefs::key::termsOfUseUrl).toString(); + serviceData.serviceInfo.privacyPolicyUrl = serviceDescription.value(apiDefs::key::privacyPolicyUrl).toString(); + + serviceData.subscriptionPlansJson = serviceDescription.value(configKey::subscriptionPlans).toArray(); + serviceData.benefits = serviceDescription.value(configKey::benefits).toArray(); + + serviceData.minPriceLabel = serviceDescription.value(configKey::minPriceLabel).toString().trimmed(); + + serviceData.supportInfo = data.value(apiDefs::key::supportInfo).toObject(); serviceData.type = serviceType; serviceData.protocol = serviceProtocol; @@ -270,7 +282,7 @@ ApiServicesModel::ApiServicesData ApiServicesModel::getApiServicesData(const QJs serviceData.serviceInfo.object = serviceInfo; serviceData.availableCountries = availableCountries; - serviceData.subscription.endDate = subscriptionObject.value(configKey::endDate).toString(); + serviceData.subscription.endDate = subscriptionObject.value(apiDefs::key::endDate).toString(); return serviceData; } diff --git a/client/ui/models/api/apiServicesModel.h b/client/ui/models/api/apiServicesModel.h index cee405b3..7bd5492d 100644 --- a/client/ui/models/api/apiServicesModel.h +++ b/client/ui/models/api/apiServicesModel.h @@ -4,65 +4,23 @@ #include #include #include +#include class ApiServicesModel : public QAbstractListModel { Q_OBJECT public: - enum Roles { - NameRole = Qt::UserRole + 1, - CardDescriptionRole, - ServiceDescriptionRole, - IsServiceAvailableRole, - SpeedRole, - TimeLimitRole, - RegionRole, - FeaturesRole, - PriceRole, - EndDateRole, - OrderRole - }; - - explicit ApiServicesModel(QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - -public slots: - void updateModel(const QJsonObject &data); - - void setServiceIndex(const int index); - - QJsonObject getSelectedServiceInfo(); - QString getSelectedServiceType(); - QString getSelectedServiceProtocol(); - QString getSelectedServiceName(); - QJsonArray getSelectedServiceCountries(); - - QString getCountryCode(); - - QString getStoreEndpoint(); - - QVariant getSelectedServiceData(const QString roleString); - -protected: - QHash roleNames() const override; - -private: struct ServiceInfo { QString name; - QString speed; - QString timeLimit; - QString region; - QString price; QString description; - QString features; QString cardDescription; + QString termsOfUseUrl; + QString privacyPolicyUrl; + QJsonObject object; }; @@ -80,11 +38,64 @@ private: QString storeEndpoint; ServiceInfo serviceInfo; + QJsonObject supportInfo; Subscription subscription; QJsonArray availableCountries; + + QJsonArray subscriptionPlansJson; + QJsonArray benefits; + + QString minPriceLabel; }; + enum Roles { + NameRole = Qt::UserRole + 1, + CardDescriptionRole, + ServiceDescriptionRole, + IsServiceAvailableRole, + PriceRole, + EndDateRole, + TermsOfUseUrlRole, + PrivacyPolicyUrlRole, + ShowRecommendedRole, + OrderRole + }; + + explicit ApiServicesModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + ApiServicesData selectedServiceData() const; + +public slots: + void updateModel(const QJsonObject &data); + + void setServiceIndex(const int index); + + QJsonObject getSelectedServiceInfo(); + QString getSelectedServiceType(); + QString getSelectedServiceProtocol(); + QString getSelectedServiceName(); + QJsonArray getSelectedServiceCountries(); + + QString getCountryCode(); + + QString getStoreEndpoint(); + + QVariant getSelectedServiceData(const QString roleString); + + Q_INVOKABLE int serviceIndexForType(const QString &type) const; + +signals: + void serviceSelectionChanged(); + +protected: + QHash roleNames() const override; + +private: ApiServicesData getApiServicesData(const QJsonObject &data); QString m_countryCode; @@ -93,4 +104,4 @@ private: int m_selectedServiceIndex; }; -#endif // APISERVICESMODEL_H +#endif diff --git a/client/ui/models/api/apiSubscriptionPlansModel.cpp b/client/ui/models/api/apiSubscriptionPlansModel.cpp new file mode 100644 index 00000000..6972f8e0 --- /dev/null +++ b/client/ui/models/api/apiSubscriptionPlansModel.cpp @@ -0,0 +1,131 @@ +#include "apiSubscriptionPlansModel.h" + +#include +#include +#include +#include + +namespace +{ +namespace configKey +{ + constexpr char billingPeriod[] = "billing_period"; + constexpr char priceLabel[] = "price_label"; + constexpr char subtitle[] = "subtitle"; + constexpr char recommended[] = "recommended"; + constexpr char checkoutUrl[] = "checkout_url"; + constexpr char isTrial[] = "is_trial"; + constexpr char serviceProtocol[] = "service_protocol"; + constexpr char storeProductId[] = "store_product_id"; +} +} + +ApiSubscriptionPlansModel::ApiSubscriptionPlansModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int ApiSubscriptionPlansModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_subscriptionPlans.size(); +} + +QVariant ApiSubscriptionPlansModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_subscriptionPlans.size()) { + return {}; + } + const SubscriptionPlanItem &plan = m_subscriptionPlans.at(index.row()); + switch (role) { + case BillingPeriodRole: + return plan.billingPeriod; + case PriceLabelRole: + return plan.priceLabel; + case SubtitleRole: + return plan.subtitle; + case RecommendedRole: + return plan.recommended; + case CheckoutUrlRole: + return plan.checkoutUrl; + case IsTrialRole: + return plan.isTrial; + case ServiceProtocolRole: + return plan.serviceProtocol; + case StoreProductIdRole: + return plan.storeProductId; + default: + return {}; + } +} + +QHash ApiSubscriptionPlansModel::roleNames() const +{ + return { + { BillingPeriodRole, "billingPeriod" }, + { PriceLabelRole, "priceLabel" }, + { SubtitleRole, "subtitle" }, + { RecommendedRole, "recommended" }, + { CheckoutUrlRole, "checkoutUrl" }, + { IsTrialRole, "isTrial" }, + { ServiceProtocolRole, "serviceProtocol" }, + { StoreProductIdRole, "storeProductId" }, + }; +} + +void ApiSubscriptionPlansModel::updateModel(const QJsonArray &arr) +{ + beginResetModel(); + m_subscriptionPlans.clear(); + m_subscriptionPlans.reserve(arr.size()); + for (const QJsonValue &planValue : arr) { + if (!planValue.isObject()) { + continue; + } + const QJsonObject planObject = planValue.toObject(); + SubscriptionPlanItem subscriptionPlan; + subscriptionPlan.billingPeriod = planObject.value(configKey::billingPeriod).toString(); + subscriptionPlan.priceLabel = planObject.value(configKey::priceLabel).toString(); + subscriptionPlan.subtitle = planObject.value(configKey::subtitle).toString(); + subscriptionPlan.recommended = planObject.value(configKey::recommended).toBool(); + subscriptionPlan.checkoutUrl = planObject.value(configKey::checkoutUrl).toString(); + subscriptionPlan.isTrial = planObject.value(configKey::isTrial).toBool(); + subscriptionPlan.serviceProtocol = planObject.value(configKey::serviceProtocol).toString(); + subscriptionPlan.storeProductId = planObject.value(configKey::storeProductId).toString(); + m_subscriptionPlans.append(std::move(subscriptionPlan)); + } + endResetModel(); +} + +void ApiSubscriptionPlansModel::clear() +{ + beginResetModel(); + m_subscriptionPlans.clear(); + endResetModel(); +} + +QVariantMap ApiSubscriptionPlansModel::planAt(int row) const +{ + if (row < 0 || row >= m_subscriptionPlans.size()) { + return {}; + } + const QModelIndex modelIndex = index(row, 0); + QVariantMap planMap; + const QHash roles = roleNames(); + for (auto roleIt = roles.cbegin(); roleIt != roles.cend(); ++roleIt) { + planMap.insert(QString::fromUtf8(roleIt.value()), data(modelIndex, roleIt.key())); + } + return planMap; +} + +int ApiSubscriptionPlansModel::recommendedRowIndex() const +{ + for (int planIndex = 0; planIndex < m_subscriptionPlans.size(); ++planIndex) { + if (m_subscriptionPlans.at(planIndex).recommended) { + return planIndex; + } + } + return 0; +} diff --git a/client/ui/models/api/apiSubscriptionPlansModel.h b/client/ui/models/api/apiSubscriptionPlansModel.h new file mode 100644 index 00000000..3a26f764 --- /dev/null +++ b/client/ui/models/api/apiSubscriptionPlansModel.h @@ -0,0 +1,53 @@ +#ifndef APISUBSCRIPTIONPLANSMODEL_H +#define APISUBSCRIPTIONPLANSMODEL_H + +#include +#include +#include + +class ApiSubscriptionPlansModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles { + BillingPeriodRole = Qt::UserRole + 1, + PriceLabelRole, + SubtitleRole, + RecommendedRole, + CheckoutUrlRole, + IsTrialRole, + ServiceProtocolRole, + StoreProductIdRole + }; + Q_ENUM(Roles) + + explicit ApiSubscriptionPlansModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void updateModel(const QJsonArray &arr); + void clear(); + + Q_INVOKABLE QVariantMap planAt(int row) const; + Q_INVOKABLE int recommendedRowIndex() const; + +private: + struct SubscriptionPlanItem + { + QString billingPeriod; + QString priceLabel; + QString subtitle; + bool recommended = false; + QString checkoutUrl; + bool isTrial = false; + QString serviceProtocol; + QString storeProductId; + }; + + QVector m_subscriptionPlans; +}; + +#endif diff --git a/client/ui/models/servers_model.cpp b/client/ui/models/servers_model.cpp index 70d5541c..eadc12e3 100644 --- a/client/ui/models/servers_model.cpp +++ b/client/ui/models/servers_model.cpp @@ -180,18 +180,35 @@ QVariant ServersModel::data(const QModelIndex &index, int role) const return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::adEndpoint).toString(); } case IsSubscriptionExpiredRole: { - if (configVersion != apiDefs::ConfigSource::AmneziaGateway) return false; - QString endDate = apiConfig.value(apiDefs::key::subscriptionEndDate).toString(); - if (endDate.isEmpty()) return false; + if (configVersion != apiDefs::ConfigSource::AmneziaGateway) { + return false; + } + if (apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false)) { + return false; + } + if (apiConfig.value(apiDefs::key::subscriptionExpiredByServer).toBool(false)) { + return true; + } + const QString endDate = + apiConfig.value(apiDefs::key::subscription).toObject().value(apiDefs::key::endDate).toString(); + if (endDate.isEmpty()) { + return false; + } return apiUtils::isSubscriptionExpired(endDate); } case IsSubscriptionExpiringSoonRole: { - if (configVersion != apiDefs::ConfigSource::AmneziaGateway) return false; - QString endDate = apiConfig.value(apiDefs::key::subscriptionEndDate).toString(); - if (endDate.isEmpty()) return false; - if (apiUtils::isSubscriptionExpired(endDate)) return false; - QDateTime endDateTime = QDateTime::fromString(endDate, Qt::ISODateWithMs); - return endDateTime <= QDateTime::currentDateTimeUtc().addDays(10); + if (configVersion != apiDefs::ConfigSource::AmneziaGateway) { + return false; + } + if (apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false)) { + return false; + } + const QString endDate = + apiConfig.value(apiDefs::key::subscription).toObject().value(apiDefs::key::endDate).toString(); + if (endDate.isEmpty()) { + return false; + } + return apiUtils::isSubscriptionExpiringSoon(endDate); } } @@ -744,21 +761,21 @@ bool ServersModel::isServerFromApiAlreadyExists(const QString &userCountryCode, return false; } -bool ServersModel::hasServerWithVpnKey(const QString &vpnKey) const +int ServersModel::indexOfServerWithVpnKey(const QString &vpnKey) const { const QString normalizedInput = normalizeVpnKey(vpnKey); if (normalizedInput.isEmpty()) { - return false; + return -1; } - for (const auto &server : std::as_const(m_servers)) { - const auto apiConfig = server.toObject().value(configKey::apiConfig).toObject(); + for (int i = 0; i < m_servers.size(); ++i) { + const auto apiConfig = m_servers.at(i).toObject().value(configKey::apiConfig).toObject(); const QString existingKey = normalizeVpnKey(apiConfig.value(apiDefs::key::vpnKey).toString()); if (!existingKey.isEmpty() && existingKey == normalizedInput) { - return true; + return i; } } - return false; + return -1; } bool ServersModel::serverHasInstalledContainers(const int serverIndex) const diff --git a/client/ui/models/servers_model.h b/client/ui/models/servers_model.h index 6aba7d37..5264b35b 100644 --- a/client/ui/models/servers_model.h +++ b/client/ui/models/servers_model.h @@ -143,7 +143,7 @@ public slots: bool isServerFromApiAlreadyExists(const quint16 crc); bool isServerFromApiAlreadyExists(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol); - bool hasServerWithVpnKey(const QString &vpnKey) const; + int indexOfServerWithVpnKey(const QString &vpnKey) const; QVariant getDefaultServerData(const QString roleString); diff --git a/client/ui/qml/Components/BenefitRow.qml b/client/ui/qml/Components/BenefitRow.qml new file mode 100644 index 00000000..07b547f1 --- /dev/null +++ b/client/ui/qml/Components/BenefitRow.qml @@ -0,0 +1,65 @@ +import QtQuick +import QtQuick.Layouts + +import Style 1.0 + +import "../Controls2/TextTypes" + +RowLayout { + id: root + + property string iconSource: "" + property string titleText: "" + property string bodyText: "" + property bool accent: false + + spacing: 12 + + Image { + Layout.alignment: Qt.AlignTop + Layout.preferredWidth: 22 + Layout.preferredHeight: 22 + source: root.iconSource + fillMode: Image.PreserveAspectFit + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + LabelTextType { + Layout.fillWidth: true + text: root.titleText + color: AmneziaStyle.color.paleGray + font.pixelSize: 16 + font.weight: Font.DemiBold + wrapMode: Text.Wrap + } + + Item { + Layout.fillWidth: true + implicitHeight: bodyLabel.implicitHeight + + LabelTextType { + id: bodyLabel + width: parent.width + text: root.bodyText + color: root.accent ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.mutedGray + font.pixelSize: 14 + wrapMode: Text.Wrap + } + + MouseArea { + anchors.fill: bodyLabel + visible: root.accent && root.bodyText.length > 0 + cursorShape: Qt.PointingHandCursor + onClicked: { + var t = root.bodyText.trim() + if (t.startsWith("@")) { + Qt.openUrlExternally("https://t.me/" + t.substring(1)) + } + } + } + } + } +} diff --git a/client/ui/qml/Components/BenefitsPanel.qml b/client/ui/qml/Components/BenefitsPanel.qml new file mode 100644 index 00000000..bb1d3a23 --- /dev/null +++ b/client/ui/qml/Components/BenefitsPanel.qml @@ -0,0 +1,40 @@ +import QtQuick +import QtQuick.Layouts + +import "." + +import Style 1.0 + +Rectangle { + id: root + + property var benefitsModel: null + + visible: benefitsModel && benefitsModel.rowCount() > 0 + + radius: 16 + color: AmneziaStyle.color.benefitsPanelBackground + implicitHeight: inner.implicitHeight + 24 + + ColumnLayout { + id: inner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 20 + + Repeater { + model: benefitsModel + + delegate: BenefitRow { + Layout.fillWidth: true + iconSource: model.icon + titleText: model.title + bodyText: model.body + accent: !!model.accent + } + } + } +} diff --git a/client/ui/qml/Components/ServersListView.qml b/client/ui/qml/Components/ServersListView.qml index 69c6acad..54ef0c91 100644 --- a/client/ui/qml/Components/ServersListView.qml +++ b/client/ui/qml/Components/ServersListView.qml @@ -67,7 +67,12 @@ ListViewType { Layout.fillWidth: true text: name - descriptionText: serverDescription + descriptionText: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon) + ? (isSubscriptionExpired ? qsTr("Subscription expired. Please renew.") : qsTr("Subscription expiring soon.")) + : serverDescription + descriptionColor: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon) + ? (isSubscriptionExpired ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot) + : AmneziaStyle.color.mutedGray checked: index === root.selectedIndex checkable: !ConnectionController.isConnected @@ -126,18 +131,6 @@ ListViewType { } } - CaptionTextType { - visible: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon) - - Layout.fillWidth: true - Layout.leftMargin: 64 - Layout.bottomMargin: 8 - - text: isSubscriptionExpired ? qsTr("Subscription expired. Please renew.") : qsTr("Subscription expiring soon.") - color: isSubscriptionExpired ? 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 8be6b805..0540dbfa 100644 --- a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml +++ b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml @@ -12,6 +12,13 @@ import "../Controls2/TextTypes" DrawerType2 { id: root + property bool isRenewalActionAvailable: false + + onOpened: { + isRenewalActionAvailable = ApiAccountInfoModel.data("isSubscriptionRenewalAvailable") + && !ApiAccountInfoModel.data("isInAppPurchase") + } + expandedStateContent: ColumnLayout { id: content @@ -43,6 +50,8 @@ DrawerType2 { } ParagraphTextType { + visible: root.isRenewalActionAvailable + Layout.fillWidth: true Layout.topMargin: 8 Layout.rightMargin: 16 @@ -53,6 +62,8 @@ DrawerType2 { } BasicButtonType { + visible: root.isRenewalActionAvailable + Layout.fillWidth: true Layout.topMargin: 16 Layout.rightMargin: 16 diff --git a/client/ui/qml/Components/SubscriptionPlanCard.qml b/client/ui/qml/Components/SubscriptionPlanCard.qml new file mode 100644 index 00000000..f69ece34 --- /dev/null +++ b/client/ui/qml/Components/SubscriptionPlanCard.qml @@ -0,0 +1,94 @@ +import QtQuick +import QtQuick.Layouts + +import Style 1.0 + +import "../Controls2/TextTypes" + +Rectangle { + id: root + + property bool selected: false + property string billingPeriod: "" + property string priceLabel: "" + property string subtitle: "" + property bool showRecommendedBadge: false + property string recommendedText: "Recommended" + + signal selectRequested + + implicitHeight: cardLayout.implicitHeight + 28 + radius: 16 + color: AmneziaStyle.color.transparent + border.width: selected ? 2 : 1 + border.color: selected ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.charcoalGray + + ColumnLayout { + id: cardLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 16 + anchors.rightMargin: 16 + + spacing: 8 + + RowLayout { + Layout.fillWidth: true + + LabelTextType { + Layout.fillWidth: true + text: root.billingPeriod + color: root.selected ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.paleGray + font.pixelSize: 17 + font.weight: Font.DemiBold + wrapMode: Text.Wrap + } + + LabelTextType { + text: root.priceLabel + color: root.selected ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.paleGray + font.pixelSize: 17 + font.weight: Font.DemiBold + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.subtitle.length > 0 || root.showRecommendedBadge + + LabelTextType { + Layout.fillWidth: true + text: root.subtitle + color: AmneziaStyle.color.mutedGray + font.pixelSize: 13 + wrapMode: Text.Wrap + } + + Rectangle { + visible: root.showRecommendedBadge + Layout.alignment: Qt.AlignVCenter + radius: 10 + color: AmneziaStyle.color.softViolet + implicitHeight: recLabel.implicitHeight + 8 + implicitWidth: recLabel.implicitWidth + 16 + + LabelTextType { + id: recLabel + + anchors.centerIn: parent + text: root.recommendedText + color: AmneziaStyle.color.midnightBlack + font.pixelSize: 11 + font.weight: Font.Medium + } + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: root.selectRequested() + } +} diff --git a/client/ui/qml/Components/TermsAndPrivacyText.qml b/client/ui/qml/Components/TermsAndPrivacyText.qml new file mode 100644 index 00000000..5eb4a142 --- /dev/null +++ b/client/ui/qml/Components/TermsAndPrivacyText.qml @@ -0,0 +1,35 @@ +import QtQuick +import QtQuick.Layouts + +import Style 1.0 + +import "../Controls2/TextTypes" + +ParagraphTextType { + id: root + + property string termsUrl: "" + property string privacyUrl: "" + + Layout.fillWidth: true + + horizontalAlignment: Text.AlignHCenter + textFormat: Text.RichText + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + + text: qsTr("By continuing, you agree to the Terms of Use and Privacy Policy") + .arg(root.termsUrl) + .arg(root.privacyUrl) + .arg(AmneziaStyle.color.goldenApricotString) + + onLinkActivated: function(link) { + Qt.openUrlExternally(link) + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + } +} diff --git a/client/ui/qml/Controls2/CardWithIconsType.qml b/client/ui/qml/Controls2/CardWithIconsType.qml index 02d033d2..52cd31e0 100644 --- a/client/ui/qml/Controls2/CardWithIconsType.qml +++ b/client/ui/qml/Controls2/CardWithIconsType.qml @@ -13,6 +13,11 @@ Button { property string bodyText property string footerText + property color headerTextColor: AmneziaStyle.color.paleGray + property color bodyTextColor: AmneziaStyle.color.mutedGray + property bool showRecommendedBadge: false + property string recommendedText: "" + property string hoveredColor: AmneziaStyle.color.slateGray property string defaultColor: AmneziaStyle.color.onyxBlack @@ -28,6 +33,7 @@ Button { property alias focusItem: rightImage hoverEnabled: true + clip: false background: Rectangle { id: backgroundRect @@ -43,104 +49,151 @@ Button { } contentItem: Item { + id: contentRoot + anchors.left: parent.left anchors.right: parent.right - implicitHeight: content.implicitHeight + readonly property bool badgeVisible: root.showRecommendedBadge && root.recommendedText !== "" - RowLayout { - id: content + implicitHeight: layoutCol.implicitHeight - anchors.fill: parent + ColumnLayout { + id: layoutCol - Image { - id: leftImage - source: leftImageSource + anchors.left: parent.left + anchors.right: parent.right + spacing: 0 - visible: leftImageSource !== "" + Item { + id: badgeTopSpacer - Layout.alignment: Qt.AlignLeft | Qt.AlignTop - Layout.topMargin: 24 - Layout.bottomMargin: 24 - Layout.leftMargin: 24 - } - - ColumnLayout { - - ListItemTitleType { - text: root.headerText - visible: text !== "" - - Layout.fillWidth: true - Layout.rightMargin: 16 - Layout.leftMargin: 16 - Layout.topMargin: 16 - Layout.bottomMargin: root.bodyText !== "" ? 0 : 16 - - opacity: root.textOpacity - } - - CaptionTextType { - text: root.bodyText - visible: text !== "" - - color: AmneziaStyle.color.mutedGray - textFormat: Text.RichText - - Layout.fillWidth: true - Layout.rightMargin: 16 - Layout.leftMargin: 16 - Layout.bottomMargin: root.footerText !== "" ? 0 : 16 - - opacity: root.textOpacity - } - - ButtonTextType { - text: root.footerText - visible: text !== "" - - color: AmneziaStyle.color.mutedGray - - Layout.fillWidth: true - Layout.rightMargin: 16 - Layout.leftMargin: 16 - Layout.topMargin: 16 - Layout.bottomMargin: 16 - - opacity: root.textOpacity - } - } - - ImageButtonType { - id: rightImage - - implicitWidth: 40 - implicitHeight: 40 - - hoverEnabled: false - image: rightImageSource - imageColor: rightImageColor - visible: rightImageSource ? true : false - - Layout.alignment: Qt.AlignRight | Qt.AlignTop - Layout.topMargin: 16 - Layout.bottomMargin: 16 - Layout.rightMargin: 16 + Layout.fillWidth: true + Layout.preferredHeight: contentRoot.badgeVisible ? (recBadge.height / 2 + 8) : 0 Rectangle { - id: rightImageBackground + id: recBadge - anchors.fill: parent - radius: 12 - color: "transparent" + visible: contentRoot.badgeVisible + z: 2 - Behavior on color { - PropertyAnimation { duration: 200 } + anchors.left: parent.left + anchors.leftMargin: 20 + anchors.verticalCenter: parent.top + + radius: 10 + color: AmneziaStyle.color.softViolet + implicitHeight: recLabel.implicitHeight + 8 + implicitWidth: recLabel.implicitWidth + 16 + + width: implicitWidth + height: implicitHeight + + BadgeTextType { + id: recLabel + + anchors.centerIn: parent + text: root.recommendedText + } + } + } + + RowLayout { + id: content + + Layout.fillWidth: true + + Image { + id: leftImage + source: leftImageSource + + visible: leftImageSource !== "" + + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + Layout.topMargin: 24 + Layout.bottomMargin: 24 + Layout.leftMargin: 24 + } + + ColumnLayout { + + ListItemTitleType { + text: root.headerText + visible: text !== "" + + color: root.headerTextColor + + Layout.fillWidth: true + Layout.rightMargin: 16 + Layout.leftMargin: 16 + Layout.topMargin: contentRoot.badgeVisible ? 0 : 16 + Layout.bottomMargin: root.bodyText !== "" ? 0 : 16 + + opacity: root.textOpacity + } + + CaptionTextType { + text: root.bodyText + visible: text !== "" + + color: root.bodyTextColor + textFormat: Text.RichText + + Layout.fillWidth: true + Layout.rightMargin: 16 + Layout.leftMargin: 16 + Layout.bottomMargin: root.footerText !== "" ? 0 : 8 + + opacity: root.textOpacity + } + + ButtonTextType { + text: root.footerText + visible: text !== "" + + color: AmneziaStyle.color.mutedGray + + Layout.fillWidth: true + Layout.rightMargin: 16 + Layout.leftMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 16 + + opacity: root.textOpacity } } - onClicked: { - root.clicked() + ImageButtonType { + id: rightImage + + implicitWidth: 40 + implicitHeight: 40 + + hoverEnabled: false + image: rightImageSource + imageColor: rightImageColor + visible: rightImageSource ? true : false + + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.topMargin: 16 + Layout.bottomMargin: 16 + Layout.rightMargin: 16 + + Rectangle { + id: rightImageBackground + + anchors.fill: parent + radius: 12 + color: "transparent" + + Behavior on color { + PropertyAnimation { duration: 200 } + } + } + + onClicked: { + root.clicked() + } } } } diff --git a/client/ui/qml/Controls2/TextTypes/BadgeTextType.qml b/client/ui/qml/Controls2/TextTypes/BadgeTextType.qml new file mode 100644 index 00000000..86327f3d --- /dev/null +++ b/client/ui/qml/Controls2/TextTypes/BadgeTextType.qml @@ -0,0 +1,15 @@ +import QtQuick + +import Style 1.0 + +Text { + lineHeight: 10 + LanguageModel.getLineHeightAppend() + lineHeightMode: Text.FixedHeight + + color: AmneziaStyle.color.midnightBlack + font.pixelSize: 11 + font.weight: Font.Medium + font.family: "PT Root UI VF" + + wrapMode: Text.NoWrap +} diff --git a/client/ui/qml/Modules/Style/AmneziaStyle.qml b/client/ui/qml/Modules/Style/AmneziaStyle.qml index 20b56336..6c81dc9a 100644 --- a/client/ui/qml/Modules/Style/AmneziaStyle.qml +++ b/client/ui/qml/Modules/Style/AmneziaStyle.qml @@ -12,13 +12,17 @@ QtObject { readonly property color slateGray: '#2C2D30' readonly property color onyxBlack: '#1C1D21' readonly property color midnightBlack: '#0E0E11' - readonly property color goldenApricot: '#FBB26A' + readonly property color goldenApricot: goldenApricotString + readonly property color benefitsPanelBackground: '#1C1C1E' + readonly property color softViolet: '#A87BE2' readonly property color burntOrange: '#A85809' readonly property color mutedBrown: '#84603D' readonly property color richBrown: '#633303' readonly property color deepBrown: '#402102' readonly property color vibrantRed: '#EB5757' readonly property color darkCharcoal: '#261E1A' + readonly property color pearlGray: '#EAEAEC' + readonly property color sheerWhite: Qt.rgba(1, 1, 1, 0.12) readonly property color translucentWhite: Qt.rgba(1, 1, 1, 0.08) readonly property color barelyTranslucentWhite: Qt.rgba(1, 1, 1, 0.05) @@ -26,9 +30,10 @@ QtObject { readonly property color softGoldenApricot: Qt.rgba(251/255, 178/255, 106/255, 0.3) readonly property color mistyGray: Qt.rgba(215/255, 216/255, 219/255, 0.8) readonly property color cloudyGray: Qt.rgba(215/255, 216/255, 219/255, 0.65) - readonly property color pearlGray: '#EAEAEC' readonly property color translucentRichBrown: Qt.rgba(99/255, 51/255, 3/255, 0.26) readonly property color translucentSlateGray: Qt.rgba(85/255, 86/255, 92/255, 0.13) readonly property color translucentOnyxBlack: Qt.rgba(28/255, 29/255, 33/255, 0.13) + + readonly property string goldenApricotString: '#FBB26A' } } diff --git a/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml b/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml index d7aded66..ca097618 100644 --- a/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml +++ b/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml @@ -20,9 +20,14 @@ PageType { property var processedServer property bool subscriptionExpired: false property bool subscriptionExpiringSoon: false + property bool isSubscriptionRenewalAvailable: false + property bool isInAppPurchase: false + function updateSubscriptionState() { root.subscriptionExpired = ServersModel.getProcessedServerData("isSubscriptionExpired") root.subscriptionExpiringSoon = ServersModel.getProcessedServerData("isSubscriptionExpiringSoon") + root.isSubscriptionRenewalAvailable = ApiAccountInfoModel.data("isSubscriptionRenewalAvailable") + root.isInAppPurchase = ApiAccountInfoModel.data("isInAppPurchase") } Component.onCompleted: { @@ -38,6 +43,14 @@ PageType { } } + Connections { + target: ApiAccountInfoModel + + function onModelReset() { + root.updateSubscriptionState() + } + } + SortFilterProxyModel { id: proxyServersModel objectName: "proxyServersModel" @@ -87,7 +100,7 @@ PageType { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.bottomMargin: 4 + Layout.bottomMargin: root.subscriptionExpired || root.subscriptionExpiringSoon ? 0 : 4 actionButtonImage: "qrc:/images/controls/settings.svg" @@ -105,26 +118,27 @@ PageType { } } - CaptionTextType { + ParagraphTextType { visible: root.subscriptionExpired || root.subscriptionExpiringSoon Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.topMargin: 4 + Layout.topMargin: 12 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 + visible: (root.subscriptionExpired || root.subscriptionExpiringSoon) + && root.isSubscriptionRenewalAvailable && !root.isInAppPurchase Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.topMargin: 8 - Layout.bottomMargin: 4 + Layout.topMargin: 28 + Layout.bottomMargin: 0 defaultColor: AmneziaStyle.color.paleGray hoveredColor: AmneziaStyle.color.lightGray @@ -138,11 +152,11 @@ PageType { } } - CaptionTextType { + ParagraphTextType { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.topMargin: (root.subscriptionExpired || root.subscriptionExpiringSoon) ? 8 : 4 + Layout.topMargin: (root.subscriptionExpired || root.subscriptionExpiringSoon) ? 12 : 4 Layout.bottomMargin: 8 text: qsTr("Location for connection") diff --git a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml index 7e44138a..d711ab92 100644 --- a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml +++ b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml @@ -2,7 +2,6 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import QtQuick.Dialogs -import Qt5Compat.GraphicalEffects import SortFilterProxyModel 0.2 @@ -55,10 +54,14 @@ PageType { property bool isSubscriptionExpired: false property bool isSubscriptionExpiringSoon: false + property bool isSubscriptionRenewalAvailable: false + property bool isInAppPurchase: false function updateSubscriptionState() { root.isSubscriptionExpired = ApiAccountInfoModel.data("isSubscriptionExpired") root.isSubscriptionExpiringSoon = ApiAccountInfoModel.data("isSubscriptionExpiringSoon") + root.isSubscriptionRenewalAvailable = ApiAccountInfoModel.data("isSubscriptionRenewalAvailable") + root.isInAppPurchase = ApiAccountInfoModel.data("isInAppPurchase") } Component.onCompleted: { @@ -124,7 +127,7 @@ PageType { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.bottomMargin: 10 + Layout.bottomMargin: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon ? 0 : 10 actionButtonImage: "qrc:/images/controls/edit-3.svg" @@ -135,13 +138,13 @@ PageType { } } - Text { + ParagraphTextType { visible: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - Layout.topMargin: 4 + Layout.topMargin: 12 text: root.isSubscriptionExpired ? qsTr("Subscription expired") @@ -150,10 +153,6 @@ PageType { color: root.isSubscriptionExpired ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot - - font.pixelSize: 14 - font.weight: Font.Medium - wrapMode: Text.WordWrap } ParagraphTextType { @@ -170,7 +169,8 @@ PageType { } BasicButtonType { - visible: root.isSubscriptionExpired || root.isSubscriptionExpiringSoon + visible: (root.isSubscriptionExpired || root.isSubscriptionExpiringSoon) + && root.isSubscriptionRenewalAvailable && !root.isInAppPurchase Layout.fillWidth: true Layout.leftMargin: 16 @@ -226,52 +226,33 @@ PageType { readonly property bool isVisibleForAmneziaFree: ApiAccountInfoModel.data("isComponentVisible") - Item { + BasicButtonType { visible: !root.isSubscriptionExpired && !root.isSubscriptionExpiringSoon + && root.isSubscriptionRenewalAvailable && !root.isInAppPurchase - Layout.fillWidth: true - implicitHeight: renewRow.implicitHeight + 32 + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 16 + Layout.bottomMargin: 16 - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: ApiSettingsController.getRenewalLink() - } + implicitHeight: 25 - Row { - id: renewRow - anchors.centerIn: parent - spacing: 12 + defaultColor: AmneziaStyle.color.transparent + hoveredColor: AmneziaStyle.color.translucentWhite + pressedColor: AmneziaStyle.color.sheerWhite + textColor: AmneziaStyle.color.goldenApricot + leftImageSource: "qrc:/images/controls/refresh-cw.svg" + leftImageColor: AmneziaStyle.color.goldenApricot - Item { - width: renewIcon.implicitWidth - height: renewIcon.implicitHeight - anchors.verticalCenter: parent.verticalCenter + text: qsTr("Renew subscription") - 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 - } + clickedFunc: function() { + ApiSettingsController.getRenewalLink() } } DividerType { visible: !root.isSubscriptionExpired && !root.isSubscriptionExpiringSoon + && root.isSubscriptionRenewalAvailable && !root.isInAppPurchase } SwitcherType { diff --git a/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml new file mode 100644 index 00000000..507e0d62 --- /dev/null +++ b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml @@ -0,0 +1,140 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + property string freeHeaderName: "" + property string freeHeaderDescription: "" + + function syncFromModel() { + root.freeHeaderName = String(ApiServicesModel.getSelectedServiceData("name")) + root.freeHeaderDescription = String(ApiServicesModel.getSelectedServiceData("serviceDescription")) + } + + Component.onCompleted: syncFromModel() + + BackButtonType { + id: backButton + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + SettingsController.safeAreaTopMargin + + onFocusChanged: { + if (activeFocus) { + flick.contentY = 0 + } + } + } + + FlickableType { + id: flick + + anchors.top: backButton.bottom + anchors.bottom: continueButton.top + anchors.left: parent.left + anchors.right: parent.right + + contentHeight: scrollColumn.implicitHeight + 24 + + ColumnLayout { + id: scrollColumn + + width: flick.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + headerText: root.freeHeaderName + descriptionText: root.freeHeaderDescription + } + + LabelTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 12 + + text: qsTr("Free features") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 13 + } + + BenefitsPanel { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + benefitsModel: ApiBenefitsModel + } + + TermsAndPrivacyText { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + + visible: !(Qt.platform.os === "ios" || IsMacOsNeBuild) + + termsUrl: String(ApiServicesModel.getSelectedServiceData("termsOfUseUrl")) + privacyUrl: String(ApiServicesModel.getSelectedServiceData("privacyPolicyUrl")) + } + + TermsAndPrivacyText { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + visible: (Qt.platform.os === "ios" || IsMacOsNeBuild) + + termsUrl: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" + privacyUrl: LanguageModel.getCurrentSiteUrl("policy") + } + } + } + + BasicButtonType { + id: continueButton + + z: 2 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + SettingsController.safeAreaBottomMargin + + text: qsTr("Continue") + + 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() + } + } + } +} diff --git a/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml new file mode 100644 index 00000000..b2fcce85 --- /dev/null +++ b/client/ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml @@ -0,0 +1,198 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" +import PageEnum 1.0 + +PageType { + id: root + + property int selectedPlanIndex: 0 + property string premiumHeaderName: "" + property string premiumHeaderDescription: "" + + readonly property var currentPlan: ApiSubscriptionPlansModel.planAt(selectedPlanIndex) + + function syncFromModel() { + root.selectedPlanIndex = ApiSubscriptionPlansModel.recommendedRowIndex() + + root.premiumHeaderName = String(ApiServicesModel.getSelectedServiceData("name")) + root.premiumHeaderDescription = String(ApiServicesModel.getSelectedServiceData("serviceDescription")) + } + + Component.onCompleted: syncFromModel() + + BackButtonType { + id: backButton + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + SettingsController.safeAreaTopMargin + + onFocusChanged: { + if (activeFocus) { + flick.contentY = 0 + } + } + } + + FlickableType { + id: flick + + anchors.top: backButton.bottom + anchors.bottom: continueButton.top + anchors.left: parent.left + anchors.right: parent.right + + contentHeight: scrollColumn.implicitHeight + 24 + + ColumnLayout { + id: scrollColumn + + width: flick.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + headerText: root.premiumHeaderName + descriptionText: root.premiumHeaderDescription + } + + Repeater { + model: ApiSubscriptionPlansModel + + delegate: SubscriptionPlanCard { + required property int index + required property var model + + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: index === ApiSubscriptionPlansModel.rowCount() - 1 ? 24 : 12 + + selected: root.selectedPlanIndex === index + billingPeriod: String(model.billingPeriod) + priceLabel: String(model.priceLabel) + subtitle: String(model.subtitle) + showRecommendedBadge: !!model.recommended + recommendedText: qsTr("Recommended") + + onSelectRequested: root.selectedPlanIndex = index + } + } + + LabelTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 12 + + text: qsTr("Premium features") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 13 + } + + BenefitsPanel { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + benefitsModel: ApiBenefitsModel + } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + visible: Qt.platform.os === "ios" || IsMacOsNeBuild + spacing: 16 + + ParagraphTextType { + Layout.fillWidth: true + 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.") + } + + TermsAndPrivacyText { + termsUrl: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" + privacyUrl: LanguageModel.getCurrentSiteUrl("policy") + } + } + + TermsAndPrivacyText { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + visible: !(Qt.platform.os === "ios" || IsMacOsNeBuild) + + termsUrl: String(ApiServicesModel.getSelectedServiceData("termsOfUseUrl")) + privacyUrl: String(ApiServicesModel.getSelectedServiceData("privacyPolicyUrl")) + } + } + } + + BasicButtonType { + id: continueButton + + z: 2 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + SettingsController.safeAreaBottomMargin + + text: { + var plan = root.currentPlan + if (!plan) { + return qsTr("Continue") + } + return qsTr("Subscribe — %1 for %2").arg(String(plan.billingPeriod)).arg(String(plan.priceLabel)) + } + + clickedFunc: function() { + var plan = root.currentPlan + if (!plan) { + return + } + if (plan.isTrial) { + PageController.goToPage(PageEnum.PageSetupWizardApiTrialEmail) + return + } + if (Qt.platform.os === "ios" || IsMacOsNeBuild) { + PageController.showBusyIndicator(true) + var storeId = plan.storeProductId !== undefined ? String(plan.storeProductId) : "" + ApiConfigsController.importPremiumFromAppStore(storeId) + PageController.showBusyIndicator(false) + return + } + if (plan.checkoutUrl) { + Qt.openUrlExternally(plan.checkoutUrl) + PageController.closePage() + PageController.closePage() + return + } + } + } +} diff --git a/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml deleted file mode 100644 index 24308a12..00000000 --- a/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml +++ /dev/null @@ -1,226 +0,0 @@ -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/PageSetupWizardApiServicesList.qml b/client/ui/qml/Pages2/PageSetupWizardApiServicesList.qml index edf361f7..6146a697 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiServicesList.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiServicesList.qml @@ -84,12 +84,19 @@ PageType { bodyText: cardDescription footerText: price + showRecommendedBadge: showRecommended && isServiceAvailable + recommendedText: qsTr("Recommended") + rightImageSource: "qrc:/images/controls/chevron-right.svg" onClicked: { if (isServiceAvailable) { ApiServicesModel.setServiceIndex(proxyApiServicesModel.mapToSource(index)) - PageController.goToPage(PageEnum.PageSetupWizardApiServiceInfo) + if (ApiServicesModel.getSelectedServiceType() === "amnezia-premium") { + PageController.goToPage(PageEnum.PageSetupWizardApiPremiumInfo) + } else { + PageController.goToPage(PageEnum.PageSetupWizardApiFreeInfo) + } } } diff --git a/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml b/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml new file mode 100644 index 00000000..a23eff4b --- /dev/null +++ b/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml @@ -0,0 +1,138 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + property string trialEmailErrorMessage: "" + + Connections { + target: ApiConfigsController + + function onTrialEmailError(message) { + root.trialEmailErrorMessage = message + emailField.errorText = message + } + } + + BackButtonType { + id: backButton + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + SettingsController.safeAreaTopMargin + + onFocusChanged: { + if (activeFocus) { + flick.contentY = 0 + } + } + } + + FlickableType { + id: flick + + anchors.top: backButton.bottom + anchors.bottom: continueButton.top + anchors.left: parent.left + anchors.right: parent.right + + contentHeight: scrollColumn.implicitHeight + 24 + + ColumnLayout { + id: scrollColumn + + width: flick.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + headerText: qsTr("Create an account") + descriptionText: qsTr("To manage your subscription") + } + + TextFieldWithHeaderType { + id: emailField + + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + headerText: qsTr("Email") + textField.placeholderText: qsTr("Email") + textField.inputMethodHints: Qt.ImhEmailCharactersOnly + + Connections { + target: emailField.textField + + function onTextChanged() { + if (root.trialEmailErrorMessage !== "") { + root.trialEmailErrorMessage = "" + emailField.errorText = "" + } + } + } + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + + wrapMode: Text.WordWrap + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + text: qsTr("We will create an account for your trial subscription and send important subscription updates to this email.") + } + } + } + + BasicButtonType { + id: continueButton + + z: 2 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + SettingsController.safeAreaBottomMargin + + text: qsTr("Continue") + + clickedFunc: function() { + root.trialEmailErrorMessage = "" + emailField.errorText = "" + + var raw = emailField.textField.text.trim() + if (raw.length === 0 || raw.indexOf("@") < 0) { + PageController.showNotificationMessage(qsTr("Enter a valid email address")) + return + } + PageController.showBusyIndicator(true) + var ok = ApiConfigsController.importTrialFromGateway(raw) + PageController.showBusyIndicator(false) + if (ok) { + PageController.closePage() + PageController.closePage() + } + } + } +} diff --git a/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml b/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml index 160177b6..061ef65a 100644 --- a/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml +++ b/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml @@ -222,6 +222,9 @@ PageType { headerText: title bodyText: description + showRecommendedBadge: featuredAmneziaConnection + recommendedText: featuredAmneziaConnection ? qsTr("Recommended") : "" + rightImageSource: "qrc:/images/controls/chevron-right.svg" leftImageSource: imageSource @@ -275,8 +278,9 @@ PageType { id: amneziaVpn property string title: qsTr("VPN by Amnezia") - property string description: qsTr("Connect to classic paid and free VPN services from Amnezia") + property string description: qsTr("The easiest way to connect to VPN") property string imageSource: "qrc:/images/controls/amnezia.svg" + property bool featuredAmneziaConnection: true property bool isVisible: true property var handler: function() { PageController.showBusyIndicator(true) @@ -291,6 +295,7 @@ PageType { QtObject { id: selfHostVpn + property bool featuredAmneziaConnection: false property string title: qsTr("Self-hosted VPN") property string description: qsTr("Configure Amnezia VPN on your own server") property string imageSource: "qrc:/images/controls/server.svg" @@ -303,6 +308,7 @@ PageType { QtObject { id: backupRestore + property bool featuredAmneziaConnection: false property string title: qsTr("Restore from backup") property string description: qsTr("") property string imageSource: "qrc:/images/controls/archive-restore.svg" @@ -321,6 +327,7 @@ PageType { QtObject { id: fileOpen + property bool featuredAmneziaConnection: false property string title: qsTr("File with connection settings") property string description: qsTr("") property string imageSource: "qrc:/images/controls/folder-search-2.svg" @@ -340,6 +347,7 @@ PageType { QtObject { id: qrScan + property bool featuredAmneziaConnection: false property string title: qsTr("QR code") property string description: qsTr("") property string imageSource: "qrc:/images/controls/scan-line.svg" @@ -355,13 +363,14 @@ PageType { QtObject { id: restorePurchases + property bool featuredAmneziaConnection: false property string title: qsTr("Restore purchases") property string description: qsTr("") property string imageSource: "qrc:/images/controls/refresh-cw.svg" property bool isVisible: Qt.platform.os === "ios" || IsMacOsNeBuild property var handler: function() { PageController.showBusyIndicator(true) - ApiConfigsController.restoreSerivceFromAppStore() + ApiConfigsController.restoreServiceFromAppStore() PageController.showBusyIndicator(false) } } @@ -369,6 +378,7 @@ PageType { QtObject { id: siteLink + property bool featuredAmneziaConnection: false property string title: qsTr("I have nothing") property string description: qsTr("") property string imageSource: "qrc:/images/controls/help-circle.svg" diff --git a/client/ui/qml/Pages2/PageStart.qml b/client/ui/qml/Pages2/PageStart.qml index e731704d..51608e0b 100644 --- a/client/ui/qml/Pages2/PageStart.qml +++ b/client/ui/qml/Pages2/PageStart.qml @@ -225,9 +225,13 @@ PageType { Connections { target: ApiConfigsController - function onInstallServerFromApiFinished(message) { + function onInstallServerFromApiFinished(message, preferredDefaultIndex) { if (!ConnectionController.isConnected) { - ServersModel.setDefaultServerIndex(ServersModel.getServersCount() - 1); + if (preferredDefaultIndex !== undefined && preferredDefaultIndex >= 0) { + ServersModel.setDefaultServerIndex(preferredDefaultIndex) + } else { + ServersModel.setDefaultServerIndex(ServersModel.getServersCount() - 1) + } ServersModel.processedIndex = ServersModel.defaultIndex } From cd50e0b8a5f3156d256638f789587530cc0530e3 Mon Sep 17 00:00:00 2001 From: lunardunno <126363523+lunardunno@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:27:06 +0400 Subject: [PATCH 24/36] fix: full server cleanup (#2446) * Fix: full server cleanup * Cleaning by REPOSITORY:TAG --- client/server_scripts/remove_all_containers.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/server_scripts/remove_all_containers.sh b/client/server_scripts/remove_all_containers.sh index 8b2d63c1..c42769b3 100644 --- a/client/server_scripts/remove_all_containers.sh +++ b/client/server_scripts/remove_all_containers.sh @@ -1,5 +1,5 @@ sudo docker ps -a | grep amnezia | awk '{print $1}' | xargs sudo docker stop;\ sudo docker ps -a | grep amnezia | awk '{print $1}' | xargs sudo docker rm -fv;\ -sudo docker images -a --format table | grep amnezia | awk '{print $3}' | xargs sudo docker rmi;\ +sudo docker images -a --format table | grep amnezia | awk '{print $3, $1 ":" $2}' | xargs sudo docker rmi;\ sudo docker network ls | grep amnezia-dns-net | awk '{print $1}' | xargs sudo docker network rm;\ sudo rm -frd /opt/amnezia From ad14847eb55f8adcc29b4c3eb7b58d58a4713f52 Mon Sep 17 00:00:00 2001 From: yyy-amnezia Date: Wed, 8 Apr 2026 07:37:52 +0300 Subject: [PATCH 25/36] fix: ios ovpn fix (#2360) * feat: enhance OpenVPN support and configuration handling for iOS and macOS platforms * Deps updated * Deps updated * feat: add OpenVPN configuration validation and regeneration logic to VpnConfigurationsController * revert: restore pre-fix OpenVPN NE flow * chore: add OpenVPN NE payload diagnostics * Revert "revert: restore pre-fix OpenVPN NE flow" This reverts commit ae99cc77e9fa982c20b15e4ca843bfebe0916942. * chore: remove openvpn config processing --------- Co-authored-by: vkamn --- client/containers/containers_defs.cpp | 4 +- client/macos/networkextension/CMakeLists.txt | 2 +- client/macos/networkextension/Info.plist.in | 8 +- .../ios/PacketTunnelProvider+OpenVPN.swift | 690 +++++++++++++++++- .../platforms/ios/PacketTunnelProvider.swift | 118 ++- client/platforms/ios/ios_controller.mm | 68 +- 6 files changed, 852 insertions(+), 38 deletions(-) diff --git a/client/containers/containers_defs.cpp b/client/containers/containers_defs.cpp index a30b016b..d14ace05 100644 --- a/client/containers/containers_defs.cpp +++ b/client/containers/containers_defs.cpp @@ -298,14 +298,14 @@ bool ContainerProps::isSupportedByCurrentPlatform(DockerContainer c) } #elif defined(MACOS_NE) - // macOS build using Network Extension – hide OpenVPN-based containers + // macOS build using Network Extension – allow OpenVPN for parity with iOS. switch (c) { + case DockerContainer::OpenVpn: return true; case DockerContainer::WireGuard: return true; case DockerContainer::Awg2: return true; case DockerContainer::Awg: return true; case DockerContainer::Xray: return true; case DockerContainer::SSXray: return true; - case DockerContainer::OpenVpn: case DockerContainer::Cloak: case DockerContainer::ShadowSocks: return false; diff --git a/client/macos/networkextension/CMakeLists.txt b/client/macos/networkextension/CMakeLists.txt index efe1b835..d2185fd5 100644 --- a/client/macos/networkextension/CMakeLists.txt +++ b/client/macos/networkextension/CMakeLists.txt @@ -16,7 +16,7 @@ set_target_properties(AmneziaVPNNetworkExtension PROPERTIES XCODE_ATTRIBUTE_PRODUCT_BUNDLE_NAME "${BUILD_IOS_APP_IDENTIFIER}.network-extension" XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS ${CMAKE_CURRENT_SOURCE_DIR}/AmneziaVPNNetworkExtension.entitlements XCODE_ATTRIBUTE_MARKETING_VERSION "${APP_MAJOR_VERSION}" - XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${BUILD_ID}" + XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${CMAKE_PROJECT_VERSION_TWEAK}" XCODE_ATTRIBUTE_PRODUCT_NAME "AmneziaVPNNetworkExtension" XCODE_ATTRIBUTE_APPLICATION_EXTENSION_API_ONLY "YES" diff --git a/client/macos/networkextension/Info.plist.in b/client/macos/networkextension/Info.plist.in index fa307001..a5d1edf8 100644 --- a/client/macos/networkextension/Info.plist.in +++ b/client/macos/networkextension/Info.plist.in @@ -8,7 +8,7 @@ AmneziaVPNNetworkExtension CFBundleIdentifier - org.amnezia.AmneziaVPN.network-extension + $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName @@ -16,9 +16,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - ${APPLE_PROJECT_VERSION} + $(MARKETING_VERSION) CFBundleVersion - ${CMAKE_PROJECT_VERSION_TWEAK} + $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption @@ -41,6 +41,6 @@ group.org.amnezia.AmneziaVPN com.wireguard.macos.app_group_id - ${BUILD_VPN_DEVELOPMENT_TEAM}.group.org.amnezia.AmneziaVPN + $(DEVELOPMENT_TEAM).group.org.amnezia.AmneziaVPN diff --git a/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift b/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift index 882ad578..3983a96f 100644 --- a/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift +++ b/client/platforms/ios/PacketTunnelProvider+OpenVPN.swift @@ -15,6 +15,12 @@ struct OpenVPNConfig: Decodable { extension PacketTunnelProvider { func startOpenVPN(completionHandler: @escaping (Error?) -> Void) { + // Reset session-derived state so reconnects never reuse stale gateway/address data. + openVpnGatewayAddress = nil + openVpnLocalAddress = nil + openVpnLocalMask = nil + lastOpenVPNSettings = nil + guard let protocolConfiguration = self.protocolConfiguration as? NETunnelProviderProtocol, let providerConfiguration = protocolConfiguration.providerConfiguration, let openVPNConfigData = providerConfiguration[Constants.ovpnConfigKey] as? Data else { @@ -25,7 +31,25 @@ extension PacketTunnelProvider { do { let openVPNConfig = try JSONDecoder().decode(OpenVPNConfig.self, from: openVPNConfigData) ovpnLog(.info, title: "config: ", message: openVPNConfig.str) + let wrapperPreview = String(decoding: openVPNConfigData.prefix(512), as: UTF8.self) + let ovpnPreview = String(openVPNConfig.config.prefix(512)) + ovpnLog(.info, title: "config wrapper", message: "bytes=\(openVPNConfigData.count) preview=\(wrapperPreview)") + ovpnLog(.info, title: "config raw", message: "chars=\(openVPNConfig.config.count) preview=\(ovpnPreview)") let ovpnConfiguration = Data(openVPNConfig.config.utf8) + splitTunnelType = openVPNConfig.splitTunnelType + splitTunnelSites = openVPNConfig.splitTunnelSites + openVpnDnsServers = Self.extractDnsServers(from: openVPNConfig.config) + openVpnRemoteAddress = Self.extractRemoteHost(from: openVPNConfig.config) + openVpnRedirectGatewayDef1 = Self.hasRedirectGatewayDef1(in: openVPNConfig.config) + if let openVpnRemoteAddress { + ovpnLog(.info, title: "Remote", message: "host=\(openVpnRemoteAddress)") + } + if !openVpnDnsServers.isEmpty { + ovpnLog(.info, title: "DNS", message: "servers=\(openVpnDnsServers)") + } + if openVpnRedirectGatewayDef1 { + ovpnLog(.info, title: "IPv4Routes", message: "redirect-gateway def1 detected") + } setupAndlaunchOpenVPN(withConfig: ovpnConfiguration, completionHandler: completionHandler) } catch { ovpnLog(.error, message: "Can't parse OpenVPN config: \(error.localizedDescription)") @@ -73,6 +97,11 @@ extension PacketTunnelProvider { let digestString = digest.map { String(format: "%02x", $0) }.joined() ovpnLog(.info, title: "ConfigDigest", message: digestString) + let hasCertTag = configString.contains("") && configString.contains("") + let hasKeyTag = configString.contains("") && configString.contains("") + let hasAuthUserPass = configString.contains("auth-user-pass") + ovpnLog(.info, title: "ConfigCreds", message: "inlineCert=\(hasCertTag) inlineKey=\(hasKeyTag) authUserPass=\(hasAuthUserPass)") + let hasTlsAuthOpen = configString.contains("") let hasTlsAuthClose = configString.contains("") ovpnLog(.info, title: "ConfigFlags", message: "tls-auth open=\(hasTlsAuthOpen) close=\(hasTlsAuthClose)") @@ -83,27 +112,98 @@ extension PacketTunnelProvider { ovpnLog(.debug, title: "ConfigHead", message: head) ovpnLog(.debug, title: "ConfigTail", message: tail) - if let start = configString.range(of: ""), - let end = configString.range(of: "", range: start.upperBound.. Void ) { + guard var effectiveSettings = networkSettings else { + ovpnLog(.info, title: "SetTunnelNetworkSettings", message: "nil settings; skipping update") + completionHandler(nil) + return + } + let splitType = splitTunnelType ?? 0 + + if let ipv4Settings = effectiveSettings.ipv4Settings { + openVpnLocalAddress = ipv4Settings.addresses.first + openVpnLocalMask = ipv4Settings.subnetMasks.first + } + + let serverIP = openVPNAdapter.connectionInformation?.serverIP + let configRemote = openVpnRemoteAddress + let serverEndpoint: String? = { + if let ip = serverIP, Self.isIPv4Address(ip) { return ip } + if let ip = configRemote, Self.isIPv4Address(ip) { return ip } + return effectiveSettings.tunnelRemoteAddress + }() + + if let serverEndpoint, + Self.isIPv4Address(serverEndpoint), + effectiveSettings.tunnelRemoteAddress != serverEndpoint { + let updatedSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: serverEndpoint) + updatedSettings.ipv4Settings = effectiveSettings.ipv4Settings + updatedSettings.ipv6Settings = effectiveSettings.ipv6Settings + updatedSettings.dnsSettings = effectiveSettings.dnsSettings + updatedSettings.proxySettings = effectiveSettings.proxySettings + updatedSettings.mtu = effectiveSettings.mtu + effectiveSettings = updatedSettings + ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress set to server=\(serverEndpoint)") + } else if let serverEndpoint, !Self.isIPv4Address(serverEndpoint) { + ovpnLog(.info, title: "Remote", message: "skip tunnelRemoteAddress override; non-ip serverEndpoint=\(serverEndpoint)") + } + // In order to direct all DNS queries first to the VPN DNS servers before the primary DNS servers // send empty string to NEDNSSettings.matchDomains - networkSettings?.dnsSettings?.matchDomains = [""] + if let dnsSettings = effectiveSettings.dnsSettings { + if dnsSettings.servers.isEmpty, !openVpnDnsServers.isEmpty { + let newSettings = NEDNSSettings(servers: openVpnDnsServers) + newSettings.matchDomains = dnsSettings.matchDomains + effectiveSettings.dnsSettings = newSettings + } + } else if !openVpnDnsServers.isEmpty { + let newSettings = NEDNSSettings(servers: openVpnDnsServers) + effectiveSettings.dnsSettings = newSettings + } - if splitTunnelType == 1 { + effectiveSettings.dnsSettings?.matchDomains = [""] + if let dnsSettings = effectiveSettings.dnsSettings { + let servers = dnsSettings.servers.joined(separator: ",") + let domains = dnsSettings.matchDomains?.joined(separator: ",") ?? "" + ovpnLog(.info, title: "DNS", message: "servers=[\(servers)] matchDomains=[\(domains)]") + } else { + ovpnLog(.error, title: "DNS", message: "dnsSettings is nil") + } + + let tunnelRemote = effectiveSettings.tunnelRemoteAddress + if !tunnelRemote.isEmpty { + ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress=\(tunnelRemote)") + } else if let remoteAddress = openVpnRemoteAddress { + ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress is empty, configRemote=\(remoteAddress)") + } + + if let ipv4Settings = effectiveSettings.ipv4Settings { + let included = (ipv4Settings.includedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationSubnetMask)" } + let excluded = (ipv4Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationSubnetMask)" } + let addresses = ipv4Settings.addresses.joined(separator: ",") + let masks = ipv4Settings.subnetMasks.joined(separator: ",") + let router: String +#if os(macOS) + if #available(macOS 13.0, *) { + router = ipv4Settings.router ?? "" + } else { + router = "" + } +#else + router = "" +#endif + ovpnLog(.info, title: "IPv4RoutesPre", message: "addresses=[\(addresses)] masks=[\(masks)] router=\(router) included=\(included) excluded=\(excluded)") + } else { + ovpnLog(.error, title: "IPv4RoutesPre", message: "ipv4Settings is nil") + } + + if let ipv6Settings = effectiveSettings.ipv6Settings { + let included = (ipv6Settings.includedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" } + let excluded = (ipv6Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" } + let addresses = ipv6Settings.addresses.joined(separator: ",") + let prefixes = ipv6Settings.networkPrefixLengths.map { "\($0)" }.joined(separator: ",") + ovpnLog(.info, title: "IPv6RoutesPre", message: "addresses=[\(addresses)] prefixes=[\(prefixes)] included=\(included) excluded=\(excluded)") + } + + if splitType == 1 { var ipv4IncludedRoutes = [NEIPv4Route]() guard let splitTunnelSites else { @@ -194,9 +393,8 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate { } } - networkSettings?.ipv4Settings?.includedRoutes = ipv4IncludedRoutes - } else { - if splitTunnelType == 2 { + effectiveSettings.ipv4Settings?.includedRoutes = ipv4IncludedRoutes + } else if splitType == 2 { var ipv4ExcludedRoutes = [NEIPv4Route]() var ipv4IncludedRoutes = [NEIPv4Route]() var ipv6IncludedRoutes = [NEIPv6Route]() @@ -224,14 +422,418 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate { destinationAddress: "\(allIPv6.address)", networkPrefixLength: NSNumber(value: allIPv6.networkPrefixLength))) } - networkSettings?.ipv4Settings?.includedRoutes = ipv4IncludedRoutes - networkSettings?.ipv6Settings?.includedRoutes = ipv6IncludedRoutes - networkSettings?.ipv4Settings?.excludedRoutes = ipv4ExcludedRoutes + effectiveSettings.ipv4Settings?.includedRoutes = ipv4IncludedRoutes + effectiveSettings.ipv6Settings?.includedRoutes = ipv6IncludedRoutes + effectiveSettings.ipv4Settings?.excludedRoutes = ipv4ExcludedRoutes + } else { + // Full tunnel: rely on adapter-provided routes. + } + + if let serverEndpoint, + Self.isIPv4Address(serverEndpoint), + let ipv4Settings = effectiveSettings.ipv4Settings { + let hostMask = "255.255.255.255" + var excluded = ipv4Settings.excludedRoutes ?? [] + let alreadyExcluded = excluded.contains { + $0.destinationAddress == serverEndpoint && $0.destinationSubnetMask == hostMask + } + if !alreadyExcluded { + excluded.append(NEIPv4Route(destinationAddress: serverEndpoint, subnetMask: hostMask)) + ipv4Settings.excludedRoutes = excluded + ovpnLog(.info, title: "IPv4Routes", message: "excluded remoteAddress=\(serverEndpoint)") + } + } else if let serverEndpoint { + ovpnLog(.info, title: "IPv4Routes", message: "skip explicit remote exclude; non-ip server=\(serverEndpoint)") + } + + let localAddr = openVpnLocalAddress + var net30Gateway: String? + if let localAddr, let mask = openVpnLocalMask { + net30Gateway = Self.net30Peer(for: localAddr, mask: mask) + } + var gateway = net30Gateway + if let adapterGateway = openVPNAdapter.connectionInformation?.gatewayIPv4, !adapterGateway.isEmpty { + if let localAddr, adapterGateway == localAddr { + ovpnLog(.info, title: "IPv4Gateway", message: "ignore adapter gateway equal to local address=\(adapterGateway)") + } else if let net30Gateway, net30Gateway != adapterGateway { + ovpnLog(.info, title: "IPv4Gateway", message: "ignore mismatched adapter gateway=\(adapterGateway), using net30 peer=\(net30Gateway)") + } else { + gateway = adapterGateway } } + openVpnGatewayAddress = gateway + if let gateway, !gateway.isEmpty { + ovpnLog(.info, title: "IPv4Gateway", message: "gateway=\(gateway)") + } +#if os(macOS) + if splitType == 0, let gateway, !gateway.isEmpty, effectiveSettings.tunnelRemoteAddress != gateway { + let updatedSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: gateway) + updatedSettings.ipv4Settings = effectiveSettings.ipv4Settings + updatedSettings.ipv6Settings = effectiveSettings.ipv6Settings + updatedSettings.dnsSettings = effectiveSettings.dnsSettings + updatedSettings.proxySettings = effectiveSettings.proxySettings + updatedSettings.mtu = effectiveSettings.mtu + effectiveSettings = updatedSettings + ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress set to gateway=\(gateway) on macOS full-tunnel") + } +#endif +#if os(macOS) + if var ipv4Settings = effectiveSettings.ipv4Settings { + if splitType == 0 { + let hasNet30Mask = ipv4Settings.subnetMasks.contains("255.255.255.252") + if hasNet30Mask { + let normalizedMasks = Array(repeating: "255.255.255.255", + count: ipv4Settings.subnetMasks.count) + let normalized = NEIPv4Settings(addresses: ipv4Settings.addresses, + subnetMasks: normalizedMasks) + normalized.includedRoutes = ipv4Settings.includedRoutes + normalized.excludedRoutes = ipv4Settings.excludedRoutes + if #available(macOS 13.0, *) { + normalized.router = ipv4Settings.router + } + ipv4Settings = normalized + ovpnLog(.info, title: "IPv4Routes", message: "normalized net30 /30 masks to /32 on macOS full-tunnel") + } + + if let gateway, !gateway.isEmpty { + if #available(macOS 13.0, *) { + ipv4Settings.router = gateway + ovpnLog(.info, title: "IPv4Routes", message: "set ipv4 router=\(gateway) on macOS full-tunnel") + } + } + + var included = ipv4Settings.includedRoutes ?? [] + let hasDefault = included.contains { + $0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "0.0.0.0" + } + if hasDefault { + included.removeAll { + $0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "0.0.0.0" + } + } + let hasDef1Low = included.contains { + $0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "128.0.0.0" + } + let hasDef1High = included.contains { + $0.destinationAddress == "128.0.0.0" && $0.destinationSubnetMask == "128.0.0.0" + } + if (hasDefault || openVpnRedirectGatewayDef1) && !(hasDef1Low && hasDef1High) { + if !hasDef1Low { + let route = NEIPv4Route(destinationAddress: "0.0.0.0", subnetMask: "128.0.0.0") + if let gateway, !gateway.isEmpty { + route.gatewayAddress = gateway + } + included.append(route) + } + if !hasDef1High { + let route = NEIPv4Route(destinationAddress: "128.0.0.0", subnetMask: "128.0.0.0") + if let gateway, !gateway.isEmpty { + route.gatewayAddress = gateway + } + included.append(route) + } + ovpnLog(.info, title: "IPv4Routes", message: "ensured def1 routes (/1 + /1) on macOS full-tunnel") + } + if let gateway, !gateway.isEmpty { + included = included.map { route in + let isDef1 = + (route.destinationAddress == "0.0.0.0" && route.destinationSubnetMask == "128.0.0.0") || + (route.destinationAddress == "128.0.0.0" && route.destinationSubnetMask == "128.0.0.0") + guard isDef1 else { return route } + if route.gatewayAddress == gateway { + return route + } + let updatedRoute = NEIPv4Route(destinationAddress: route.destinationAddress, + subnetMask: route.destinationSubnetMask) + updatedRoute.gatewayAddress = gateway + return updatedRoute + } + ovpnLog(.info, title: "IPv4Routes", message: "set gateway=\(gateway) on macOS def1 routes") + } + ipv4Settings.includedRoutes = included + effectiveSettings.ipv4Settings = ipv4Settings + } + } +#endif + if let ipv4Settings = effectiveSettings.ipv4Settings { + let included = (ipv4Settings.includedRoutes ?? []).map { + let gw = $0.gatewayAddress ?? "" + return "\($0.destinationAddress)/\($0.destinationSubnetMask) gw=\(gw)" + } + let excluded = (ipv4Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationSubnetMask)" } + let addresses = ipv4Settings.addresses.joined(separator: ",") + let masks = ipv4Settings.subnetMasks.joined(separator: ",") + let router: String +#if os(macOS) + if #available(macOS 13.0, *) { + router = ipv4Settings.router ?? "" + } else { + router = "" + } +#else + router = "" +#endif + ovpnLog(.info, title: "IPv4Routes", message: "addresses=[\(addresses)] masks=[\(masks)] router=\(router) included=\(included) excluded=\(excluded)") + } else { + ovpnLog(.error, title: "IPv4Routes", message: "ipv4Settings is nil") + } + + if let ipv6Settings = effectiveSettings.ipv6Settings { + let included = (ipv6Settings.includedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" } + let excluded = (ipv6Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" } + let addresses = ipv6Settings.addresses.joined(separator: ",") + let prefixes = ipv6Settings.networkPrefixLengths.map { "\($0)" }.joined(separator: ",") + ovpnLog(.info, title: "IPv6Routes", message: "addresses=[\(addresses)] prefixes=[\(prefixes)] included=\(included) excluded=\(excluded)") + } +#if os(macOS) + if effectiveSettings.ipv6Settings != nil { + effectiveSettings.ipv6Settings = nil + ovpnLog(.info, title: "IPv6", message: "cleared ipv6Settings on macOS") + } +#endif + + lastOpenVPNSettings = effectiveSettings + // Set the network settings for the current tunneling session. - setTunnelNetworkSettings(networkSettings, completionHandler: completionHandler) + setTunnelNetworkSettings(effectiveSettings) { error in + if let error { + ovpnLog(.error, title: "SetTunnelNetworkSettings", message: error.localizedDescription) + } else { + ovpnLog(.info, title: "SetTunnelNetworkSettings", message: "ok") + } + completionHandler(error) + } + } + + private static func extractDnsServers(from config: String) -> [String] { + let lines = config.split(whereSeparator: \.isNewline) + var servers: [String] = [] + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("dhcp-option DNS ") { + let parts = trimmed.split(separator: " ") + if let last = parts.last { + servers.append(String(last)) + } + } + } + return servers + } + + private static func extractRemoteHost(from config: String) -> String? { + let lines = config.split(whereSeparator: \.isNewline) + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("remote ") { + let parts = trimmed.split(separator: " ") + if parts.count >= 2 { + return String(parts[1]) + } + } + } + return nil + } + + private static func hasRedirectGatewayDef1(in config: String) -> Bool { + let lines = config.split(whereSeparator: \.isNewline) + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("redirect-gateway") { + return trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).contains("def1") + } + } + return false + } + + private static func net30Peer(for address: String, mask: String) -> String? { + guard mask == "255.255.255.252" else { return nil } + let parts = address.split(separator: ".") + guard parts.count == 4 else { return nil } + var octets: [Int] = [] + for part in parts { + guard let num = Int(part), num >= 0 && num <= 255 else { return nil } + octets.append(num) + } + let ip = (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3] + let network = ip & ~3 + let host = ip - network + let peerHost: Int + switch host { + case 1: peerHost = 2 + case 2: peerHost = 1 + default: return nil + } + let peerIP = network + peerHost + return "\((peerIP >> 24) & 0xff).\((peerIP >> 16) & 0xff).\((peerIP >> 8) & 0xff).\(peerIP & 0xff)" + } + + private func logOpenVPNConnectionInfo() { + guard let info = ovpnAdapter?.connectionInformation else { return } + let message = "vpnIPv4=\(info.vpnIPv4 ?? "") gatewayIPv4=\(info.gatewayIPv4 ?? "") serverIP=\(info.serverIP ?? "") tun=\(info.tunName ?? "")" + ovpnLog(.info, title: "ConnInfo", message: message) + } + + private static func normalizeInlineBlock( + in config: String, + tag: String, + beginMarkers: [String], + endMarkers: [String] + ) -> String { + guard !beginMarkers.isEmpty, !endMarkers.isEmpty else { return config } + + var normalizedConfig = config + let openTag = "<\(tag)>" + let closeTag = "" + var searchStart = normalizedConfig.startIndex + + while let openRange = normalizedConfig.range(of: openTag, range: searchStart..= beginIndex { + let extracted = lines[beginIndex...endIndex].joined(separator: "\n") + let replacement = "<\(tag)>\n\(extracted)\n" + normalizedConfig.replaceSubrange(openRange.lowerBound.. linesIn=\(lines.count) linesOut=\(endIndex - beginIndex + 1)") + searchStart = normalizedConfig.index(openRange.lowerBound, offsetBy: replacement.count) + } else { + ovpnLog(.error, title: "ConfigInline", message: "tag=<\(tag)> missing markers, keeping original body") + searchStart = closeRange.upperBound + } + } + + return normalizedConfig + } + + + private static func stripUnsupportedOptions(forOpenVPNAdapter config: String) -> String { + let unsupportedTokens: Set = [ + "block-ipv6", + "script-security", + "up", + "down", + "resolv-retry", + "persist-key", + "persist-tun", + "compat-mode", + "disable-dco" + ] + let inlineBlockTags: Set = [ + "ca", + "cert", + "key", + "pkcs12", + "tls-auth", + "tls-crypt", + "tls-crypt-v2", + "secret", + "crl-verify", + "extra-certs" + ] + + var removed: [String: Int] = [:] + var normalized: [String: Int] = [:] + var output: [String] = [] + var activeInlineTag: String? + + for rawLine in config.split(whereSeparator: \.isNewline) { + let line = String(rawLine) + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + output.append(line) + continue + } + + let trimmedLowercased = trimmed.lowercased() + + if let currentInlineTag = activeInlineTag { + output.append(line) + if trimmedLowercased == "" { + activeInlineTag = nil + } + continue + } + + if trimmedLowercased.hasPrefix("<"), + trimmedLowercased.hasSuffix(">"), + !trimmedLowercased.hasPrefix(" Bool { + let parts = value.split(separator: ".") + if parts.count != 4 { return false } + for part in parts { + guard let num = Int(part), num >= 0 && num <= 255 else { return false } + } + return true } // Process events returned by the OpenVPN library @@ -249,6 +851,9 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate { startHandler(nil) self.startHandler = nil + + logOpenVPNConnectionInfo() + refreshOpenVPNSettingsAfterConnect() case .disconnected: guard let stopHandler = stopHandler else { return } @@ -291,4 +896,41 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate { // Handle log messages ovpnLog(.info, message: logMessage) } + + func openVPNAdapterDidReceiveClockTick(_ openVPNAdapter: OpenVPNAdapter) { + let now = Date() + if now.timeIntervalSince(lastOpenVPNStatsLogTime) < 5 { + return + } + lastOpenVPNStatsLogTime = now + + let transport = openVPNAdapter.transportStatistics + let iface = openVPNAdapter.interfaceStatistics + let transportLine = "transport bytesIn=\(transport.bytesIn) bytesOut=\(transport.bytesOut) packetsIn=\(transport.packetsIn) packetsOut=\(transport.packetsOut)" + let ifaceLine = "iface bytesIn=\(iface.bytesIn) bytesOut=\(iface.bytesOut) packetsIn=\(iface.packetsIn) packetsOut=\(iface.packetsOut) errorsIn=\(iface.errorsIn) errorsOut=\(iface.errorsOut)" + ovpnLog(.info, title: "Stats", message: "\(transportLine) | \(ifaceLine)") + } + + private func refreshOpenVPNSettingsAfterConnect() { + let localAddr = openVpnLocalAddress + var net30Gateway: String? + if let localAddr, let mask = openVpnLocalMask { + net30Gateway = Self.net30Peer(for: localAddr, mask: mask) + } + var gateway = net30Gateway + if let adapterGateway = ovpnAdapter?.connectionInformation?.gatewayIPv4, !adapterGateway.isEmpty { + if let localAddr, adapterGateway == localAddr { + ovpnLog(.info, title: "IPv4Gateway", message: "post-connect ignoring adapter gateway equal to local address=\(adapterGateway)") + } else if let net30Gateway, net30Gateway != adapterGateway { + ovpnLog(.info, title: "IPv4Gateway", message: "post-connect keeping net30 peer=\(net30Gateway), adapter gateway=\(adapterGateway)") + } else { + gateway = adapterGateway + } + } + + guard let gateway, !gateway.isEmpty else { return } + openVpnGatewayAddress = gateway + ovpnLog(.info, title: "IPv4Gateway", message: "post-connect gateway=\(gateway)") + } + } diff --git a/client/platforms/ios/PacketTunnelProvider.swift b/client/platforms/ios/PacketTunnelProvider.swift index e80bbb05..b2a88457 100644 --- a/client/platforms/ios/PacketTunnelProvider.swift +++ b/client/platforms/ios/PacketTunnelProvider.swift @@ -53,6 +53,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider { var splitTunnelType: Int? var splitTunnelSites: [String]? + var openVpnDnsServers: [String] = [] + var openVpnRemoteAddress: String? + var openVpnRedirectGatewayDef1 = false + var openVpnLocalAddress: String? + var openVpnLocalMask: String? + var openVpnGatewayAddress: String? + var lastOpenVPNSettings: NEPacketTunnelNetworkSettings? + var lastOpenVPNStatsLogTime = Date.distantPast let vpnReachability = OpenVPNReachability() @@ -83,8 +91,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { guard hasMeaningfulChange, let proto = self.protoType else { return } - // WireGuard/AWG manages network changes internally in its own adapter. - if proto == .wireguard { + // WireGuard/AWG and OpenVPN manages network changes internally in its own adapter. + if proto == .wireguard || proto == .openvpn { return } @@ -192,9 +200,26 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let errorNotifier = ErrorNotifier(activationAttemptId: activationAttemptId) neLog(.info, message: "Start tunnel") + if let vpnProto = protocolConfiguration as? NEVPNProtocol { + if #available(iOS 14.0, macOS 11.0, *) { + var details = "includeAllNetworks=\(vpnProto.includeAllNetworks)" + if #available(iOS 14.2, macOS 11.0, *) { + details += " excludeLocalNetworks=\(vpnProto.excludeLocalNetworks)" + } + neLog(.info, title: "Protocol", message: details) + } + } if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol { let providerConfiguration = protocolConfiguration.providerConfiguration + let providerKeys = providerConfiguration?.keys.sorted().joined(separator: ",") ?? "" + var protocolDetails = "bundleId=\(protocolConfiguration.providerBundleIdentifier ?? "") keys=[\(providerKeys)]" + if let ovpnData = providerConfiguration?[Constants.ovpnConfigKey] as? Data { + let preview = String(decoding: ovpnData.prefix(512), as: UTF8.self) + protocolDetails += " ovpnBytes=\(ovpnData.count) ovpnPreview=\(preview)" + } + neLog(.info, title: "Protocol", message: protocolDetails) + if (providerConfiguration?[Constants.ovpnConfigKey] as? Data) != nil { protoType = .openvpn } else if (providerConfiguration?[Constants.wireGuardConfigKey] as? Data) != nil { @@ -449,6 +474,8 @@ extension WireGuardLogLevel { final class PacketTunnelFlowAdapter: NSObject, OpenVPNAdapterPacketFlow { private let flow: NEPacketTunnelFlow + private var readLogCounter = 0 + private var writeLogCounter = 0 init(flow: NEPacketTunnelFlow) { self.flow = flow @@ -457,15 +484,98 @@ final class PacketTunnelFlowAdapter: NSObject, OpenVPNAdapterPacketFlow { @objc(readPacketsWithCompletionHandler:) func readPackets(completionHandler: @escaping ([Data], [NSNumber]) -> Void) { - flow.readPackets(completionHandler: completionHandler) + flow.readPackets { packets, protocols in +#if os(macOS) + if self.readLogCounter < 20, let firstPacket = packets.first, let firstProtocol = protocols.first { + let prefix = firstPacket.prefix(12).map { String(format: "%02x", $0) }.joined() + let header = Self.describePacketHeader(firstPacket) + ovpnLog(.info, title: "FlowRead", message: "count=\(packets.count) proto0=\(firstProtocol) len0=\(firstPacket.count) prefix=\(prefix) \(header)") + self.readLogCounter += 1 + } +#endif + completionHandler(packets, protocols) + } } @objc(writePackets:withProtocols:) func writePackets(_ packets: [Data], withProtocols protocols: [NSNumber]) -> Bool { - flow.writePackets(packets, withProtocols: protocols) +#if os(macOS) + if writeLogCounter < 20, let firstPacket = packets.first, let firstProtocol = protocols.first { + let prefix = firstPacket.prefix(12).map { String(format: "%02x", $0) }.joined() + let header = Self.describePacketHeader(firstPacket) + ovpnLog(.info, title: "FlowWrite", message: "count=\(packets.count) proto0=\(firstProtocol) len0=\(firstPacket.count) prefix=\(prefix) \(header)") + writeLogCounter += 1 + } +#endif + return flow.writePackets(packets, withProtocols: protocols) + } + + private static func describePacketHeader(_ packet: Data) -> String { + guard let versionNibble = packet.first.map({ Int($0 >> 4) }) else { + return "ip=unknown" + } + + if versionNibble == 4, packet.count >= 20 { + let ihl = Int(packet[0] & 0x0f) * 4 + guard ihl >= 20, packet.count >= ihl else { + return "ip=ipv4 malformed" + } + + let proto = packet[9] + let src = "\(packet[12]).\(packet[13]).\(packet[14]).\(packet[15])" + let dst = "\(packet[16]).\(packet[17]).\(packet[18]).\(packet[19])" + let l4Offset = ihl + let ports: String + if (proto == 6 || proto == 17) && packet.count >= l4Offset + 4 { + let srcPort = (UInt16(packet[l4Offset]) << 8) | UInt16(packet[l4Offset + 1]) + let dstPort = (UInt16(packet[l4Offset + 2]) << 8) | UInt16(packet[l4Offset + 3]) + ports = "sport=\(srcPort) dport=\(dstPort)" + } else { + ports = "sport=- dport=-" + } + let protoName: String + switch proto { + case 1: protoName = "ICMP" + case 6: protoName = "TCP" + case 17: protoName = "UDP" + default: protoName = "P\(proto)" + } + return "ip=ipv4 src=\(src) dst=\(dst) proto=\(protoName) \(ports)" + } + + if versionNibble == 6, packet.count >= 40 { + let proto = packet[6] + func hex16(_ start: Int) -> String { + let value = (UInt16(packet[start]) << 8) | UInt16(packet[start + 1]) + return String(format: "%x", value) + } + let src = stride(from: 8, to: 24, by: 2).map(hex16).joined(separator: ":") + let dst = stride(from: 24, to: 40, by: 2).map(hex16).joined(separator: ":") + let l4Offset = 40 + let ports: String + if (proto == 6 || proto == 17) && packet.count >= l4Offset + 4 { + let srcPort = (UInt16(packet[l4Offset]) << 8) | UInt16(packet[l4Offset + 1]) + let dstPort = (UInt16(packet[l4Offset + 2]) << 8) | UInt16(packet[l4Offset + 3]) + ports = "sport=\(srcPort) dport=\(dstPort)" + } else { + ports = "sport=- dport=-" + } + let protoName: String + switch proto { + case 58: protoName = "ICMPv6" + case 6: protoName = "TCP" + case 17: protoName = "UDP" + default: protoName = "P\(proto)" + } + return "ip=ipv6 src=\(src) dst=\(dst) proto=\(protoName) \(ports)" + } + + return "ip=v\(versionNibble) len=\(packet.count)" } } +extension NEPacketTunnelFlow: OpenVPNAdapterPacketFlow {} + extension NEProviderStopReason { var amneziaDescription: String { switch self { diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index b2a5dcd3..bf79c590 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -552,6 +552,16 @@ bool IosController::setupOpenVPN() QJsonDocument openVPNConfigDoc(openVPNConfig); QString openVPNConfigStr(openVPNConfigDoc.toJson(QJsonDocument::Compact)); + QString openVPNConfigPreview = openVPNConfigStr.left(512); + QString ovpnPreview = ovpnConfig.left(512); + + qDebug().noquote() << "IosController::setupOpenVPN payload" + << "jsonBytes=" << openVPNConfigStr.toUtf8().size() + << "ovpnChars=" << ovpnConfig.size() + << "splitTunnelType=" << m_rawConfig[config_key::splitTunnelType].toInt() + << "splitTunnelSites=" << splitTunnelSites; + qDebug().noquote() << "IosController::setupOpenVPN payload jsonPreview=" << openVPNConfigPreview; + qDebug().noquote() << "IosController::setupOpenVPN payload ovpnPreview=" << ovpnPreview; return startOpenVPN(openVPNConfigStr); } @@ -800,11 +810,59 @@ bool IosController::startOpenVPN(const QString &config) NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init]; tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID]; - tunnelProtocol.providerConfiguration = @{@"ovpn": [[NSString stringWithUTF8String:config.toStdString().c_str()] dataUsingEncoding:NSUTF8StringEncoding]}; + QByteArray configUtf8 = config.toUtf8(); + NSData *ovpnConfigData = [NSData dataWithBytes:configUtf8.constData() length:configUtf8.size()]; + tunnelProtocol.providerConfiguration = @{@"ovpn": ovpnConfigData}; tunnelProtocol.serverAddress = m_serverAddress; + if (@available(iOS 14.0, macOS 11.0, *)) { + int splitTunnelType = 0; + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(config.toUtf8(), &parseError); + if (parseError.error == QJsonParseError::NoError && doc.isObject()) { + QJsonObject obj = doc.object(); + splitTunnelType = obj.value(config_key::splitTunnelType).toInt(0); + } +#if defined(MACOS_NE) + // On macOS NE use route-based full tunnel. includeAllNetworks enables + // policy-based drop-all mode and causes enforceRoutes to be ignored. + tunnelProtocol.includeAllNetworks = NO; + if (splitTunnelType == 0) { + tunnelProtocol.enforceRoutes = YES; + if (@available(iOS 14.2, macOS 11.0, *)) { + tunnelProtocol.excludeLocalNetworks = YES; + } + } +#else + tunnelProtocol.includeAllNetworks = (splitTunnelType == 0); + if (@available(iOS 14.2, macOS 11.0, *)) { + // Keep existing iOS behavior. + if (splitTunnelType == 0) { + tunnelProtocol.excludeLocalNetworks = NO; + } + } +#endif + } m_currentTunnel.protocolConfiguration = tunnelProtocol; + NETunnelProviderProtocol *appliedProtocol = (NETunnelProviderProtocol *)m_currentTunnel.protocolConfiguration; + NSData *ovpnPayload = appliedProtocol.providerConfiguration[@"ovpn"]; + NSString *payloadPreview = @""; + if (ovpnPayload != nil) { + NSString *decodedPayload = [[NSString alloc] initWithData:ovpnPayload encoding:NSUTF8StringEncoding]; + if (decodedPayload != nil) { + payloadPreview = [decodedPayload substringToIndex:MIN((NSUInteger)512, decodedPayload.length)]; + } + } + + qDebug().noquote() << "IosController::startOpenVPN protocolConfiguration" + << "bundleId=" << QString::fromNSString(appliedProtocol.providerBundleIdentifier ?: @"") + << "serverAddress=" << QString::fromNSString(appliedProtocol.serverAddress ?: @"") + << "providerKeys=" << QString::fromNSString([[appliedProtocol.providerConfiguration.allKeys description] copy]) + << "ovpnBytes=" << (ovpnPayload != nil ? ovpnPayload.length : 0); + qDebug().noquote() << "IosController::startOpenVPN protocolConfiguration payloadPreview=" + << QString::fromNSString(payloadPreview); + startTunnel(); } @@ -814,7 +872,9 @@ bool IosController::startWireGuard(const QString &config) NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init]; tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID]; - tunnelProtocol.providerConfiguration = @{@"wireguard": [[NSString stringWithUTF8String:config.toStdString().c_str()] dataUsingEncoding:NSUTF8StringEncoding]}; + QByteArray configUtf8 = config.toUtf8(); + NSData *wgConfigData = [NSData dataWithBytes:configUtf8.constData() length:configUtf8.size()]; + tunnelProtocol.providerConfiguration = @{@"wireguard": wgConfigData}; tunnelProtocol.serverAddress = m_serverAddress; m_currentTunnel.protocolConfiguration = tunnelProtocol; @@ -828,7 +888,9 @@ bool IosController::startXray(const QString &config) NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init]; tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID]; - tunnelProtocol.providerConfiguration = @{@"xray": [[NSString stringWithUTF8String:config.toStdString().c_str()] dataUsingEncoding:NSUTF8StringEncoding]}; + QByteArray configUtf8 = config.toUtf8(); + NSData *xrayConfigData = [NSData dataWithBytes:configUtf8.constData() length:configUtf8.size()]; + tunnelProtocol.providerConfiguration = @{@"xray": xrayConfigData}; tunnelProtocol.serverAddress = m_serverAddress; m_currentTunnel.protocolConfiguration = tunnelProtocol; From 493ee228835ea5e3f908a30be364a11debe12025 Mon Sep 17 00:00:00 2001 From: Mitternacht822 <208699442+Mitternacht822@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:45:51 +0400 Subject: [PATCH 26/36] chore: block vless toggle while active connection (#2318) * fix: prevent disabled SwitcherType from toggling via keyboard * fix: disabled vless option toggle while connection is active --- client/ui/qml/Controls2/SwitcherType.qml | 2 +- client/ui/qml/Pages2/PageSettingsApiServerInfo.qml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ui/qml/Controls2/SwitcherType.qml b/client/ui/qml/Controls2/SwitcherType.qml index 4b41fc26..b2500b53 100644 --- a/client/ui/qml/Controls2/SwitcherType.qml +++ b/client/ui/qml/Controls2/SwitcherType.qml @@ -153,7 +153,7 @@ Switch { Keys.onSpacePressed: event => handleSwitch(event) function handleSwitch(event) { - if (!event.isAutoRepeat) { + if (root.enabled && !event.isAutoRepeat) { root.checked = !root.checked root.toggled() } diff --git a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml index d711ab92..a8a70493 100644 --- a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml +++ b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml @@ -259,6 +259,7 @@ PageType { id: switcher readonly property bool isVlessProtocol: ApiConfigsController.isVlessProtocol() + readonly property bool isProtocolSwitchBlocked: ServersModel.isDefaultServerCurrentlyProcessed() && ConnectionController.isConnected Layout.fillWidth: true Layout.topMargin: 24 @@ -266,6 +267,7 @@ PageType { Layout.leftMargin: 16 visible: ApiAccountInfoModel.data("isProtocolSelectionSupported") + enabled: !switcher.isProtocolSwitchBlocked text: qsTr("Use VLESS protocol") checked: switcher.isVlessProtocol From 46f5b3894bda0cca3b5a325508d27d9bf57c56ae Mon Sep 17 00:00:00 2001 From: vkamn Date: Fri, 10 Apr 2026 21:24:00 +0700 Subject: [PATCH 27/36] chore: minor fixes (#2459) * fix: fixed links on page with service description * fix: fixed subscription text color * chore: update ru translations * chore: add save button * fix: ru translation fixes --- client/core/api/apiUtils.cpp | 2 +- client/core/errorstrings.cpp | 2 +- client/translations/amneziavpn_ru_RU.ts | 445 +++++++++++++----- .../controllers/api/apiConfigsController.cpp | 8 +- client/ui/models/api/apiAccountInfoModel.cpp | 5 +- client/ui/models/api/apiBenefitsModel.cpp | 14 +- client/ui/models/api/apiBenefitsModel.h | 4 +- client/ui/qml/Components/BenefitRow.qml | 19 +- client/ui/qml/Components/BenefitsPanel.qml | 2 +- client/ui/qml/Components/ServersListView.qml | 2 +- .../Components/SubscriptionExpiredDrawer.qml | 2 +- .../ui/qml/Components/TermsAndPrivacyText.qml | 2 + client/ui/qml/Controls2/CardWithIconsType.qml | 55 +-- client/ui/qml/Pages2/PageDevMenu.qml | 24 +- .../Pages2/PageSetupWizardApiTrialEmail.qml | 2 +- .../Pages2/PageSetupWizardConfigSource.qml | 2 +- .../qml/Pages2/PageSetupWizardCredentials.qml | 2 +- 17 files changed, 389 insertions(+), 203 deletions(-) diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index f60e2d49..3d6cb335 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -192,7 +192,7 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl } qDebug() << "something went wrong"; - return amnezia::ErrorCode::InternalError; + return amnezia::ErrorCode::ApiConfigDownloadError; } bool apiUtils::isPremiumServer(const QJsonObject &serverConfigObject) diff --git a/client/core/errorstrings.cpp b/client/core/errorstrings.cpp index 20d094dd..4f5262cd 100644 --- a/client/core/errorstrings.cpp +++ b/client/core/errorstrings.cpp @@ -82,7 +82,7 @@ QString errorString(ErrorCode code) { case (ErrorCode::ApiPurchaseError): errorMessage = QObject::tr("Unable to process purchase"); break; case (ErrorCode::ApiSubscriptionNotActiveError): errorMessage = QObject::tr("No active subscription found"); break; case (ErrorCode::ApiNoPurchasedSubscriptionsError): errorMessage = QObject::tr("No purchased subscriptions found. Please purchase a subscription first"); break; - case (ErrorCode::ApiTrialAlreadyUsedError): errorMessage = QObject::tr("This email has already been used for trial activation"); break; + case (ErrorCode::ApiTrialAlreadyUsedError): errorMessage = QObject::tr("This email address has already been used to activate a trial"); break; // QFile errors case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break; diff --git a/client/translations/amneziavpn_ru_RU.ts b/client/translations/amneziavpn_ru_RU.ts index 2bfbf79f..e813b40f 100644 --- a/client/translations/amneziavpn_ru_RU.ts +++ b/client/translations/amneziavpn_ru_RU.ts @@ -52,18 +52,18 @@ ApiAccountInfoModel - - + + Active Активна - - <p><a style="color: #EB5757;">Inactive</a> + + Inactive Не активна - + %1 out of %2 %1 из %2 @@ -71,23 +71,51 @@ ApiConfigsController - - + + %1 installed successfully. %1 успешно установлен. - + Subscription restored successfully. Подписка успешно восстановлена. - + + %1/mo + IAP: price per month in plan subtitle + %1/мес + + + + from %1 per month + IAP: card footer minimum monthly price from StoreKit + от %1 в месяц + + + + + This subscription has already been added + Эта подписка уже добавлена + + + + %1 has been added to the app + %1 добавлено в приложение + + + + This email address has already been used to activate a trial. If you like the service, you can upgrade to Premium + Этот адрес электронной почты уже использовался для активации пробного периода. Если вам понравился сервис, вы можете оформить подписку Premium + + + API config reloaded Конфигурация API перезагружена - + Successfully changed the country of connection to %1 Страна подключения изменена на %1 @@ -182,29 +210,24 @@ <p><a style="color: #EB5757;">Недоступно в вашем регионе. Если у вас включен VPN, отключите его, вернитесь на предыдущий экран и попробуйте снова.</a> - %1 MBit/s - %1 Мбит/с + %1 Мбит/с - %1 days - %1 дней + %1 дней - Free - Бесплатно + Бесплатно - %1 $ - %1 $ + %1 $ - %1 $/month - %1 $/месяц + %1 $/месяц @@ -241,45 +264,45 @@ ConnectionController - + Connecting... Подключение... - + Connected Подключено - + Preparing... Подготовка... - + Settings updated successfully, reconnnection... Настройки успешно обновлены, переподключение... - + Settings updated successfully Настройки успешно обновлены - + Reconnecting... Переподключение... - - - + + + Connect Подключиться - + Disconnecting... Отключение... @@ -1697,17 +1720,32 @@ Thank you for staying with us! PageSettingsApiAvailableCountries - + + Subscription expired + Подписка закончилась + + + + Subscription expiring soon + Подписка скоро закончится + + + + Renew subscription + Продлить подписку + + + Location for connection Страны для подключения - + Unable change server location while trying to make an active connection Невозможно изменить локацию во время попытки соединения - + Unable change server location while there is an active connection Невозможно изменить локацию во время активного соединения @@ -1939,12 +1977,12 @@ Thank you for staying with us! PageSettingsApiServerInfo - + Configurations have been updated for some countries. Download and install the updated configuration files Сетевые адреса одного или нескольких серверов были обновлены. Пожалуйста, удалите старые конфигурацию и загрузите новые файлы - + Manage configuration files Управление файлами конфигурации @@ -1964,106 +2002,122 @@ Thank you for staying with us! Активные соединения - + + Subscription expired + Подписка закончилась + + + + Subscription expiring soon + Подписка скоро закончится + + + + + Renew subscription + Продлить подписку + + + Use VLESS protocol Использовать протокол VLESS - + Cannot change protocol during active connection Невозможно изменить протокол во время активного соединения - + Subscription Key Ключ для подключения - + Configuration Files Файлы конфигурации - + Active Devices Активные устройства - + Manage currently connected devices Управление подключенными устройствами - + Support Поддержка - + How to connect on another device Как подключить другие устройства - + Reload API config Перезагрузить конфигурацию API - + Reload API config? Перезагрузить конфигурацию API? - - - + + + Continue Продолжить - - - + + + Cancel Отменить - + Cannot reload API config during active connection Невозможно перзагрузить API конфигурацию при активном соединении - + Unlink this device Отвязать это устройство - + Are you sure you want to unlink this device? Вы уверены, что хотите отвязать это устройство? - + This will unlink the device from your subscription. You can reconnect it anytime by pressing "Reload API config" in subscription settings on device. Это отключит устройство от вашей подписки. Вы можете повторно подключить его в любое время, нажав "Перезагрузить конфигурацию API" в настройках подписки на устройстве. - + Cannot unlink device during active connection Невозможно отвязать устройство во время активного соединения - + Remove from application Удалить из приложения - + Remove from application? Удалить из приложения? - + Cannot remove server during active connection Невозможно удалить сервер во время активного соединения @@ -3111,51 +3165,83 @@ Thank you for staying with us! - PageSetupWizardApiServiceInfo + PageSetupWizardApiFreeInfo - + + Free features + Возможности Free + + + + Continue + Продолжить + + + + PageSetupWizardApiPremiumInfo + + + Recommended + Рекомендуется + + + + Premium features + Возможности Premium + + + 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. Списание с Apple ID при подтверждении. Продление автоматическое, если автопродление не отключено минимум за 24 часа до окончания периода. Управление в настройках Apple ID. - + + Continue + Продолжить + + + + Subscribe — %1 for %2 + Подписаться — %1 за %2 + + + + PageSetupWizardApiServiceInfo + + 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. + Списание с Apple ID при подтверждении. Продление автоматическое, если автопродление не отключено минимум за 24 часа до окончания периода. Управление в настройках Apple ID. + + Subscribe Now - Подписаться сейчас + Подписаться сейчас - By continuing, you agree to the <a href="%1" style="color: #FBB26A;">Terms of Use</a> and <a href="%2" style="color: #FBB26A;">Privacy Policy</a> - Продолжая, вы соглашаетесь с <a href="%1" style="color: #FBB26A;">Условиями использования</a> и <a href="%2" style="color: #FBB26A;">Политикой конфиденциальности</a> + Продолжая, вы соглашаетесь с <a href="%1" style="color: #FBB26A;">Условиями использования</a> и <a href="%2" style="color: #FBB26A;">Политикой конфиденциальности</a> - For the region - Для региона + Для региона - Price - Цена + Цена - Work period - Период работы + Период работы - Speed - Скорость + Скорость - Features - Особенности + Особенности - Connect - Подключиться + Подключиться @@ -3170,11 +3256,50 @@ Thank you for staying with us! Choose a VPN service that suits your needs. Выберите VPN-сервис, который подходит именно вам. + + + Recommended + Рекомендуется + + + + PageSetupWizardApiTrialEmail + + + Create an account + Создайте учётную запись + + + + To manage your subscription + Для управления подпиской + + + + + Email + Электронная почта + + + + We will create an account for your trial subscription and send important subscription updates to this email address + Мы создадим учётную запись для вашей пробной подписки и будем отправлять на этот адрес электронной почты важные уведомления о подписке + + + + Continue + Продолжить + + + + Enter a valid email address + Введите корректный адрес электронной почты + PageSetupWizardConfigSource - + File with connection settings Файл с настройками подключения @@ -3249,71 +3374,80 @@ Thank you for staying with us! Другие варианты подключения - + + Recommended + Рекомендуется + + + Site Amnezia Сайт Amnezia - + + The easiest way to connect to the VPN + Самый простой способ подключиться к VPN + + + Restore purchases Восстановить покупки - + VPN by Amnezia VPN от Amnezia - Connect to classic paid and free VPN services from Amnezia - Подключайтесь к классическим платным и бесплатным VPN-сервисам от Amnezia + Подключайтесь к классическим платным и бесплатным VPN-сервисам от Amnezia - + Self-hosted VPN Self-hosted VPN - + Configure Amnezia VPN on your own server Настроить VPN на собственном сервере - + Restore from backup Восстановить из резервной копии - - - - - + + + + + - + Open backup file Открыть резервную копию - + Backup files (*.backup) Файлы резервных копий (*.backup) - + Open config file Открыть файл с конфигурацией - + QR code QR-код - + I have nothing У меня ничего нет @@ -3321,17 +3455,17 @@ Thank you for staying with us! PageSetupWizardCredentials - + Server IP address [:port] IP-адрес[:порт] сервера - + Continue Продолжить - + Enter the address in the format 255.255.255.255:88 Введите адрес в формате 255.255.255.255:88 @@ -3341,48 +3475,54 @@ Thank you for staying with us! Настроить ваш сервер - + 255.255.255.255:22 255.255.255.255:22 - + SSH Username Имя пользователя SSH - + + Password or SSH private key Пароль или закрытый ключ SSH - + + SSH key requirements: supported key types are ED25519 and RSA in PEM format. Paste the private key, including the BEGIN/END lines. If your key doesn’t work, generate a compatible one + Требования к SSH-ключу: поддерживаются ключи ED25519 и RSA в формате PEM. Вставьте закрытый ключ целиком, включая строки BEGIN/END. Если ваш ключ не подходит, создайте совместимый ключ + + + All data you enter will remain strictly confidential and will not be shared or disclosed to the Amnezia or any third parties Все данные, которые вы вводите, останутся строго конфиденциальными и не будут переданы или раскрыты Amnezia или каким-либо третьим лицам - + How to run your VPN server Как создать VPN на собственном сервере - + Where to get connection data, step-by-step instructions for buying a VPS Где взять данные для подключения, пошаговые инструкции по покупке VPS - + Ip address cannot be empty Поле с IP-адресом не может быть пустым - + Login cannot be empty Поле с логином не может быть пустым - + Password/private key cannot be empty Поле с паролем/закрытым ключом не может быть пустым @@ -3516,7 +3656,7 @@ Thank you for staying with us! PageSetupWizardStart - + Let's get started Приступим @@ -4326,7 +4466,22 @@ Thank you for staying with us! Не удалось обработать покупку - + + No active subscription found + Активная подписка не найдена + + + + No purchased subscriptions found. Please purchase a subscription first + Платные подписки не найдены. Сначала оформите подписку + + + + This email address has already been used to activate a trial + Этот адрес электронной почты уже использовался для активации пробного периода + + + ErrorCode: %1. Код ошибки: %1. @@ -4426,37 +4581,37 @@ Thank you for staying with us! Превышен лимит разрешенных конфигураций для одной подписки - + QFile error: The file could not be opened Ошибка QFile: не удалось открыть файл - + QFile error: An error occurred when reading from the file Ошибка QFile: произошла ошибка при чтении из файла - + QFile error: The file could not be accessed Ошибка QFile: не удалось получить доступ к файлу - + QFile error: An unspecified error occurred Ошибка QFile: произошла неизвестная ошибка - + QFile error: A fatal error occurred Ошибка QFile: произошла фатальная ошибка - + QFile error: The operation was aborted Ошибка QFile: операция была прервана - + Internal error Внутренняя ошибка @@ -4985,7 +5140,17 @@ FileZilla или другие SFTP-клиенты, а также смонтир ServersListView - + + Subscription expired. Please renew + Подписка закончилась. Пожалуйста, продлите её + + + + Subscription expiring soon + Подписка скоро закончится + + + Unable change server while there is an active connection Невозможно изменить сервер во время активного соединения @@ -5007,12 +5172,17 @@ FileZilla или другие SFTP-клиенты, а также смонтир SettingsController - + + Can't open file: %1 + Невозможно открыть файл: %1 + + + All settings have been reset to default values Все настройки сброшены до значений по умолчанию - + Backup file is corrupted Файл резервной копии поврежден @@ -5065,6 +5235,29 @@ FileZilla или другие SFTP-клиенты, а также смонтир Экспорт завершен + + SubscriptionExpiredDrawer + + + Amnezia Premium subscription has expired + Подписка Amnezia Premium закончилась + + + + Renew to continue using VPN + Продлите подписку, чтобы продолжить использовать VPN + + + + Renew + Продлить + + + + Support + Поддержка + + SystemTrayNotificationHandler @@ -5098,6 +5291,14 @@ FileZilla или другие SFTP-клиенты, а также смонтир Закрыть + + TermsAndPrivacyText + + + By continuing, you agree to the <a href="%1" style="color: %3;">Terms of Use</a> and <a href="%2" style="color: %3;">Privacy Policy</a> + Продолжая, вы соглашаетесь с <a href="%1" style="color: %3;">Условиями использования</a> и <a href="%2" style="color: %3;">Политикой конфиденциальности</a> + + TextFieldWithHeaderType @@ -5173,12 +5374,12 @@ FileZilla или другие SFTP-клиенты, а также смонтир main2 - + Private key passphrase Парольная фраза для закрытого ключа - + Save Сохранить diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index bda3db97..d55a74db 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -661,7 +661,7 @@ bool ApiConfigsController::importPremiumFromAppStore(const QString &storeProduct int duplicateServerIndex = -1; errorCode = importServiceFromBilling(responseBody, isTestPurchase, duplicateServerIndex); if (errorCode == ErrorCode::ApiConfigAlreadyAdded) { - emit installServerFromApiFinished(tr("This subscription is already in the app."), duplicateServerIndex); + emit installServerFromApiFinished(tr("This subscription has already been added"), duplicateServerIndex); return true; } if (errorCode != ErrorCode::NoError) { @@ -669,7 +669,7 @@ bool ApiConfigsController::importPremiumFromAppStore(const QString &storeProduct return false; } emit installServerFromApiFinished( - tr("%1 was added to the app.").arg(m_apiServicesModel->getSelectedServiceName())); + tr("%1 has been added to the app").arg(m_apiServicesModel->getSelectedServiceName())); return true; #else Q_UNUSED(storeProductId); @@ -799,7 +799,7 @@ bool ApiConfigsController::restoreServiceFromAppStore() if (!hasInstalledConfig) { if (duplicateConfigAlreadyPresent) { - emit installServerFromApiFinished(tr("This subscription is already in the app."), duplicateServerIndex); + emit installServerFromApiFinished(tr("This subscription has already been added"), duplicateServerIndex); return true; } @@ -894,7 +894,7 @@ bool ApiConfigsController::importTrialFromGateway(const QString &email) ErrorCode errorCode = executeRequest(QString("%1v1/trial"), apiPayload, responseBody); if (errorCode != ErrorCode::NoError) { if (errorCode == ErrorCode::ApiTrialAlreadyUsedError) { - emit trialEmailError(tr("This email has already been used for trial activation. If you like the service, you can buy Premium.")); + emit trialEmailError(tr("This email address has already been used to activate a trial. If you like the service, you can upgrade to Premium")); return false; } emit errorOccurred(errorCode); diff --git a/client/ui/models/api/apiAccountInfoModel.cpp b/client/ui/models/api/apiAccountInfoModel.cpp index 4e37a98c..81112bb0 100644 --- a/client/ui/models/api/apiAccountInfoModel.cpp +++ b/client/ui/models/api/apiAccountInfoModel.cpp @@ -32,8 +32,9 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const return tr("Active"); } - return apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate) ? tr("

Inactive") - : tr("

Active"); + return apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate) + ? QStringLiteral("

%1").arg(tr("Inactive")) + : QStringLiteral("

%1").arg(tr("Active")); } case EndDateRole: { if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) { diff --git a/client/ui/models/api/apiBenefitsModel.cpp b/client/ui/models/api/apiBenefitsModel.cpp index 42b79c9b..2e645594 100644 --- a/client/ui/models/api/apiBenefitsModel.cpp +++ b/client/ui/models/api/apiBenefitsModel.cpp @@ -12,7 +12,7 @@ namespace configKey constexpr char title[] = "title"; constexpr char body[] = "body"; constexpr char icon[] = "icon"; - constexpr char accent[] = "accent"; + constexpr char link[] = "link"; } QString gatewayIconKeyToUrl(const QString &iconKey) @@ -62,8 +62,8 @@ QVariant ApiBenefitsModel::data(const QModelIndex &index, int role) const return item.title; case BodyRole: return item.body; - case AccentRole: - return item.accent; + case LinkRole: + return item.link; default: return {}; } @@ -75,7 +75,7 @@ QHash ApiBenefitsModel::roleNames() const { IconRole, "icon" }, { TitleRole, "title" }, { BodyRole, "body" }, - { AccentRole, "accent" }, + { LinkRole, "link" }, }; } @@ -90,7 +90,11 @@ void ApiBenefitsModel::updateModel(const QJsonArray &benefits) const QJsonObject benefitObject = benefitValue.toObject(); QString title = benefitObject.value(configKey::title).toString(); QString body = benefitObject.value(configKey::body).toString(); + const bool isLink = benefitObject.value(configKey::link).toBool(); const QString iconKey = benefitObject.value(configKey::icon).toString(); + if (isLink) { + body = body.trimmed(); + } if (title.isEmpty() && body.isEmpty()) { continue; } @@ -98,7 +102,7 @@ void ApiBenefitsModel::updateModel(const QJsonArray &benefits) item.icon = gatewayIconKeyToUrl(iconKey); item.title = std::move(title); item.body = std::move(body); - item.accent = benefitObject.value(configKey::accent).toBool(); + item.link = isLink; m_serviceBenefits.append(std::move(item)); } endResetModel(); diff --git a/client/ui/models/api/apiBenefitsModel.h b/client/ui/models/api/apiBenefitsModel.h index c6b8465f..6a09e08e 100644 --- a/client/ui/models/api/apiBenefitsModel.h +++ b/client/ui/models/api/apiBenefitsModel.h @@ -15,7 +15,7 @@ public: IconRole = Qt::UserRole + 1, TitleRole, BodyRole, - AccentRole + LinkRole }; Q_ENUM(Roles) @@ -34,7 +34,7 @@ private: QString icon; QString title; QString body; - bool accent = false; + bool link = false; }; QVector m_serviceBenefits; diff --git a/client/ui/qml/Components/BenefitRow.qml b/client/ui/qml/Components/BenefitRow.qml index 07b547f1..2870a712 100644 --- a/client/ui/qml/Components/BenefitRow.qml +++ b/client/ui/qml/Components/BenefitRow.qml @@ -11,7 +11,11 @@ RowLayout { property string iconSource: "" property string titleText: "" property string bodyText: "" - property bool accent: false + property bool link: false + + readonly property string bodyLineText: root.link && root.bodyText.length > 0 ? "@" + root.bodyText : root.bodyText + + readonly property bool bodyClickable: root.link && root.bodyText.length > 0 spacing: 12 @@ -43,22 +47,17 @@ RowLayout { LabelTextType { id: bodyLabel width: parent.width - text: root.bodyText - color: root.accent ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.mutedGray + text: root.bodyLineText + color: root.link ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.mutedGray font.pixelSize: 14 wrapMode: Text.Wrap } MouseArea { anchors.fill: bodyLabel - visible: root.accent && root.bodyText.length > 0 + visible: root.bodyClickable cursorShape: Qt.PointingHandCursor - onClicked: { - var t = root.bodyText.trim() - if (t.startsWith("@")) { - Qt.openUrlExternally("https://t.me/" + t.substring(1)) - } - } + onClicked: Qt.openUrlExternally("https://t.me/" + root.bodyText) } } } diff --git a/client/ui/qml/Components/BenefitsPanel.qml b/client/ui/qml/Components/BenefitsPanel.qml index bb1d3a23..c3469e4c 100644 --- a/client/ui/qml/Components/BenefitsPanel.qml +++ b/client/ui/qml/Components/BenefitsPanel.qml @@ -33,7 +33,7 @@ Rectangle { iconSource: model.icon titleText: model.title bodyText: model.body - accent: !!model.accent + link: !!model.link } } } diff --git a/client/ui/qml/Components/ServersListView.qml b/client/ui/qml/Components/ServersListView.qml index 54ef0c91..0cc5b091 100644 --- a/client/ui/qml/Components/ServersListView.qml +++ b/client/ui/qml/Components/ServersListView.qml @@ -68,7 +68,7 @@ ListViewType { text: name descriptionText: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon) - ? (isSubscriptionExpired ? qsTr("Subscription expired. Please renew.") : qsTr("Subscription expiring soon.")) + ? (isSubscriptionExpired ? qsTr("Subscription expired. Please renew") : qsTr("Subscription expiring soon")) : serverDescription descriptionColor: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon) ? (isSubscriptionExpired ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot) diff --git a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml index 0540dbfa..230bc7db 100644 --- a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml +++ b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml @@ -57,7 +57,7 @@ DrawerType2 { Layout.rightMargin: 16 Layout.leftMargin: 16 - text: qsTr("Renew your subscription to continue using VPN") + text: qsTr("Renew to continue using VPN") horizontalAlignment: Text.AlignLeft } diff --git a/client/ui/qml/Components/TermsAndPrivacyText.qml b/client/ui/qml/Components/TermsAndPrivacyText.qml index 5eb4a142..1fad3da6 100644 --- a/client/ui/qml/Components/TermsAndPrivacyText.qml +++ b/client/ui/qml/Components/TermsAndPrivacyText.qml @@ -17,6 +17,8 @@ ParagraphTextType { textFormat: Text.RichText color: AmneziaStyle.color.mutedGray font.pixelSize: 12 + lineHeight: 1.35 + lineHeightMode: Text.ProportionalHeight text: qsTr("By continuing, you agree to the Terms of Use and Privacy Policy") .arg(root.termsUrl) diff --git a/client/ui/qml/Controls2/CardWithIconsType.qml b/client/ui/qml/Controls2/CardWithIconsType.qml index 52cd31e0..827a3950 100644 --- a/client/ui/qml/Controls2/CardWithIconsType.qml +++ b/client/ui/qml/Controls2/CardWithIconsType.qml @@ -28,20 +28,20 @@ Button { property string leftImageSource - property real textOpacity: 1.0 - property alias focusItem: rightImage hoverEnabled: true clip: false + readonly property real cardTextOpacity: !enabled ? 1.0 : pressed ? 0.7 : hovered ? 0.8 : 1.0 + background: Rectangle { id: backgroundRect anchors.fill: parent radius: 16 - color: defaultColor + color: root.hovered && root.enabled ? root.hoveredColor : root.defaultColor Behavior on color { PropertyAnimation { duration: 200 } @@ -51,6 +51,7 @@ Button { contentItem: Item { id: contentRoot + z: 1 anchors.left: parent.left anchors.right: parent.right @@ -129,7 +130,7 @@ Button { Layout.topMargin: contentRoot.badgeVisible ? 0 : 16 Layout.bottomMargin: root.bodyText !== "" ? 0 : 16 - opacity: root.textOpacity + opacity: root.cardTextOpacity } CaptionTextType { @@ -138,13 +139,16 @@ Button { color: root.bodyTextColor textFormat: Text.RichText + onLinkActivated: function(link) { + Qt.openUrlExternally(link) + } Layout.fillWidth: true Layout.rightMargin: 16 Layout.leftMargin: 16 Layout.bottomMargin: root.footerText !== "" ? 0 : 8 - opacity: root.textOpacity + opacity: root.cardTextOpacity } ButtonTextType { @@ -159,7 +163,7 @@ Button { Layout.topMargin: 16 Layout.bottomMargin: 16 - opacity: root.textOpacity + opacity: root.cardTextOpacity } } @@ -184,7 +188,7 @@ Button { anchors.fill: parent radius: 12 - color: "transparent" + color: root.pressed ? rightImage.pressedColor : root.hovered ? rightImage.hoveredColor : rightImage.defaultColor Behavior on color { PropertyAnimation { duration: 200 } @@ -198,41 +202,4 @@ Button { } } } - - MouseArea { - anchors.fill: parent - - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - enabled: root.enabled - - onEntered: { - backgroundRect.color = root.hoveredColor - - if (rightImageSource) { - rightImageBackground.color = rightImage.hoveredColor - } - root.textOpacity = 0.8 - } - - onExited: { - backgroundRect.color = root.defaultColor - - if (rightImageSource) { - rightImageBackground.color = rightImage.defaultColor - } - root.textOpacity = 1 - } - - onPressedChanged: { - if (rightImageSource) { - rightImageBackground.color = pressed ? rightImage.pressedColor : entered ? rightImage.hoveredColor : rightImage.defaultColor - } - root.textOpacity = 0.7 - } - - onClicked: { - root.clicked() - } - } } diff --git a/client/ui/qml/Pages2/PageDevMenu.qml b/client/ui/qml/Pages2/PageDevMenu.qml index 81f44edb..c55c04b1 100644 --- a/client/ui/qml/Pages2/PageDevMenu.qml +++ b/client/ui/qml/Pages2/PageDevMenu.qml @@ -2,8 +2,6 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import SortFilterProxyModel 0.2 - import PageEnum 1.0 import Style 1.0 @@ -52,6 +50,8 @@ PageType { width: listView.width TextFieldWithHeaderType { + id: gatewayEndpointField + Layout.fillWidth: true Layout.topMargin: 16 Layout.rightMargin: 16 @@ -64,13 +64,25 @@ PageType { clickedFunc: function() { SettingsController.resetGatewayEndpoint() + gatewayEndpointField.textField.text = SettingsController.gatewayEndpoint } + } - textField.onEditingFinished: { - textField.text = textField.text.replace(/^\s+|\s+$/g, '') - if (textField.text !== SettingsController.gatewayEndpoint) { - SettingsController.gatewayEndpoint = textField.text + BasicButtonType { + id: saveButton + + Layout.fillWidth: true + Layout.margins: 16 + + text: qsTr("Save") + + clickedFunc: function() { + var trimmed = gatewayEndpointField.textField.text.replace(/^\s+|\s+$/g, '') + gatewayEndpointField.textField.text = trimmed + if (trimmed !== SettingsController.gatewayEndpoint) { + SettingsController.gatewayEndpoint = trimmed } + PageController.showNotificationMessage(qsTr("Settings saved")) } } } diff --git a/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml b/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml index a23eff4b..a37d0c37 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml @@ -99,7 +99,7 @@ PageType { wrapMode: Text.WordWrap color: AmneziaStyle.color.mutedGray font.pixelSize: 12 - text: qsTr("We will create an account for your trial subscription and send important subscription updates to this email.") + text: qsTr("We will create an account for your trial subscription and send important subscription updates to this email address") } } } diff --git a/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml b/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml index 061ef65a..97058784 100644 --- a/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml +++ b/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml @@ -278,7 +278,7 @@ PageType { id: amneziaVpn property string title: qsTr("VPN by Amnezia") - property string description: qsTr("The easiest way to connect to VPN") + property string description: qsTr("The easiest way to connect to the VPN") property string imageSource: "qrc:/images/controls/amnezia.svg" property bool featuredAmneziaConnection: true property bool isVisible: true diff --git a/client/ui/qml/Pages2/PageSetupWizardCredentials.qml b/client/ui/qml/Pages2/PageSetupWizardCredentials.qml index 8b67754d..f98afe28 100644 --- a/client/ui/qml/Pages2/PageSetupWizardCredentials.qml +++ b/client/ui/qml/Pages2/PageSetupWizardCredentials.qml @@ -94,7 +94,7 @@ PageType { 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.") + textString: qsTr("SSH key requirements: supported key types are ED25519 and RSA in PEM format. Paste the private key, including the BEGIN/END lines. If your key doesn’t work, generate a compatible one") } } From a75bd0cf5e49e09c2246f719d0f2ecb0bca2cdd5 Mon Sep 17 00:00:00 2001 From: lunardunno <126363523+lunardunno@users.noreply.github.com> Date: Mon, 13 Apr 2026 08:27:45 +0400 Subject: [PATCH 28/36] fix: set a fixed 3proxy ver 0.9.5 (#2468) --- client/server_scripts/socks5_proxy/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/server_scripts/socks5_proxy/Dockerfile b/client/server_scripts/socks5_proxy/Dockerfile index 7a38682f..5a651210 100644 --- a/client/server_scripts/socks5_proxy/Dockerfile +++ b/client/server_scripts/socks5_proxy/Dockerfile @@ -1,4 +1,4 @@ -FROM 3proxy/3proxy:latest +FROM 3proxy/3proxy:0.9.5 LABEL maintainer="AmneziaVPN" @@ -7,4 +7,4 @@ RUN echo -e "#!/bin/bash\ntail -f /dev/null" > /opt/amnezia/start.sh RUN chmod a+x /opt/amnezia/start.sh ENTRYPOINT [ "/bin/sh", "/opt/amnezia/start.sh" ] -CMD [ "" ] \ No newline at end of file +CMD [ "" ] From 92deee5f67e1f40bfdcf9bc8b64f8f84e29514c9 Mon Sep 17 00:00:00 2001 From: yp Date: Mon, 13 Apr 2026 15:06:08 +0300 Subject: [PATCH 29/36] fix: tun2socks auth settings (#2456) * add parser auth/pass & fix port * fix generateRandomHex * remove hardcore port ios * add generated random port * fix sin6_port * fixed inbound * add error message * add std::runtime_error & fixed random generator * remove loop --------- Co-authored-by: Yaumenau Pavel --- client/android/xray/src/main/kotlin/Xray.kt | 77 ++++++++++++- .../xray/src/main/kotlin/XrayConfig.kt | 14 +++ client/core/serialization/inbound.cpp | 109 +++++++++++++++++- client/core/serialization/serialization.h | 17 +++ .../ios/PacketTunnelProvider+Xray.swift | 109 +++++++++++++++++- client/protocols/xrayprotocol.cpp | 23 +++- client/protocols/xrayprotocol.h | 4 + 7 files changed, 341 insertions(+), 12 deletions(-) diff --git a/client/android/xray/src/main/kotlin/Xray.kt b/client/android/xray/src/main/kotlin/Xray.kt index 46200595..e2e73fab 100644 --- a/client/android/xray/src/main/kotlin/Xray.kt +++ b/client/android/xray/src/main/kotlin/Xray.kt @@ -4,6 +4,9 @@ import android.content.Context import android.net.VpnService.Builder import java.io.File import java.io.IOException +import java.net.InetAddress +import java.net.ServerSocket +import java.util.UUID import go.Seq import org.amnezia.vpn.protocol.BadConfigException import org.amnezia.vpn.protocol.Protocol @@ -19,11 +22,32 @@ import org.amnezia.vpn.util.Log import org.amnezia.vpn.util.net.InetNetwork import org.amnezia.vpn.util.net.ip import org.amnezia.vpn.util.net.parseInetAddress +import org.json.JSONArray import org.json.JSONObject private const val TAG = "Xray" private const val LIBXRAY_TAG = "libXray" +private fun findSocksInboundIndex(inbounds: JSONArray): Int { + for (i in 0 until inbounds.length()) { + val o = inbounds.optJSONObject(i) ?: continue + if (o.optString("protocol").equals("socks", ignoreCase = true)) { + return i + } + } + return -1 +} + +private fun acquireFreeLocalPort(): Int { + try { + ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).use { return it.localPort } + } catch (e: Exception) { + throw VpnStartException( + "Failed to acquire free TCP port on 127.0.0.1 for SOCKS inbound: ${e.message}" + ) + } +} + class Xray : Protocol() { private var isRunning: Boolean = false @@ -56,6 +80,10 @@ class Xray : Protocol() { val xrayJsonConfig = config.optJSONObject("xray_config_data") ?: config.optJSONObject("ssxray_config_data") ?: throw BadConfigException("config_data not found") + + // Inject SOCKS5 auth before starting xray. Re-uses existing credentials if present. + ensureInboundAuth(xrayJsonConfig) + val xrayConfig = parseConfig(config, xrayJsonConfig) (xrayJsonConfig.optJSONObject("log") ?: JSONObject().also { xrayJsonConfig.put("log", it) }) @@ -97,9 +125,22 @@ class Xray : Protocol() { if (it.isNotBlank()) setMtu(it.toInt()) } - val socksConfig = xrayJsonConfig.getJSONArray("inbounds")[0] as JSONObject + val inbounds = xrayJsonConfig.getJSONArray("inbounds") + val socksIdx = findSocksInboundIndex(inbounds) + if (socksIdx < 0) { + throw BadConfigException("socks inbound not found") + } + val socksConfig = inbounds.getJSONObject(socksIdx) socksConfig.getInt("port").let { setSocksPort(it) } + val socksSettings = socksConfig.optJSONObject("settings") + val accounts = socksSettings?.optJSONArray("accounts") + if (accounts != null && accounts.length() > 0) { + val account = accounts.getJSONObject(0) + setSocksUser(account.optString("user")) + setSocksPass(account.optString("pass")) + } + configSplitTunneling(config) configAppSplitTunneling(config) } @@ -162,9 +203,10 @@ class Xray : Protocol() { } private fun runTun2Socks(config: XrayConfig, fd: Int) { + val proxyUrl = "socks5://${config.socksUser}:${config.socksPass}@127.0.0.1:${config.socksPort}" val tun2SocksConfig = Tun2SocksConfig().apply { mtu = config.mtu.toLong() - proxy = "socks5://127.0.0.1:${config.socksPort}" + proxy = proxyUrl device = "fd://$fd" logLevel = "warn" } @@ -173,6 +215,37 @@ class Xray : Protocol() { } } + // Ensures SOCKS5 auth is present on the socks inbound settings. + // Re-uses existing credentials if already configured; otherwise generates random ones. + private fun ensureInboundAuth(xrayConfig: JSONObject) { + val inbounds = xrayConfig.optJSONArray("inbounds") ?: return + val socksIdx = findSocksInboundIndex(inbounds) + if (socksIdx < 0) return + + val inbound = inbounds.getJSONObject(socksIdx) + inbound.put("port", acquireFreeLocalPort()) + val settings = inbound.optJSONObject("settings") ?: JSONObject().also { inbound.put("settings", it) } + val accounts = settings.optJSONArray("accounts") + if (accounts != null && accounts.length() > 0) { + val account = accounts.getJSONObject(0) + if (account.optString("user").isNotEmpty() && account.optString("pass").isNotEmpty()) { + // Ensure auth mode is enforced even for imported configs that had accounts + // but auth: "noauth" (or no auth field). + settings.put("auth", "password") + inbound.put("settings", settings) + inbounds.put(socksIdx, inbound) + return + } + } + + val user = UUID.randomUUID().toString().replace("-", "").substring(0, 16) + val pass = UUID.randomUUID().toString().replace("-", "") + settings.put("auth", "password") + settings.put("accounts", JSONArray().put(JSONObject().put("user", user).put("pass", pass))) + inbound.put("settings", settings) + inbounds.put(socksIdx, inbound) + } + companion object { val instance: Xray by lazy { Xray() } } diff --git a/client/android/xray/src/main/kotlin/XrayConfig.kt b/client/android/xray/src/main/kotlin/XrayConfig.kt index 821a1c2f..d4c00f0f 100644 --- a/client/android/xray/src/main/kotlin/XrayConfig.kt +++ b/client/android/xray/src/main/kotlin/XrayConfig.kt @@ -9,12 +9,16 @@ private const val XRAY_DEFAULT_MAX_MEMORY: Long = 50 shl 20 // 50 MB class XrayConfig protected constructor( protocolConfigBuilder: ProtocolConfig.Builder, val socksPort: Int, + val socksUser: String, + val socksPass: String, val maxMemory: Long, ) : ProtocolConfig(protocolConfigBuilder) { protected constructor(builder: Builder) : this( builder, builder.socksPort, + builder.socksUser, + builder.socksPass, builder.maxMemory ) @@ -22,6 +26,12 @@ class XrayConfig protected constructor( internal var socksPort: Int = 0 private set + internal var socksUser: String = "" + private set + + internal var socksPass: String = "" + private set + internal var maxMemory: Long = XRAY_DEFAULT_MAX_MEMORY private set @@ -29,6 +39,10 @@ class XrayConfig protected constructor( fun setSocksPort(port: Int) = apply { socksPort = port } + fun setSocksUser(user: String) = apply { socksUser = user } + + fun setSocksPass(pass: String) = apply { socksPass = pass } + fun setMaxMemory(maxMemory: Long) = apply { this.maxMemory = maxMemory } override fun build(): XrayConfig = configBuild().run { XrayConfig(this@Builder) } diff --git a/client/core/serialization/inbound.cpp b/client/core/serialization/inbound.cpp index 35eeb533..2e16e1ab 100644 --- a/client/core/serialization/inbound.cpp +++ b/client/core/serialization/inbound.cpp @@ -1,6 +1,11 @@ #include +#include #include #include +#include +#include +#include +#include #include "3rd/QJsonStruct/QJsonIO.hpp" #include "transfer.h" #include "serialization.h" @@ -14,25 +19,125 @@ namespace amnezia::serialization::inbounds // "port": 10808, // "protocol": "socks", // "settings": { +// "auth": "password", +// "accounts": [{"user": "...", "pass": "..."}], // "udp": true // } // } //], const static QString listen = "127.0.0.1"; -const static int port = 10808; +const static int defaultPort = 10808; const static QString protocol = "socks"; +static int indexOfSocksInbound(const QJsonArray &inbounds) +{ + for (int i = 0; i < inbounds.size(); ++i) { + const QString p = inbounds.at(i).toObject().value(QLatin1String("protocol")).toString(); + if (p.compare(QLatin1String("socks"), Qt::CaseInsensitive) == 0) + return i; + } + return -1; +} + +// Ask the OS for a free TCP port on loopback (same stack as inbound "listen": "127.0.0.1"). +static int acquireFreeLocalPort() +{ + QTcpServer probe; + if (!probe.listen(QHostAddress(QStringLiteral("127.0.0.1")), 0)) { + throw std::runtime_error( + "Failed to bind a local TCP port on 127.0.0.1 for SOCKS inbound " + "(QTcpServer::listen failed; possible permission or OS network error)."); + } + return static_cast(probe.serverPort()); +} + +// Generates a hex string of `byteCount` random bytes (URL-safe, no special chars). +static QString generateRandomHex(int byteCount) +{ + if (byteCount <= 0) + return {}; + // fillRange writes full quint32 words; size the buffer to a multiple of 4 bytes to avoid + // overrunning a short buffer when byteCount is not divisible by 4. + const int numUint32 = (byteCount + int(sizeof(quint32)) - 1) / int(sizeof(quint32)); + QByteArray buf(numUint32 * int(sizeof(quint32)), '\0'); + QRandomGenerator::system()->fillRange(reinterpret_cast(buf.data()), numUint32); + return QString::fromLatin1(buf.left(byteCount).toHex()); +} + QJsonObject GenerateInboundEntry() { QJsonObject root; QJsonIO::SetValue(root, listen, "listen"); - QJsonIO::SetValue(root, port, "port"); + QJsonIO::SetValue(root, defaultPort, "port"); QJsonIO::SetValue(root, protocol, "protocol"); QJsonIO::SetValue(root, true, "settings", "udp"); return root; } +InboundCredentials GetInboundCredentials(const QJsonObject &xrayConfig) +{ + InboundCredentials creds; + creds.port = defaultPort; + + const QJsonArray inbounds = xrayConfig.value("inbounds").toArray(); + const int socksIdx = indexOfSocksInbound(inbounds); + if (socksIdx < 0) + return creds; + + const QJsonObject inbound = inbounds.at(socksIdx).toObject(); + creds.port = inbound.value("port").toInt(defaultPort); + + const QJsonObject settings = inbound.value("settings").toObject(); + const QJsonArray accounts = settings.value("accounts").toArray(); + if (accounts.isEmpty()) + return creds; + + const QJsonObject account = accounts.first().toObject(); + creds.username = account.value("user").toString(); + creds.password = account.value("pass").toString(); + return creds; +} + +InboundCredentials EnsureInboundAuth(QJsonObject &xrayConfig) +{ + QJsonArray inbounds = xrayConfig.value("inbounds").toArray(); + const int socksIdx = indexOfSocksInbound(inbounds); + if (socksIdx < 0) + return GetInboundCredentials(xrayConfig); // no SOCKS inbound to patch + + QJsonObject inbound = inbounds.at(socksIdx).toObject(); + InboundCredentials creds; + creds.port = acquireFreeLocalPort(); + inbound["port"] = creds.port; + + QJsonObject settings = inbound.value("settings").toObject(); + const QJsonArray accounts = settings.value("accounts").toArray(); + if (!accounts.isEmpty()) { + const QJsonObject account = accounts.first().toObject(); + creds.username = account.value("user").toString(); + creds.password = account.value("pass").toString(); + } + + if (creds.username.isEmpty() || creds.password.isEmpty()) { + // Generate fresh credentials for this session (never persisted) + creds.username = generateRandomHex(8); // 16 hex chars + creds.password = generateRandomHex(16); // 32 hex chars + QJsonObject account; + account["user"] = creds.username; + account["pass"] = creds.password; + settings["accounts"] = QJsonArray{ account }; + } + + // Always ensure auth mode is enforced, even for imported configs that had + // accounts but auth: "noauth" (or no auth field at all). + settings["auth"] = QStringLiteral("password"); + inbound["settings"] = settings; + inbounds[socksIdx] = inbound; + xrayConfig["inbounds"] = inbounds; + + return creds; +} } // namespace amnezia::serialization::inbounds diff --git a/client/core/serialization/serialization.h b/client/core/serialization/serialization.h index ec5f4f5c..d311ec33 100644 --- a/client/core/serialization/serialization.h +++ b/client/core/serialization/serialization.h @@ -60,7 +60,24 @@ namespace amnezia::serialization namespace inbounds { + struct InboundCredentials { + QString username; + QString password; + int port; + }; + QJsonObject GenerateInboundEntry(); + + // Reads existing SOCKS5 auth from the first inbound with protocol "socks" + // (.settings.accounts[0]). Returns empty username/password if none. + InboundCredentials GetInboundCredentials(const QJsonObject &xrayConfig); + + // Ensures SOCKS5 auth is present on the inbound whose protocol is "socks". + // Re-uses existing credentials if already set; otherwise generates random ones + // and writes them into the config. Assigns a free loopback TCP port each session + // (OS-assigned). Throws std::runtime_error if a SOCKS inbound exists but binding + // a local port on 127.0.0.1 fails (e.g. permissions or OS error). + InboundCredentials EnsureInboundAuth(QJsonObject &xrayConfig); } } diff --git a/client/platforms/ios/PacketTunnelProvider+Xray.swift b/client/platforms/ios/PacketTunnelProvider+Xray.swift index 4d3d723c..0b46b5ce 100644 --- a/client/platforms/ios/PacketTunnelProvider+Xray.swift +++ b/client/platforms/ios/PacketTunnelProvider+Xray.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import NetworkExtension @@ -6,6 +7,7 @@ enum XrayErrors: Error { case xrayConfigIsWrong case cantSaveXrayConfig case cantParseListenAndPort + case cantAcquireLocalPort case cantSaveHevSocksConfig } @@ -21,6 +23,42 @@ extension Constants { } extension PacketTunnelProvider { + /// TCP port chosen by the OS on IPv6 loopback (::1), matching inbound listen address. + private func acquireFreeLocalPort() throws -> Int { + let fd = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP) + guard fd != -1 else { + throw XrayErrors.cantAcquireLocalPort + } + defer { close(fd) } + var reuse: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + var addr = sockaddr_in6() + addr.sin6_len = UInt8(MemoryLayout.size) + addr.sin6_family = sa_family_t(AF_INET6) + addr.sin6_port = in_port_t(0).bigEndian + addr.sin6_addr = in6addr_loopback + addr.sin6_scope_id = 0 + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { p in + bind(fd, p, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + throw XrayErrors.cantAcquireLocalPort + } + var bound = sockaddr_in6() + var len = socklen_t(MemoryLayout.size) + let gr = withUnsafeMutablePointer(to: &bound) { p in + p.withMemoryRebound(to: sockaddr.self, capacity: 1) { bp in + getsockname(fd, bp, &len) + } + } + guard gr == 0 else { + throw XrayErrors.cantAcquireLocalPort + } + return Int(bound.sin6_port.byteSwapped) + } + private func applyXraySplitTunnel(_ xrayConfig: XrayConfig, settings: NEPacketTunnelNetworkSettings) { guard let splitTunnelType = xrayConfig.splitTunnelType else { @@ -129,14 +167,11 @@ extension PacketTunnelProvider { return } - let port = 10808 + let port = try acquireFreeLocalPort() let address = "::1" - if var inboundsArray = jsonDict["inbounds"] as? [[String: Any]], !inboundsArray.isEmpty { - inboundsArray[0]["port"] = port - inboundsArray[0]["listen"] = address - jsonDict["inbounds"] = inboundsArray - } + // Extract existing SOCKS5 credentials or generate new ones per session. + let socksCredentials = ensureInboundAuth(jsonDict: &jsonDict, port: port, address: address) let updatedData = try JSONSerialization.data(withJSONObject: jsonDict, options: []) @@ -159,6 +194,8 @@ extension PacketTunnelProvider { self?.setupAndRunTun2socks(configData: updatedData, address: address, port: port, + username: socksCredentials.username, + password: socksCredentials.password, completionHandler: completionHandler) } } @@ -183,6 +220,62 @@ extension PacketTunnelProvider { } } + private struct SocksCredentials { + let username: String + let password: String + } + + private func indexOfSocksInbound(in inboundsArray: [[String: Any]]) -> Int? { + for (i, inbound) in inboundsArray.enumerated() { + guard let proto = inbound["protocol"] as? String else { continue } + if proto.caseInsensitiveCompare("socks") == .orderedSame { + return i + } + } + return nil + } + + // Returns existing SOCKS5 credentials from the inbound config, or generates and injects + // new random ones. Also sets port and address on the socks inbound entry. + private func ensureInboundAuth(jsonDict: inout [String: Any], port: Int, address: String) -> SocksCredentials { + var inboundsArray = jsonDict["inbounds"] as? [[String: Any]] ?? [] + + if let socksIdx = indexOfSocksInbound(in: inboundsArray) { + var inbound = inboundsArray[socksIdx] + inbound["port"] = port + inbound["listen"] = address + + var settings = inbound["settings"] as? [String: Any] ?? [:] + if let accounts = settings["accounts"] as? [[String: Any]], + let first = accounts.first, + let user = first["user"] as? String, !user.isEmpty, + let pass = first["pass"] as? String, !pass.isEmpty { + // Re-use existing credentials, but always enforce auth mode in case the + // imported config had accounts but auth: "noauth" (or no auth field). + settings["auth"] = "password" + inbound["settings"] = settings + inboundsArray[socksIdx] = inbound + jsonDict["inbounds"] = inboundsArray + return SocksCredentials(username: user, password: pass) + } + + // Generate new random credentials for this session + let user = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(16) + let pass = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + settings["auth"] = "password" + settings["accounts"] = [["user": String(user), "pass": pass]] + inbound["settings"] = settings + inboundsArray[socksIdx] = inbound + jsonDict["inbounds"] = inboundsArray + return SocksCredentials(username: String(user), password: pass) + } + + // Fallback: no socks inbound — generate credentials but can't inject + let user = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(16) + let pass = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + return SocksCredentials(username: String(user), password: pass) + } + private func setupAndStartXray(configData: Data, completionHandler: @escaping (Error?) -> Void) { let path = Constants.cachesDirectory.appendingPathComponent("config.json", isDirectory: false).path @@ -214,6 +307,8 @@ extension PacketTunnelProvider { private func setupAndRunTun2socks(configData: Data, address: String, port: Int, + username: String, + password: String, completionHandler: @escaping (Error?) -> Void) { let config = """ tunnel: @@ -221,6 +316,8 @@ extension PacketTunnelProvider { socks5: port: \(port) address: \(address) + username: \(username) + password: \(password) udp: 'udp' misc: task-stack-size: 20480 diff --git a/client/protocols/xrayprotocol.cpp b/client/protocols/xrayprotocol.cpp index 50bf829a..016501ad 100755 --- a/client/protocols/xrayprotocol.cpp +++ b/client/protocols/xrayprotocol.cpp @@ -1,6 +1,7 @@ #include "xrayprotocol.h" #include "core/ipcclient.h" +#include "core/serialization/serialization.h" #include "ipc.h" #include "utilities.h" #include "core/networkUtilities.h" @@ -14,6 +15,8 @@ #include #include +#include + #ifdef Q_OS_MACOS static const QString tunName = "utun22"; #else @@ -53,6 +56,19 @@ ErrorCode XrayProtocol::start() { qDebug() << "XrayProtocol::start()"; + // Inject SOCKS5 auth into the inbound before starting xray. + // Re-uses existing credentials if the config already has them (e.g. imported config). + amnezia::serialization::inbounds::InboundCredentials creds; + try { + creds = amnezia::serialization::inbounds::EnsureInboundAuth(m_xrayConfig); + } catch (const std::exception &e) { + qCritical() << "EnsureInboundAuth failed:" << e.what(); + return ErrorCode::InternalError; + } + m_socksUser = creds.username; + m_socksPassword = creds.password; + m_socksPort = creds.port; + return IpcClient::withInterface([&](QSharedPointer iface) { auto xrayStart = iface->xrayStart(QJsonDocument(m_xrayConfig).toJson()); if (!xrayStart.waitForFinished() || !xrayStart.returnValue()) { @@ -121,8 +137,11 @@ ErrorCode XrayProtocol::startTun2Socks() return ErrorCode::AmneziaServiceConnectionFailed; } + const QString proxyUrl = QString("socks5://%1:%2@127.0.0.1:%3") + .arg(m_socksUser, m_socksPassword, QString::number(m_socksPort)); + m_tun2socksProcess->setProgram(PermittedProcess::Tun2Socks); - m_tun2socksProcess->setArguments({"-device", QString("tun://%1").arg(tunName), "-proxy", "socks5://127.0.0.1:10808" }); + m_tun2socksProcess->setArguments({"-device", QString("tun://%1").arg(tunName), "-proxy", proxyUrl}); connect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, [this]() { auto readAllStandardOutput = m_tun2socksProcess->readAllStandardOutput(); @@ -136,7 +155,7 @@ ErrorCode XrayProtocol::startTun2Socks() if (!line.contains("[TCP]") && !line.contains("[UDP]")) qDebug() << "[tun2socks]:" << line; - if (line.contains("[STACK] tun://") && line.contains("<-> socks5://127.0.0.1")) { + if (line.contains("[STACK] tun://") && line.contains("<-> socks5://")) { disconnect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, nullptr); if (ErrorCode res = setupRouting(); res != ErrorCode::NoError) { diff --git a/client/protocols/xrayprotocol.h b/client/protocols/xrayprotocol.h index bccb844a..0abd237e 100644 --- a/client/protocols/xrayprotocol.h +++ b/client/protocols/xrayprotocol.h @@ -26,6 +26,10 @@ private: QList m_dnsServers; QString m_remoteAddress; + QString m_socksUser; + QString m_socksPassword; + int m_socksPort = 10808; + QSharedPointer m_tun2socksProcess; }; From ebe3a5dac6ae7101cb7e9520903ce92a0d1ed32e Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Tue, 14 Apr 2026 06:10:41 +0300 Subject: [PATCH 30/36] fix: add linux reconnection (#2415) * fix: add linux reconnection * fix: Dbus error, fix race conditional * fix: improve reeconnection * fix: add dns load/unload * feat: catch state changed via check gateway * revert: restore linuxfirewall.cpp * fix: restore reconnect time * fix: add NM_STATE_DISABLED and check getGatewayAndIface more carefully * fix: reconnect * fix: revert wireguardutilslinux * fix: revert --- .../platforms/linux/daemon/linuxroutemonitor.cpp | 7 ++++++- client/platforms/linux/linuxnetworkwatcher.cpp | 3 +++ .../platforms/linux/linuxnetworkwatcherworker.cpp | 14 ++++++++------ client/platforms/linux/linuxnetworkwatcherworker.h | 1 + 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/client/platforms/linux/daemon/linuxroutemonitor.cpp b/client/platforms/linux/daemon/linuxroutemonitor.cpp index 39926f23..eaf5bcd8 100644 --- a/client/platforms/linux/daemon/linuxroutemonitor.cpp +++ b/client/platforms/linux/daemon/linuxroutemonitor.cpp @@ -164,8 +164,13 @@ bool LinuxRouteMonitor::rtmSendRoute(int action, int flags, int type, } if (rtm->rtm_type == RTN_THROW) { + QString gateway = NetworkUtilities::getGatewayAndIface().first; + if (gateway.isEmpty()) { + logger.warning() << "No default gateway available, skipping exclusion route"; + return false; + } struct in_addr ip4; - inet_pton(AF_INET, NetworkUtilities::getGatewayAndIface().first.toUtf8(), &ip4); + inet_pton(AF_INET, gateway.toUtf8(), &ip4); nlmsg_append_attr(nlmsg, sizeof(buf), RTA_GATEWAY, &ip4, sizeof(ip4)); nlmsg_append_attr32(nlmsg, sizeof(buf), RTA_PRIORITY, 0); rtm->rtm_type = RTN_UNICAST; diff --git a/client/platforms/linux/linuxnetworkwatcher.cpp b/client/platforms/linux/linuxnetworkwatcher.cpp index 11b31fa5..b27eae83 100644 --- a/client/platforms/linux/linuxnetworkwatcher.cpp +++ b/client/platforms/linux/linuxnetworkwatcher.cpp @@ -44,6 +44,9 @@ void LinuxNetworkWatcher::initialize() { connect(m_worker, &LinuxNetworkWatcherWorker::wakeup, this, &NetworkWatcherImpl::wakeup); + connect(m_worker, &LinuxNetworkWatcherWorker::networkChanged, this, + [this]() { emit networkChanged(""); }); + // Let's wait a few seconds to allow the UI to be fully loaded and shown. // This is not strictly needed, but it's better for user experience because // it makes the UI faster to appear, plus it gives a bit of delay between the diff --git a/client/platforms/linux/linuxnetworkwatcherworker.cpp b/client/platforms/linux/linuxnetworkwatcherworker.cpp index f7cce6bb..ca3f7c1b 100644 --- a/client/platforms/linux/linuxnetworkwatcherworker.cpp +++ b/client/platforms/linux/linuxnetworkwatcherworker.cpp @@ -37,6 +37,7 @@ enum NMState { NM_STATE_UNKNOWN = 0, NM_STATE_ASLEEP = 10, + NM_STATE_DISABLED = 10, NM_STATE_DISCONNECTED = 20, NM_STATE_DISCONNECTING = 30, NM_STATE_CONNECTING = 40, @@ -199,10 +200,11 @@ void LinuxNetworkWatcherWorker::checkDevices() { void LinuxNetworkWatcherWorker::NMStateChanged(quint32 state) { - if (state == NM_STATE_ASLEEP) { - emit wakeup(); - } - - logger.debug() << "NMStateChanged " << state; -} + logger.debug() << "NMStateChanged " << state; + if (state == NM_STATE_ASLEEP || state == NM_STATE_DISABLED) { + emit wakeup(); + } else if (state == NM_STATE_CONNECTED_GLOBAL) { + emit networkChanged(); + } +} \ No newline at end of file diff --git a/client/platforms/linux/linuxnetworkwatcherworker.h b/client/platforms/linux/linuxnetworkwatcherworker.h index 7c5ae540..8dead74d 100644 --- a/client/platforms/linux/linuxnetworkwatcherworker.h +++ b/client/platforms/linux/linuxnetworkwatcherworker.h @@ -24,6 +24,7 @@ class LinuxNetworkWatcherWorker final : public QObject { signals: void unsecuredNetwork(const QString& networkName, const QString& networkId); void wakeup(); + void networkChanged(); public slots: void initialize(); From 4c18ceaa5098bae15a79cfb1f5c980a0eac49a98 Mon Sep 17 00:00:00 2001 From: vkamn Date: Tue, 14 Apr 2026 16:27:46 +0800 Subject: [PATCH 31/36] chore: minor fixes (#2477) --- client/ui/models/api/apiServicesModel.cpp | 8 ++++++++ client/ui/models/api/apiServicesModel.h | 2 ++ client/ui/qml/Pages2/PageSetupWizardApiServicesList.qml | 3 +++ 3 files changed, 13 insertions(+) diff --git a/client/ui/models/api/apiServicesModel.cpp b/client/ui/models/api/apiServicesModel.cpp index d309afd8..9f16edca 100644 --- a/client/ui/models/api/apiServicesModel.cpp +++ b/client/ui/models/api/apiServicesModel.cpp @@ -91,6 +91,12 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const } return true; } + case IsPremiumRole: { + return serviceType == serviceType::amneziaPremium; + } + case HasSubscriptionPlansRole: { + return !apiServiceData.subscriptionPlansJson.isEmpty(); + } case PriceRole: { return apiServiceData.minPriceLabel; } @@ -233,6 +239,8 @@ QHash ApiServicesModel::roleNames() const roles[CardDescriptionRole] = "cardDescription"; roles[ServiceDescriptionRole] = "serviceDescription"; roles[IsServiceAvailableRole] = "isServiceAvailable"; + roles[IsPremiumRole] = "isPremium"; + roles[HasSubscriptionPlansRole] = "hasSubscriptionPlans"; roles[PriceRole] = "price"; roles[EndDateRole] = "endDate"; roles[TermsOfUseUrlRole] = "termsOfUseUrl"; diff --git a/client/ui/models/api/apiServicesModel.h b/client/ui/models/api/apiServicesModel.h index 7bd5492d..eab7712b 100644 --- a/client/ui/models/api/apiServicesModel.h +++ b/client/ui/models/api/apiServicesModel.h @@ -54,6 +54,8 @@ public: CardDescriptionRole, ServiceDescriptionRole, IsServiceAvailableRole, + IsPremiumRole, + HasSubscriptionPlansRole, PriceRole, EndDateRole, TermsOfUseUrlRole, diff --git a/client/ui/qml/Pages2/PageSetupWizardApiServicesList.qml b/client/ui/qml/Pages2/PageSetupWizardApiServicesList.qml index 6146a697..6d8be466 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiServicesList.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiServicesList.qml @@ -67,8 +67,11 @@ PageType { } delegate: ColumnLayout { + property bool hideCard: isPremium && !hasSubscriptionPlans width: listView.width + visible: !hideCard + height: hideCard ? 0 : implicitHeight enabled: isServiceAvailable From cebfcc846e9ffd124daf2d7754b368e614494146 Mon Sep 17 00:00:00 2001 From: vkamn Date: Fri, 17 Apr 2026 15:01:24 +0800 Subject: [PATCH 32/36] feat: add renewal for external-premium (#2485) * feat: add renewal for external-premium * chore: bump version * chore: send subscription status for renewal link request --- CMakeLists.txt | 2 +- client/core/api/apiDefs.h | 3 ++- client/core/api/apiUtils.cpp | 10 +++------- client/core/api/apiUtils.h | 2 +- .../controllers/api/apiSettingsController.cpp | 14 +++++++++++++ client/ui/models/api/apiAccountInfoModel.cpp | 6 ++---- client/ui/models/api/apiAccountInfoModel.h | 1 + client/ui/models/servers_model.cpp | 4 ++++ client/ui/models/servers_model.h | 1 + .../Components/SubscriptionExpiredDrawer.qml | 20 +++++++++++-------- 10 files changed, 41 insertions(+), 22 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e9146dfd..13af39cf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR) set(PROJECT AmneziaVPN) -set(AMNEZIAVPN_VERSION 4.8.15.0) +set(AMNEZIAVPN_VERSION 4.8.15.1) project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION} DESCRIPTION "AmneziaVPN" diff --git a/client/core/api/apiDefs.h b/client/core/api/apiDefs.h index ebcdced3..2c2fd00f 100644 --- a/client/core/api/apiDefs.h +++ b/client/core/api/apiDefs.h @@ -10,7 +10,6 @@ namespace apiDefs AmneziaFreeV3, AmneziaPremiumV1, AmneziaPremiumV2, - AmneziaTrialV2, SelfHosted, ExternalPremium, ExternalTrial @@ -57,6 +56,7 @@ namespace apiDefs constexpr QLatin1String maxDeviceCount("max_device_count"); constexpr QLatin1String subscriptionEndDate("subscription_end_date"); constexpr QLatin1String subscriptionExpiredByServer("subscription_expired_by_server"); + constexpr QLatin1String subscriptionStatus("subscription_status"); constexpr QLatin1String subscription("subscription"); constexpr QLatin1String endDate("end_date"); constexpr QLatin1String issuedConfigs("issued_configs"); @@ -83,6 +83,7 @@ namespace apiDefs constexpr QLatin1String serviceInfo("service_info"); constexpr QLatin1String isAdVisible("is_ad_visible"); + constexpr QLatin1String isRenewalAvailable("is_renewal_available"); constexpr QLatin1String adHeader("ad_header"); constexpr QLatin1String adDescription("ad_description"); constexpr QLatin1String adEndpoint("ad_endpoint"); diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index 3d6cb335..5ed46d1f 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -101,7 +101,6 @@ 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"); @@ -111,8 +110,6 @@ apiDefs::ConfigType apiUtils::getConfigType(const QJsonObject &serverConfigObjec 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) { @@ -198,8 +195,7 @@ 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::AmneziaTrialV2, apiDefs::ConfigType::ExternalPremium, - apiDefs::ConfigType::ExternalTrial }; + apiDefs::ConfigType::ExternalPremium, apiDefs::ConfigType::ExternalTrial }; return premiumTypes.contains(getConfigType(serverConfigObject)); } @@ -244,8 +240,8 @@ QString apiUtils::getPremiumV1VpnKey(const QJsonObject &serverConfigObject) QString apiUtils::getPremiumV2VpnKey(const QJsonObject &serverConfigObject) { auto configType = apiUtils::getConfigType(serverConfigObject); - if (configType != apiDefs::ConfigType::AmneziaPremiumV2 && configType != apiDefs::ConfigType::AmneziaTrialV2 - && configType != apiDefs::ConfigType::ExternalPremium && configType != apiDefs::ConfigType::ExternalTrial) { + if (configType != apiDefs::ConfigType::AmneziaPremiumV2 && configType != apiDefs::ConfigType::ExternalPremium + && configType != apiDefs::ConfigType::ExternalTrial) { return {}; } diff --git a/client/core/api/apiUtils.h b/client/core/api/apiUtils.h index 819242a5..ba2a0103 100644 --- a/client/core/api/apiUtils.h +++ b/client/core/api/apiUtils.h @@ -13,7 +13,7 @@ namespace apiUtils bool isSubscriptionExpired(const QString &subscriptionEndDate); - bool isSubscriptionExpiringSoon(const QString &subscriptionEndDate, int withinDays = 10); + bool isSubscriptionExpiringSoon(const QString &subscriptionEndDate, int withinDays = 30); bool isPremiumServer(const QJsonObject &serverConfigObject); diff --git a/client/ui/controllers/api/apiSettingsController.cpp b/client/ui/controllers/api/apiSettingsController.cpp index d9643766..9ba2262f 100644 --- a/client/ui/controllers/api/apiSettingsController.cpp +++ b/client/ui/controllers/api/apiSettingsController.cpp @@ -23,6 +23,19 @@ namespace } const int requestTimeoutMsecs = 12 * 1000; // 12 secs + + QString getSubscriptionStatusForRenewal(const QSharedPointer &accountInfoModel) + { + if (!accountInfoModel.isNull() && accountInfoModel->data(QStringLiteral("isSubscriptionExpired")).toBool()) { + return QStringLiteral("expired"); + } + + if (!accountInfoModel.isNull() && accountInfoModel->data(QStringLiteral("isSubscriptionExpiringSoon")).toBool()) { + return QStringLiteral("expire_soon"); + } + + return QStringLiteral("active"); + } } ApiSettingsController::ApiSettingsController(const QSharedPointer &serversModel, @@ -105,6 +118,7 @@ void ApiSettingsController::getRenewalLink() apiPayload[configKey::authData] = authData; apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION); apiPayload[apiDefs::key::appLanguage] = m_settings->getAppLanguage().name().split("_").first(); + apiPayload[apiDefs::key::subscriptionStatus] = getSubscriptionStatusForRenewal(m_apiAccountInfoModel); auto future = gatewayController->postAsync(QString("%1v1/renewal_link"), apiPayload); future.then(this, [this, gatewayController](QPair result) { diff --git a/client/ui/models/api/apiAccountInfoModel.cpp b/client/ui/models/api/apiAccountInfoModel.cpp index 81112bb0..13b19cfc 100644 --- a/client/ui/models/api/apiAccountInfoModel.cpp +++ b/client/ui/models/api/apiAccountInfoModel.cpp @@ -54,14 +54,11 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const } case IsComponentVisibleRole: { return m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2 - || m_accountInfoData.configType == apiDefs::ConfigType::AmneziaTrialV2 || m_accountInfoData.configType == apiDefs::ConfigType::ExternalPremium || m_accountInfoData.configType == apiDefs::ConfigType::ExternalTrial; } case IsSubscriptionRenewalAvailableRole: { - return m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2 - || m_accountInfoData.configType == apiDefs::ConfigType::AmneziaTrialV2 - || m_accountInfoData.configType == apiDefs::ConfigType::ExternalTrial; + return m_accountInfoData.isRenewalAvailable; } case HasExpiredWorkerRole: { for (int i = 0; i < m_issuedConfigsInfo.size(); i++) { @@ -133,6 +130,7 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons accountInfoData.isInAppPurchase = apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false); accountInfoData.subscriptionDescription = accountInfoObject.value(apiDefs::key::subscriptionDescription).toString(); + accountInfoData.isRenewalAvailable = accountInfoObject.value(apiDefs::key::isRenewalAvailable).toBool(false); for (const auto &protocol : accountInfoObject.value(apiDefs::key::supportedProtocols).toArray()) { accountInfoData.supportedProtocols.push_back(protocol.toString()); diff --git a/client/ui/models/api/apiAccountInfoModel.h b/client/ui/models/api/apiAccountInfoModel.h index fec24cb2..b3f2270d 100644 --- a/client/ui/models/api/apiAccountInfoModel.h +++ b/client/ui/models/api/apiAccountInfoModel.h @@ -61,6 +61,7 @@ private: QString subscriptionDescription; bool isInAppPurchase = false; + bool isRenewalAvailable = false; }; AccountInfoData m_accountInfoData; diff --git a/client/ui/models/servers_model.cpp b/client/ui/models/servers_model.cpp index eadc12e3..510c1617 100644 --- a/client/ui/models/servers_model.cpp +++ b/client/ui/models/servers_model.cpp @@ -179,6 +179,9 @@ QVariant ServersModel::data(const QModelIndex &index, int role) const case AdEndpointRole: { return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::adEndpoint).toString(); } + case IsRenewalAvailableRole: { + return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::isRenewalAvailable).toBool(false); + } case IsSubscriptionExpiredRole: { if (configVersion != apiDefs::ConfigSource::AmneziaGateway) { return false; @@ -473,6 +476,7 @@ QHash ServersModel::roleNames() const roles[AdHeaderRole] = "adHeader"; roles[AdDescriptionRole] = "adDescription"; roles[AdEndpointRole] = "adEndpoint"; + roles[IsRenewalAvailableRole] = "isRenewalAvailable"; roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired"; roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon"; diff --git a/client/ui/models/servers_model.h b/client/ui/models/servers_model.h index 5264b35b..548711a2 100644 --- a/client/ui/models/servers_model.h +++ b/client/ui/models/servers_model.h @@ -51,6 +51,7 @@ public: AdHeaderRole, AdDescriptionRole, AdEndpointRole, + IsRenewalAvailableRole, IsSubscriptionExpiredRole, IsSubscriptionExpiringSoonRole, diff --git a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml index 230bc7db..0d29cf75 100644 --- a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml +++ b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml @@ -12,11 +12,10 @@ import "../Controls2/TextTypes" DrawerType2 { id: root - property bool isRenewalActionAvailable: false + property bool isRenewalAvailable: false onOpened: { - isRenewalActionAvailable = ApiAccountInfoModel.data("isSubscriptionRenewalAvailable") - && !ApiAccountInfoModel.data("isInAppPurchase") + isRenewalAvailable = ServersModel.getProcessedServerData("isRenewalAvailable") && !ApiAccountInfoModel.data("isInAppPurchase") } expandedStateContent: ColumnLayout { @@ -44,13 +43,13 @@ DrawerType2 { anchors.left: parent.left anchors.right: parent.right - text: qsTr("Amnezia Premium subscription has expired") + text: ServersModel.getProcessedServerData("name") + qsTr(" subscription has expired") horizontalAlignment: Text.AlignLeft } } ParagraphTextType { - visible: root.isRenewalActionAvailable + visible: root.isRenewalAvailable Layout.fillWidth: true Layout.topMargin: 8 @@ -62,7 +61,7 @@ DrawerType2 { } BasicButtonType { - visible: root.isRenewalActionAvailable + visible: root.isRenewalAvailable Layout.fillWidth: true Layout.topMargin: 16 @@ -96,8 +95,13 @@ DrawerType2 { text: qsTr("Support") clickedFunc: function() { - root.closeTriggered() - PageController.goToPage(PageEnum.PageSettingsApiSupport) + PageController.showBusyIndicator(true) + let result = ApiSettingsController.getAccountInfo(false) + PageController.showBusyIndicator(false) + if (result) { + root.closeTriggered() + PageController.goToPage(PageEnum.PageSettingsApiSupport) + } } } } From 8dbded1624db8acad58668d9c084b62d90847a77 Mon Sep 17 00:00:00 2001 From: vkamn Date: Fri, 17 Apr 2026 15:02:54 +0800 Subject: [PATCH 33/36] chore: remove ip from tunnel name for ios (#2489) --- client/platforms/ios/ios_controller.mm | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index bf79c590..d99752f4 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -218,16 +218,13 @@ bool IosController::connectVpn(amnezia::Proto proto, const QJsonObject& configur m_rawConfig = configuration; m_serverAddress = configuration.value(config_key::hostName).toString().toNSString(); + const QString serverDescription = configuration.value(config_key::description).toString().trimmed(); QString tunnelName; - if (configuration.value(config_key::description).toString().isEmpty()) { + if (serverDescription.isEmpty()) { + tunnelName = ProtocolProps::protoToString(proto); + } else { tunnelName = QString("%1 %2") - .arg(configuration.value(config_key::hostName).toString()) - .arg(ProtocolProps::protoToString(proto)); - } - else { - tunnelName = QString("%1 (%2) %3") - .arg(configuration.value(config_key::description).toString()) - .arg(configuration.value(config_key::hostName).toString()) + .arg(serverDescription) .arg(ProtocolProps::protoToString(proto)); } From 650c1c6ebb65da1205790908d4fdec8bc7a0ca7f Mon Sep 17 00:00:00 2001 From: vkamn Date: Mon, 20 Apr 2026 20:32:59 +0800 Subject: [PATCH 34/36] chore: bump version (#2502) --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 13af39cf..dd23b1fe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR) set(PROJECT AmneziaVPN) -set(AMNEZIAVPN_VERSION 4.8.15.1) +set(AMNEZIAVPN_VERSION 4.8.15.2) project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION} DESCRIPTION "AmneziaVPN" @@ -12,7 +12,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d") set(RELEASE_DATE "${CURRENT_DATE}") set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}) -set(APP_ANDROID_VERSION_CODE 2118) +set(APP_ANDROID_VERSION_CODE 2119) if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux") set(MZ_PLATFORM_NAME "linux") From f0da2b003f59cac5dd23326cb2589e16465dac07 Mon Sep 17 00:00:00 2001 From: vkamn Date: Thu, 23 Apr 2026 21:30:18 +0800 Subject: [PATCH 35/36] feat: add fallback proxy endpoint (#2518) --- .github/workflows/deploy.yml | 7 ++ .github/workflows/tag-deploy.yml | 1 + client/CMakeLists.txt | 1 + client/core/controllers/gatewayController.cpp | 79 +++++++++++-------- 4 files changed, 57 insertions(+), 31 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 99e24b3a..669552a7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,6 +17,7 @@ jobs: QIF_VERSION: 4.7 PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }} PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }} + FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }} DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} @@ -98,6 +99,7 @@ jobs: BUILD_ARCH: 64 PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }} PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }} + FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }} DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} @@ -204,6 +206,7 @@ jobs: CXX: c++ PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }} PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }} + FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }} DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} @@ -318,6 +321,7 @@ jobs: PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }} PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }} + FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }} DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} @@ -395,6 +399,7 @@ jobs: PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }} PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }} + FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }} DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} @@ -477,6 +482,7 @@ jobs: PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }} PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }} + FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }} DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} @@ -545,6 +551,7 @@ jobs: QT_MODULES: 'qtremoteobjects qt5compat qtimageformats qtshadertools' PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }} PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }} + FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }} DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} diff --git a/.github/workflows/tag-deploy.yml b/.github/workflows/tag-deploy.yml index 31c334bf..bed2862e 100644 --- a/.github/workflows/tag-deploy.yml +++ b/.github/workflows/tag-deploy.yml @@ -17,6 +17,7 @@ jobs: QIF_VERSION: 4.5 PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }} PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }} + FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }} DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }} DEV_AGW_ENDPOINT: ${{ secrets.DEV_AGW_ENDPOINT }} DEV_S3_ENDPOINT: ${{ secrets.DEV_S3_ENDPOINT }} diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 0f3ae7a0..1a7e9143 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -25,6 +25,7 @@ add_definitions(-DGIT_COMMIT_HASH="${GIT_COMMIT_HASH}") add_definitions(-DPROD_AGW_PUBLIC_KEY="$ENV{PROD_AGW_PUBLIC_KEY}") add_definitions(-DPROD_S3_ENDPOINT="$ENV{PROD_S3_ENDPOINT}") +add_definitions(-DFALLBACK_S3_ENDPOINT="$ENV{FALLBACK_S3_ENDPOINT}") add_definitions(-DDEV_AGW_PUBLIC_KEY="$ENV{DEV_AGW_PUBLIC_KEY}") add_definitions(-DDEV_AGW_ENDPOINT="$ENV{DEV_AGW_ENDPOINT}") diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 30b4c572..4631eac8 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -49,6 +49,8 @@ namespace constexpr int httpStatusCodeUnprocessableEntity = 422; constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); + + constexpr int proxyStorageRequestTimeoutMsecs = 3000; } GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs, @@ -284,23 +286,30 @@ QFuture> GatewayController::postAsync(const QString auto serviceType = apiPayload.value(apiDefs::key::serviceType).toString(""); auto userCountryCode = apiPayload.value(apiDefs::key::userCountryCode).toString(""); - QStringList baseUrls; + QStringList primaryBaseUrls; + QStringList fallbackBaseUrls; if (m_isDevEnvironment) { - baseUrls = QString(DEV_S3_ENDPOINT).split(", "); + primaryBaseUrls = QString(DEV_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); } else { - baseUrls = QString(PROD_S3_ENDPOINT).split(", "); + primaryBaseUrls = QString(PROD_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); + fallbackBaseUrls = QString(FALLBACK_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); } - QStringList proxyStorageUrls; - if (!serviceType.isEmpty()) { - for (const auto &baseUrl : baseUrls) { - QByteArray path = ("endpoints-" + serviceType + "-" + userCountryCode).toUtf8(); - proxyStorageUrls.push_back(baseUrl + path.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals) - + ".json"); + auto appendStorageUrls = [&serviceType, &userCountryCode](const QStringList &baseUrls, QStringList &target) { + if (!serviceType.isEmpty()) { + for (const auto &baseUrl : baseUrls) { + QByteArray path = ("endpoints-" + serviceType + "-" + userCountryCode).toUtf8(); + target.push_back(baseUrl + path.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals) + ".json"); + } } - } - for (const auto &baseUrl : baseUrls) - proxyStorageUrls.push_back(baseUrl + "endpoints.json"); + for (const auto &baseUrl : baseUrls) { + target.push_back(baseUrl + "endpoints.json"); + } + }; + + QStringList proxyStorageUrls; + appendStorageUrls(primaryBaseUrls, proxyStorageUrls); + appendStorageUrls(fallbackBaseUrls, proxyStorageUrls); getProxyUrlsAsync(proxyStorageUrls, 0, [this, encRequestData, endpoint, processResponse](const QStringList &proxyUrls) { getProxyUrlAsync(proxyUrls, 0, [this, encRequestData, endpoint, processResponse](const QString &proxyUrl) { @@ -327,40 +336,48 @@ QFuture> GatewayController::postAsync(const QString QStringList GatewayController::getProxyUrls(const QString &serviceType, const QString &userCountryCode) { QNetworkRequest request; - request.setTransferTimeout(m_requestTimeoutMsecs); + request.setTransferTimeout(proxyStorageRequestTimeoutMsecs); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); QEventLoop wait; QList sslErrors; QNetworkReply *reply; - QStringList baseUrls; + QStringList primaryBaseUrls; + QStringList fallbackBaseUrls; if (m_isDevEnvironment) { - baseUrls = QString(DEV_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); + primaryBaseUrls = QString(DEV_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); } else { - baseUrls = QString(PROD_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); - } - - if (baseUrls.empty()) { - qDebug() << "empty storage endpoint list"; - return {}; + primaryBaseUrls = QString(PROD_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); + fallbackBaseUrls = QString(FALLBACK_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); } std::random_device randomDevice; std::mt19937 generator(randomDevice()); - std::shuffle(baseUrls.begin(), baseUrls.end(), generator); + std::shuffle(primaryBaseUrls.begin(), primaryBaseUrls.end(), generator); + std::shuffle(fallbackBaseUrls.begin(), fallbackBaseUrls.end(), generator); QByteArray key = m_isDevEnvironment ? DEV_AGW_PUBLIC_KEY : PROD_AGW_PUBLIC_KEY; - QStringList proxyStorageUrls; - if (!serviceType.isEmpty()) { - for (const auto &baseUrl : baseUrls) { - QByteArray path = ("endpoints-" + serviceType + "-" + userCountryCode).toUtf8(); - proxyStorageUrls.push_back(baseUrl + path.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals) + ".json"); + auto appendStorageUrls = [&serviceType, &userCountryCode](const QStringList &baseUrls, QStringList &target) { + if (!serviceType.isEmpty()) { + for (const auto &baseUrl : baseUrls) { + QByteArray path = ("endpoints-" + serviceType + "-" + userCountryCode).toUtf8(); + target.push_back(baseUrl + path.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals) + ".json"); + } } - } - for (const auto &baseUrl : baseUrls) { - proxyStorageUrls.push_back(baseUrl + "endpoints.json"); + for (const auto &baseUrl : baseUrls) { + target.push_back(baseUrl + "endpoints.json"); + } + }; + + QStringList proxyStorageUrls; + appendStorageUrls(primaryBaseUrls, proxyStorageUrls); + appendStorageUrls(fallbackBaseUrls, proxyStorageUrls); + + if (proxyStorageUrls.empty()) { + qDebug() << "empty storage endpoint list"; + return {}; } for (const auto &proxyStorageUrl : proxyStorageUrls) { @@ -562,7 +579,7 @@ void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, co } QNetworkRequest request; - request.setTransferTimeout(m_requestTimeoutMsecs); + request.setTransferTimeout(proxyStorageRequestTimeoutMsecs); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setUrl(proxyStorageUrls[currentProxyStorageIndex]); From 2edd7de41319c11184706729047979ffb679e18f Mon Sep 17 00:00:00 2001 From: vkamn Date: Mon, 27 Apr 2026 13:18:50 +0800 Subject: [PATCH 36/36] chore: minor fixes (#2524) * fix: fixed i5 empty check * fix: add check config format in extractConfigFromQr --- CMakeLists.txt | 4 ++-- client/ui/controllers/importController.cpp | 11 ++++++++++- client/ui/qml/Pages2/PageProtocolAwgSettings.qml | 1 + 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index dd23b1fe..96aed3ea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR) set(PROJECT AmneziaVPN) -set(AMNEZIAVPN_VERSION 4.8.15.2) +set(AMNEZIAVPN_VERSION 4.8.15.4) project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION} DESCRIPTION "AmneziaVPN" @@ -12,7 +12,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d") set(RELEASE_DATE "${CURRENT_DATE}") set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}) -set(APP_ANDROID_VERSION_CODE 2119) +set(APP_ANDROID_VERSION_CODE 2120) if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux") set(MZ_PLATFORM_NAME "linux") diff --git a/client/ui/controllers/importController.cpp b/client/ui/controllers/importController.cpp index 91d7ec3b..3708d0f6 100644 --- a/client/ui/controllers/importController.cpp +++ b/client/ui/controllers/importController.cpp @@ -217,6 +217,8 @@ bool ImportController::extractConfigFromData(QString data) bool ImportController::extractConfigFromQr(const QByteArray &data) { + m_configType = checkConfigFormat(QString::fromUtf8(data)); + QJsonObject dataObj = QJsonDocument::fromJson(data).object(); if (!dataObj.isEmpty()) { m_config = dataObj; @@ -226,10 +228,13 @@ bool ImportController::extractConfigFromQr(const QByteArray &data) QByteArray ba_uncompressed = qUncompress(data); if (!ba_uncompressed.isEmpty()) { m_config = QJsonDocument::fromJson(ba_uncompressed).object(); + if (m_config.isEmpty()) { + return false; + } + m_configType = checkConfigFormat(QString::fromUtf8(ba_uncompressed)); return true; } - m_configType = checkConfigFormat(data); if (m_configType == ConfigTypes::Invalid) { QByteArray ba = QByteArray::fromBase64(data, QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); QByteArray baUncompressed = qUncompress(ba); @@ -240,6 +245,10 @@ bool ImportController::extractConfigFromQr(const QByteArray &data) if (!ba.isEmpty()) { m_config = QJsonDocument::fromJson(ba).object(); + if (m_config.isEmpty()) { + return false; + } + m_configType = checkConfigFormat(QString::fromUtf8(ba)); return true; } } diff --git a/client/ui/qml/Pages2/PageProtocolAwgSettings.qml b/client/ui/qml/Pages2/PageProtocolAwgSettings.qml index 1e4bdcb3..f725b4b1 100644 --- a/client/ui/qml/Pages2/PageProtocolAwgSettings.qml +++ b/client/ui/qml/Pages2/PageProtocolAwgSettings.qml @@ -482,6 +482,7 @@ PageType { headerText: qsTr("I5 - Special junk 5") textField.text: serverSpecialJunk5 + checkEmptyText: false textField.onEditingFinished: { if (textField.text !== serverSpecialJunk5) {