diff --git a/CMakeLists.txt b/CMakeLists.txt index 11400bf73f..37dc0bedea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(PROJECT AmneziaVPN) -set(AMNEZIAVPN_VERSION 4.8.15.4) +set(AMNEZIAVPN_VERSION 4.9.0.2) set(QT_CREATOR_SKIP_PACKAGE_MANAGER_SETUP ON CACHE BOOL "" FORCE) set(CMAKE_PROJECT_TOP_LEVEL_INCLUDES diff --git a/client/amneziaApplication.cpp b/client/amneziaApplication.cpp index 008cc345d2..1fa2c6fae3 100644 --- a/client/amneziaApplication.cpp +++ b/client/amneziaApplication.cpp @@ -109,6 +109,9 @@ void AmneziaApplication::init() // install filter on main window if (auto win = qobject_cast(obj)) { win->installEventFilter(this); +#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) + win->setDefaultAlphaBuffer(true); +#endif #ifdef Q_OS_ANDROID QObject::connect(win, &QQuickWindow::sceneGraphError, [](QQuickWindow::SceneGraphError, const QString &msg) { diff --git a/client/android/res/drawable/ic_pairing_back.xml b/client/android/res/drawable/ic_pairing_back.xml new file mode 100644 index 0000000000..cb4ba65859 --- /dev/null +++ b/client/android/res/drawable/ic_pairing_back.xml @@ -0,0 +1,10 @@ + + + + diff --git a/client/android/res/drawable/torch_fab_bg.xml b/client/android/res/drawable/torch_fab_bg.xml new file mode 100644 index 0000000000..cab37f8ded --- /dev/null +++ b/client/android/res/drawable/torch_fab_bg.xml @@ -0,0 +1,5 @@ + + + + diff --git a/client/android/res/layout/camera_preview.xml b/client/android/res/layout/camera_preview.xml index 003abbb6e9..8f46c119ff 100644 --- a/client/android/res/layout/camera_preview.xml +++ b/client/android/res/layout/camera_preview.xml @@ -8,4 +8,75 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/android/res/values-ru/strings.xml b/client/android/res/values-ru/strings.xml index 5e35bba51f..c194f6254f 100644 --- a/client/android/res/values-ru/strings.xml +++ b/client/android/res/values-ru/strings.xml @@ -24,5 +24,13 @@ Для ΠΏΠΎΠΊΠ°Π·Π° ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠΉ Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΠΎ Π²ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ увСдомлСния Π² систСмных настройках ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ настройки ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠΉ + Доступ ΠΊ ΠΊΠ°ΠΌΠ΅Ρ€Π΅ + Π§Ρ‚ΠΎΠ±Ρ‹ ΠΎΡ‚ΡΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR-ΠΊΠΎΠ΄ для добавлСния устройства, Amnezia VPN Π½ΡƒΠΆΠ΅Π½ доступ ΠΊ ΠΊΠ°ΠΌΠ΅Ρ€Π΅. + ΠŸΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠΈΡ‚ΡŒ + Π€ΠΎΠ½Π°Ρ€ΠΈΠΊ + Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ устройство ΠΏΠΎ QR + ΠžΡ‚ΡΠΊΠ°Π½ΠΈΡ€ΡƒΠΉΡ‚Π΅ QR сСссии Π½Π° устройствС, ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠ΅ Ρ…ΠΎΡ‚ΠΈΡ‚Π΅ Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ. ΠŸΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ подписки Π±ΡƒΠ΄Π΅Ρ‚ ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€ΠΆΠ΄Π΅Π½ΠΈΠ΅. + Назад + ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, установитС ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ для просмотра Ρ„Π°ΠΉΠ»ΠΎΠ² \ No newline at end of file diff --git a/client/android/res/values/strings.xml b/client/android/res/values/strings.xml index bf8d76d1d8..52e37457fd 100644 --- a/client/android/res/values/strings.xml +++ b/client/android/res/values/strings.xml @@ -24,5 +24,13 @@ To show notifications, you must enable notifications in the system settings Open notification settings + Camera access + To scan a QR code for device pairing, Amnezia VPN needs access to the camera. + Continue + Flashlight + Add device via QR + Scan the session QR shown on the device you want to add. You will confirm before the subscription is sent. + Back + Please install a file management utility to browse files \ No newline at end of file diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index dca70ee5cf..20d7ca33e8 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -42,6 +42,9 @@ import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry import java.io.IOException import kotlin.LazyThreadSafetyMode.NONE import kotlin.coroutines.CoroutineContext @@ -73,12 +76,18 @@ private const val CHECK_VPN_PERMISSION_ACTION_CODE = 1 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 CHECK_CAMERA_PERMISSION_ACTION_CODE = 5 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" -class AmneziaActivity : QtActivity() { +class AmneziaActivity : QtActivity(), LifecycleOwner { + + private val lifecycleRegistry = LifecycleRegistry(this) + + override val lifecycle: Lifecycle + get() = lifecycleRegistry private lateinit var mainScope: CoroutineScope private val qtInitialized = CompletableDeferred() @@ -99,6 +108,8 @@ class AmneziaActivity : QtActivity() { private var pendingOpenFileUri: String? = null private var openFileDeliveryScheduled = false + private var lastPairingQrReaderStartUptimeMs: Long = 0L + private val vpnServiceEventHandler: Handler by lazy(NONE) { object : Handler(Looper.getMainLooper()) { override fun handleMessage(msg: Message) { @@ -205,6 +216,7 @@ class AmneziaActivity : QtActivity() { registerBroadcastReceivers() intent?.let(::processIntent) runBlocking { vpnProto = proto.await() } + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) } override fun onSaveInstanceState(outState: Bundle) { @@ -262,6 +274,7 @@ class AmneziaActivity : QtActivity() { override fun onStart() { super.onStart() + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) Log.d(TAG, "Start Amnezia activity") mainScope.launch { qtInitialized.await() @@ -285,6 +298,7 @@ class AmneziaActivity : QtActivity() { qtInitialized.await() QtAndroidController.onServiceDisconnected() } + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) super.onStop() } @@ -357,6 +371,7 @@ class AmneziaActivity : QtActivity() { if (qtInitialized.isCompleted) { QtAndroidController.onActivityPaused() } + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) super.onPause() isActivityResumed = false // Cancel all pending operations when activity pauses @@ -367,6 +382,7 @@ class AmneziaActivity : QtActivity() { override fun onResume() { super.onResume() + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) isActivityResumed = true Log.d(TAG, "Resume Amnezia activity") if (qtInitialized.isCompleted) { @@ -483,6 +499,7 @@ class AmneziaActivity : QtActivity() { unregisterBroadcastReceiver(notificationStateReceiver) notificationStateReceiver = null mainScope.cancel() + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) super.onDestroy() } @@ -880,6 +897,66 @@ class AmneziaActivity : QtActivity() { @SuppressLint("UnsupportedChromeOsCameraSystemFeature") fun isCameraPresent(): Boolean = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA) + @Suppress("unused") + fun isCameraPermissionGranted(): Boolean = + ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + + @Suppress("unused") + fun requestCameraPermissionForQrPairing() { + if (isCameraPermissionGranted()) { + mainScope.launch { + qtInitialized.await() + QtAndroidController.onCameraPermissionResult(true) + } + return + } + runOnUiThread { + AlertDialog.Builder(this) + .setTitle(R.string.cameraPermissionDialogTitle) + .setMessage(R.string.cameraPermissionDialogMessage) + .setNegativeButton(R.string.cancel) { _, _ -> + mainScope.launch { + qtInitialized.await() + QtAndroidController.onCameraPermissionResult(false) + } + } + .setPositiveButton(R.string.cameraPermissionContinue) { _, _ -> + requestPermission( + Manifest.permission.CAMERA, + CHECK_CAMERA_PERMISSION_ACTION_CODE, + PermissionRequestHandler( + onSuccess = { + mainScope.launch { + qtInitialized.await() + QtAndroidController.onCameraPermissionResult(true) + } + }, + onFail = { + mainScope.launch { + qtInitialized.await() + QtAndroidController.onCameraPermissionResult(false) + } + }, + onAny = {} + ) + ) + } + .show() + } + } + + @Suppress("unused") + fun openApplicationDetailsSettings() { + try { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + startActivity(this) + } + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "openApplicationDetailsSettings: $e") + } + } + @Suppress("unused") fun isOnTv(): Boolean = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) @@ -928,6 +1005,19 @@ class AmneziaActivity : QtActivity() { } } + @Suppress("unused") + fun startPairingQrCodeReader() { + val now = SystemClock.uptimeMillis() + if (now - lastPairingQrReaderStartUptimeMs < 1200L) { + return + } + lastPairingQrReaderStartUptimeMs = now + Intent(this, CameraActivity::class.java).also { + it.putExtra(CameraActivity.EXTRA_PAIRING_QR_CAMERA, true) + startActivity(it) + } + } + @Suppress("unused") fun setSaveLogs(enabled: Boolean) { Log.v(TAG, "Set save logs: $enabled") @@ -1179,6 +1269,7 @@ class AmneziaActivity : QtActivity() { CREATE_FILE_ACTION_CODE -> "CREATE_FILE" OPEN_FILE_ACTION_CODE -> "OPEN_FILE" CHECK_NOTIFICATION_PERMISSION_ACTION_CODE -> "CHECK_NOTIFICATION_PERMISSION" + CHECK_CAMERA_PERMISSION_ACTION_CODE -> "CHECK_CAMERA_PERMISSION" else -> actionCode.toString() } } diff --git a/client/android/src/org/amnezia/vpn/CameraActivity.kt b/client/android/src/org/amnezia/vpn/CameraActivity.kt index eddcca8b9d..504fd42ec9 100644 --- a/client/android/src/org/amnezia/vpn/CameraActivity.kt +++ b/client/android/src/org/amnezia/vpn/CameraActivity.kt @@ -2,47 +2,384 @@ package org.amnezia.vpn import android.Manifest import android.annotation.SuppressLint +import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Color +import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.view.MotionEvent.ACTION_DOWN import android.view.MotionEvent.ACTION_UP +import android.graphics.RectF +import android.view.Gravity +import android.view.View +import android.widget.FrameLayout import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.FocusMeteringAction import androidx.camera.core.FocusMeteringAction.FLAG_AE import androidx.camera.core.FocusMeteringAction.FLAG_AF import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.camera.view.TransformExperimental +import androidx.camera.view.transform.CoordinateTransform +import androidx.camera.view.transform.ImageProxyTransformFactory +import androidx.camera.view.transform.OutputTransform import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Observer +import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.BarcodeScannerOptions.Builder import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.barcode.ZoomSuggestionOptions import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage import org.amnezia.vpn.databinding.CameraPreviewBinding import org.amnezia.vpn.qt.QtAndroidController import org.amnezia.vpn.util.Log +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.roundToInt private const val TAG = "CameraActivity" +@OptIn(TransformExperimental::class) class CameraActivity : ComponentActivity() { + companion object { + const val EXTRA_PAIRING_QR_CAMERA = "org.amnezia.vpn.extra.PAIRING_QR_CAMERA" + } + private lateinit var viewBinding: CameraPreviewBinding - private lateinit var cameraProvider: ProcessCameraProvider + private var cameraProvider: ProcessCameraProvider? = null + private var boundCamera: Camera? = null + private var boundImageAnalysis: ImageAnalysis? = null + private var torchOn: Boolean = false + + private var imageAnalysisExecutor: ExecutorService? = null + + private val qrHandledOrClosing = AtomicBoolean(false) + + private var pairingQrDeliveredToQt = false + + private var pairingQrUserDismissedCamera = false + + private var barcodeScanner: BarcodeScanner? = null + + private val cachedPreviewOutputTransform = AtomicReference(null) + + private var previewTransformLayoutListener: View.OnLayoutChangeListener? = null + + private var previewStreamStateObserver: Observer? = null + + @Volatile + private var pairingGeomHeaderBottomPx = 0f + + @Volatile + private var pairingGeomStatusBarTopPx = 0f + + @Volatile + private var pairingGeomDensity = 1f @ExperimentalGetImage override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewBinding = CameraPreviewBinding.inflate(layoutInflater) setContentView(viewBinding.root) + viewBinding.viewFinder.scaleType = PreviewView.ScaleType.FILL_CENTER + + if (intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) { + WindowCompat.setDecorFitsSystemWindows(window, false) + val density = resources.displayMetrics.density + val padH = (8 * density).toInt() + val padTopBase = (28 * density).toInt() + val padBottom = (12 * density).toInt() + ViewCompat.setOnApplyWindowInsetsListener(viewBinding.pairingChrome) { v, windowInsets -> + val bars = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) + v.setPadding(padH, padTopBase + bars.top, (16 * density).toInt(), padBottom) + v.post { onPairingLayoutGeometryChanged() } + windowInsets + } + viewBinding.pairingScanOverlay.visibility = View.VISIBLE + viewBinding.pairingChrome.visibility = View.VISIBLE + viewBinding.root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + viewBinding.root.post { onPairingLayoutGeometryChanged() } + } + viewBinding.root.post { + onPairingLayoutGeometryChanged() + applyPairingTorchButtonChrome() + } + } + + viewBinding.pairingBack.setOnClickListener { releaseCameraAndFinish() } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + releaseCameraAndFinish() + } + } + ) + + viewBinding.torchButton.setOnClickListener { + torchOn = !torchOn + try { + boundCamera?.cameraControl?.enableTorch(torchOn) + } catch (e: Exception) { + Log.e(TAG, "Torch: $e") + } + if (intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) { + applyPairingTorchButtonChrome() + } + } checkPermissions(onSuccess = ::startCamera, onFail = ::finish) } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + if (!intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) { + return + } + if (!::viewBinding.isInitialized) { + return + } + cleanupCameraResources() + qrHandledOrClosing.set(false) + pairingQrDeliveredToQt = false + pairingQrUserDismissedCamera = false + torchOn = false + viewBinding.pairingScanOverlay.visibility = View.VISIBLE + viewBinding.pairingChrome.visibility = View.VISIBLE + viewBinding.root.post { + onPairingLayoutGeometryChanged() + applyPairingTorchButtonChrome() + } + checkPermissions(onSuccess = ::startCamera, onFail = ::finish) + } + + override fun onDestroy() { + cleanupCameraResources() + val pairing = intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false) + if (pairing && !pairingQrDeliveredToQt && !pairingQrUserDismissedCamera) { + try { + QtAndroidController.onPairingQrCameraClosed() + } catch (t: Throwable) { + Log.e(TAG, "onPairingQrCameraClosed: $t") + } + } + super.onDestroy() + } + + /** Idempotent: safe from back, successful decode, or process death. */ + private fun cleanupCameraResources() { + qrHandledOrClosing.set(true) + try { + boundImageAnalysis?.clearAnalyzer() + } catch (_: Exception) { + } + boundImageAnalysis = null + try { + barcodeScanner?.close() + } catch (_: Exception) { + } + barcodeScanner = null + try { + boundCamera?.cameraControl?.enableTorch(false) + } catch (_: Exception) { + } + boundCamera = null + try { + cameraProvider?.unbindAll() + } catch (_: Exception) { + } + imageAnalysisExecutor?.let { ex -> + try { + ex.shutdown() + } catch (_: Exception) { + } + } + imageAnalysisExecutor = null + previewTransformLayoutListener?.let { listener -> + if (::viewBinding.isInitialized) { + viewBinding.viewFinder.removeOnLayoutChangeListener(listener) + } + } + previewTransformLayoutListener = null + previewStreamStateObserver?.let { obs -> + if (::viewBinding.isInitialized) { + viewBinding.viewFinder.previewStreamState.removeObserver(obs) + } + } + previewStreamStateObserver = null + cachedPreviewOutputTransform.set(null) + } + + private fun refreshCachedPreviewOutputTransform() { + if (!::viewBinding.isInitialized) { + return + } + val vf = viewBinding.viewFinder + try { + val out = vf.outputTransform + cachedPreviewOutputTransform.set(out) + } catch (t: Throwable) { + Log.e(TAG, "refreshCachedPreviewOutputTransform: $t") + cachedPreviewOutputTransform.set(null) + } + } + + private fun scheduleCachedPreviewOutputTransformRefresh() { + if (!::viewBinding.isInitialized) { + return + } + viewBinding.viewFinder.post { refreshCachedPreviewOutputTransform() } + } + + private fun onPairingLayoutGeometryChanged() { + if (!::viewBinding.isInitialized || !intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) { + return + } + val root = viewBinding.root + val chrome = viewBinding.pairingChrome + val w = root.width + val h = root.height + if (w <= 0 || h <= 0) { + return + } + val density = resources.displayMetrics.density + val headerBottom = if (chrome.visibility == View.VISIBLE) chrome.bottom.toFloat() else 0f + val insets = ViewCompat.getRootWindowInsets(root) + val statusTop = insets?.getInsets(WindowInsetsCompat.Type.statusBars())?.top?.toFloat() ?: 0f + val safeBottom = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom?.toFloat() ?: 0f + + pairingGeomHeaderBottomPx = headerBottom + pairingGeomStatusBarTopPx = statusTop + pairingGeomDensity = density + + viewBinding.pairingScanOverlay.setPairingHeaderBottomPx(headerBottom) + + val hole = PairingQrScanGeometry.pairingIosStyleHoleRectF(w, h, headerBottom, statusTop, density) + val torchCy = PairingQrScanGeometry.pairingIosStyleTorchCenterYPx( + hole.bottom, + h.toFloat(), + headerBottom, + safeBottom, + density + ) + val torchSizePx = (56f * density).roundToInt().coerceAtLeast(1) + val topMargin = (torchCy - torchSizePx / 2f).roundToInt().coerceAtLeast(0) + val wantGravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + viewBinding.torchButton.post { + if (!::viewBinding.isInitialized || !intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) { + return@post + } + val btn = viewBinding.torchButton + val lp = btn.layoutParams as FrameLayout.LayoutParams + if (lp.gravity == wantGravity && lp.topMargin == topMargin && lp.bottomMargin == 0) { + return@post + } + lp.gravity = wantGravity + lp.topMargin = topMargin + lp.bottomMargin = 0 + btn.layoutParams = lp + } + } + + private fun applyPairingTorchButtonChrome() { + if (!::viewBinding.isInitialized || !intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) { + return + } + val btn = viewBinding.torchButton + val d = resources.displayMetrics.density + val alpha = if (torchOn) (0.42f * 255f).toInt() else (0.22f * 255f).toInt() + val bg = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(Color.argb(alpha, 255, 255, 255)) + if (torchOn) { + setStroke((2f * d).roundToInt(), Color.rgb(255, 191, 115)) + } else { + setStroke(0, 0) + } + } + btn.background = bg + } + + private fun pairingHoleRectInImageSpace( + viewFinder: PreviewView, + imageProxy: ImageProxy, + imageWidth: Int, + imageHeight: Int + ): RectF { + val vw = viewFinder.width + val vh = viewFinder.height + fun geomFallback(): RectF = + PairingQrScanGeometry.pairingIosStyleHoleInImageCoords( + vw, + vh, + pairingGeomHeaderBottomPx, + pairingGeomStatusBarTopPx, + pairingGeomDensity, + imageWidth, + imageHeight + ) + if (vw <= 0 || vh <= 0 || imageWidth <= 0 || imageHeight <= 0) { + return geomFallback() + } + return try { + val previewOut = cachedPreviewOutputTransform.get() + if (previewOut == null) { + geomFallback() + } else { + val imageFactory = ImageProxyTransformFactory().apply { + setUsingRotationDegrees(true) + } + val imageOut = imageFactory.getOutputTransform(imageProxy) + val holeView = PairingQrScanGeometry.pairingIosStyleHoleRectF( + vw, + vh, + pairingGeomHeaderBottomPx, + pairingGeomStatusBarTopPx, + pairingGeomDensity + ) + if (holeView.width() <= 0f || holeView.height() <= 0f) { + return geomFallback() + } + val hole = RectF(holeView) + CoordinateTransform(previewOut, imageOut).mapRect(hole) + hole + } + } catch (t: Throwable) { + Log.e(TAG, "pairingHoleRectInImageSpace: $t") + geomFallback() + } + } + + private fun releaseCameraAndFinish() { + if (intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) { + pairingQrUserDismissedCamera = true + try { + QtAndroidController.onPairingQrCameraUserDismissed() + } catch (t: Throwable) { + Log.e(TAG, "onPairingQrCameraUserDismissed: $t") + } + } + cleanupCameraResources() + finish() + } + private fun checkPermissions(onSuccess: () -> Unit, onFail: () -> Unit) { if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { onSuccess() @@ -67,26 +404,41 @@ class CameraActivity : ComponentActivity() { cameraProviderFuture.addListener({ cameraProvider = cameraProviderFuture.get() - bindPreview() - bindImageAnalysis() + bindCameraUseCases() }, ContextCompat.getMainExecutor(this)) } @SuppressLint("ClickableViewAccessibility") - private fun bindPreview() { + @ExperimentalGetImage + private fun bindCameraUseCases() { + val provider = cameraProvider ?: return + imageAnalysisExecutor?.shutdown() + imageAnalysisExecutor = Executors.newSingleThreadExecutor() + val viewFinder = viewBinding.viewFinder val preview = Preview.Builder().build().also { it.setSurfaceProvider(viewFinder.surfaceProvider) } - val camera = cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview) + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + val camera = provider.bindToLifecycle( + this, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalysis + ) + boundCamera = camera + boundImageAnalysis = imageAnalysis viewFinder.setOnTouchListener { _, motionEvent -> when (motionEvent.action) { ACTION_DOWN -> true ACTION_UP -> { val point = viewFinder - .meteringPointFactory.createPoint(motionEvent.x, motionEvent.x) + .meteringPointFactory.createPoint(motionEvent.x, motionEvent.y) val action = FocusMeteringAction .Builder(point, FLAG_AF or FLAG_AE).build() @@ -98,58 +450,121 @@ class CameraActivity : ComponentActivity() { else -> false } } - } - @ExperimentalGetImage - private fun bindImageAnalysis() { - val imageAnalysis = ImageAnalysis.Builder().build() + if (intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) { + previewTransformLayoutListener?.let { viewFinder.removeOnLayoutChangeListener(it) } + val layoutListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + viewFinder.post { + scheduleCachedPreviewOutputTransformRefresh() + onPairingLayoutGeometryChanged() + } + } + previewTransformLayoutListener = layoutListener + viewFinder.addOnLayoutChangeListener(layoutListener) + previewStreamStateObserver?.let { viewFinder.previewStreamState.removeObserver(it) } + val streamObserver = Observer { state -> + if (state == PreviewView.StreamState.STREAMING) { + viewFinder.post { + scheduleCachedPreviewOutputTransformRefresh() + onPairingLayoutGeometryChanged() + } + } + } + previewStreamStateObserver = streamObserver + viewFinder.previewStreamState.observe(this, streamObserver) + scheduleCachedPreviewOutputTransformRefresh() + } - val camera = cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, imageAnalysis) + try { + barcodeScanner?.close() + } catch (_: Exception) { + } - val barcodeScanner = BarcodeScanning.getClient( + barcodeScanner = BarcodeScanning.getClient( Builder() .setBarcodeFormats(Barcode.FORMAT_QR_CODE) - .setZoomSuggestionOptions( - ZoomSuggestionOptions.Builder { zoomLevel -> - camera.cameraControl.setZoomRatio(zoomLevel) - true - }.apply { - camera.cameraInfo.zoomState.value?.maxZoomRatio?.let { maxZoomRation -> - setMaxSupportedZoomRatio(maxZoomRation) - } - }.build() - ).build() + .build() ) - // optimization val checkedBarcodes = hashSetOf() + val analysisExecutor = imageAnalysisExecutor!! + val mainExecutor = ContextCompat.getMainExecutor(this) + val pairingQrMode = intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false) - imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(this)) { imageProxy -> - imageProxy.image?.let { InputImage.fromMediaImage(it, imageProxy.imageInfo.rotationDegrees) } - ?.let { image -> - barcodeScanner.process(image).addOnSuccessListener { barcodes -> - barcodes.firstOrNull()?.let { barcode -> - barcode.displayValue?.let { code -> - if (code.isNotEmpty() && code !in checkedBarcodes) { - if (QtAndroidController.decodeQrCode(code)) { - barcodeScanner.close() + imageAnalysis.setAnalyzer(analysisExecutor) { imageProxy -> + if (qrHandledOrClosing.get()) { + imageProxy.close() + return@setAnalyzer + } + val mediaImage = imageProxy.image + if (mediaImage == null) { + imageProxy.close() + return@setAnalyzer + } + val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + val viewW = viewFinder.width + val viewH = viewFinder.height + val pairingRoi = if (pairingQrMode) { + pairingHoleRectInImageSpace(viewFinder, imageProxy, image.width, image.height) + } else { + null + } + val scanner = barcodeScanner ?: run { + imageProxy.close() + return@setAnalyzer + } + scanner.process(image) + .addOnSuccessListener(mainExecutor) { barcodes -> + if (qrHandledOrClosing.get()) { + return@addOnSuccessListener + } + val barcode = if (pairingQrMode) { + val roi = pairingRoi + ?: PairingQrScanGeometry.pairingIosStyleHoleInImageCoords( + viewW, + viewH, + pairingGeomHeaderBottomPx, + pairingGeomStatusBarTopPx, + pairingGeomDensity, + image.width, + image.height + ) + barcodes.firstOrNull { + PairingQrScanGeometry.barcodeMatchesPairingHole( + roi, + image.width, + image.height, + it + ) + } + } else { + barcodes.firstOrNull() + } + barcode?.displayValue?.let { code -> + if (code.isNotEmpty() && code !in checkedBarcodes) { + checkedBarcodes.add(code) + if (QtAndroidController.decodeQrCode(code)) { + if (qrHandledOrClosing.compareAndSet(false, true)) { + if (pairingQrMode) { + pairingQrDeliveredToQt = true + } stopCamera() } - checkedBarcodes.add(code) } } - } - }.addOnFailureListener { - Log.e(TAG, "Processing QR code image failed: ${it.message}") - }.addOnCompleteListener { - imageProxy.close() } } + .addOnFailureListener(mainExecutor) { + Log.e(TAG, "Processing QR code image failed: ${it.message}") + } + .addOnCompleteListener(mainExecutor) { + imageProxy.close() + } } } private fun stopCamera() { - cameraProvider.unbindAll() + cleanupCameraResources() finish() } } diff --git a/client/android/src/org/amnezia/vpn/PairingQrScanBracketPaths.kt b/client/android/src/org/amnezia/vpn/PairingQrScanBracketPaths.kt new file mode 100644 index 0000000000..5f48a971d8 --- /dev/null +++ b/client/android/src/org/amnezia/vpn/PairingQrScanBracketPaths.kt @@ -0,0 +1,101 @@ +package org.amnezia.vpn + +import android.graphics.Path +import android.graphics.RectF +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.max +import kotlin.math.min + +object PairingQrScanBracketPaths { + + private fun Path.addCornerMinorArc( + cx: Float, + cy: Float, + r: Float, + sx: Float, + sy: Float, + ex: Float, + ey: Float + ) { + var asRad = atan2((sy - cy).toDouble(), (sx - cx).toDouble()) + var aeRad = atan2((ey - cy).toDouble(), (ex - cx).toDouble()) + while (aeRad - asRad > PI) { + aeRad -= 2.0 * PI + } + while (aeRad - asRad < -PI) { + aeRad += 2.0 * PI + } + val minor = aeRad - asRad + val startDeg = Math.toDegrees(asRad).toFloat() + val sweepDeg = Math.toDegrees(minor).toFloat() + addArc(RectF(cx - r, cy - r, cx + r, cy + r), startDeg, sweepDeg) + } + + fun bracketStrokePath(corner: Int, x0: Float, y0: Float, s: Float, R: Float, L: Float, t: Float): Path { + val r = max(1.5f, R - t * 0.5f) + val p = Path() + val yy = y0 + t * 0.5f + val yyb = y0 + s - t * 0.5f + val xx = x0 + t * 0.5f + val xxb = x0 + s - t * 0.5f + + when (corner) { + 0 -> { + val cTLx = x0 + R + val cTLy = y0 + R + val sTLx = x0 + R + val sTLy = yy + val eTLx = xx + val eTLy = y0 + R + p.moveTo(x0 + R + L, yy) + p.lineTo(sTLx, sTLy) + p.addCornerMinorArc(cTLx, cTLy, r, sTLx, sTLy, eTLx, eTLy) + val yEndTL = min(y0 + R + L, y0 + s - R - t * 0.5f) + p.lineTo(xx, max(yEndTL, y0 + R + 2f)) + } + 1 -> { + val cTRx = x0 + s - R + val cTRy = y0 + R + val sTRx = x0 + s - R + val sTRy = yy + val eTRx = xxb + val eTRy = y0 + R + p.moveTo(x0 + s - R - L, yy) + p.lineTo(sTRx, sTRy) + p.addCornerMinorArc(cTRx, cTRy, r, sTRx, sTRy, eTRx, eTRy) + val yEndTR = min(y0 + R + L, y0 + s - R - t * 0.5f) + p.lineTo(xxb, max(yEndTR, y0 + R + 2f)) + } + 2 -> { + val cBLx = x0 + R + val cBLy = y0 + s - R + val sBLx = x0 + R + val sBLy = yyb + val eBLx = xx + val eBLy = y0 + s - R + p.moveTo(x0 + R + L, yyb) + p.lineTo(sBLx, sBLy) + p.addCornerMinorArc(cBLx, cBLy, r, sBLx, sBLy, eBLx, eBLy) + val yEndTopRef = max(min(y0 + R + L, y0 + s - R - t * 0.5f), y0 + R + 2f) + val yLegBL = y0 + s + y0 - yEndTopRef + p.lineTo(xx, yLegBL) + } + 3 -> { + val cBRx = x0 + s - R + val cBRy = y0 + s - R + val sBRx = x0 + s - R + val sBRy = yyb + val eBRx = xxb + val eBRy = y0 + s - R + p.moveTo(x0 + s - R - L, yyb) + p.lineTo(sBRx, sBRy) + p.addCornerMinorArc(cBRx, cBRy, r, sBRx, sBRy, eBRx, eBRy) + val yEndTopRef = max(min(y0 + R + L, y0 + s - R - t * 0.5f), y0 + R + 2f) + val yLegBR = y0 + s + y0 - yEndTopRef + p.lineTo(xxb, yLegBR) + } + } + return p + } +} diff --git a/client/android/src/org/amnezia/vpn/PairingQrScanGeometry.kt b/client/android/src/org/amnezia/vpn/PairingQrScanGeometry.kt new file mode 100644 index 0000000000..b3cb8db74e --- /dev/null +++ b/client/android/src/org/amnezia/vpn/PairingQrScanGeometry.kt @@ -0,0 +1,152 @@ +package org.amnezia.vpn + +import android.graphics.Rect +import android.graphics.RectF +import com.google.mlkit.vision.barcode.common.Barcode +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min + +object PairingQrScanGeometry { + fun viewRectToInputImageRectFillCenter( + viewW: Int, + viewH: Int, + imageW: Int, + imageH: Int, + viewRect: RectF + ): RectF { + val scale = max(viewW / imageW.toFloat(), viewH / imageH.toFloat()) + val drawLeft = (viewW - imageW * scale) / 2f + val drawTop = (viewH - imageH * scale) / 2f + return RectF( + (viewRect.left - drawLeft) / scale, + (viewRect.top - drawTop) / scale, + (viewRect.right - drawLeft) / scale, + (viewRect.bottom - drawTop) / scale + ) + } + + fun pairingIosStyleHoleCornerRadiusPx(sidePx: Float, density: Float): Float { + val d = density + var holeR = min(28f * d, max(10f * d, sidePx * 0.056f)) + val half = 0.5f * sidePx + holeR = min(holeR, max(6f * d, half - 2f * d)) + return max(holeR, 1f) + } + + fun barcodeBoxOverlapFraction(roi: RectF, box: Rect): Float { + val bf = RectF(box) + val inter = RectF(roi) + if (!inter.intersect(bf)) return 0f + val interArea = inter.width() * inter.height() + val boxArea = bf.width() * bf.height() + return if (boxArea <= 0f) 0f else interArea / boxArea + } + + fun barcodeMatchesPairingHole( + roiInImageSpace: RectF, + imageW: Int, + imageH: Int, + barcode: Barcode, + minOverlapFraction: Float = PAIRING_SEND_MIN_OVERLAP_BBOX_FALLBACK + ): Boolean { + if (imageW <= 0 || imageH <= 0) { + return false + } + val roi = RectF(roiInImageSpace) + val iw = imageW.toFloat() + val ih = imageH.toFloat() + roi.left = max(0f, roi.left) + roi.top = max(0f, roi.top) + roi.right = min(iw, roi.right) + roi.bottom = min(ih, roi.bottom) + if (roi.width() <= 0f || roi.height() <= 0f) { + return false + } + + val corners = barcode.cornerPoints + if (corners != null && corners.size >= 4) { + for (p in corners) { + if (!roi.contains(p.x.toFloat(), p.y.toFloat())) { + return false + } + } + return true + } + + val box = barcode.boundingBox ?: return false + val cx = box.centerX().toFloat() + val cy = box.centerY().toFloat() + if (!roi.contains(cx, cy)) { + return false + } + return barcodeBoxOverlapFraction(roi, box) >= minOverlapFraction + } + + private const val PAIRING_SEND_MIN_OVERLAP_BBOX_FALLBACK = 0.72f + + fun pairingIosStyleHoleRectF( + viewW: Int, + viewH: Int, + headerBottomPx: Float, + statusBarTopPx: Float, + density: Float + ): RectF { + val w = viewW.toFloat() + val h = viewH.toFloat() + val d = density + if (w < 32f || h < 32f) { + return RectF() + } + var hdrBottom = headerBottomPx + if (hdrBottom < 8f * d) { + hdrBottom = 132f * d + statusBarTopPx + } + val sqSz = floor(min(w, h) * 0.72).toFloat() + var sqX = (w - sqSz) / 2f + var sqY = (h - sqSz) / 2f + sqY = max(sqY, hdrBottom + 8f * d) + val kBottomBand = 80f * d + val maxHoleBottom = h - kBottomBand + if (sqY + sqSz > maxHoleBottom) { + sqY = maxHoleBottom - sqSz + sqY = max(sqY, hdrBottom + 8f * d) + } + sqX = max(8f * d, min(sqX, w - sqSz - 8f * d)) + sqY = max(hdrBottom + 4f * d, min(sqY, h - sqSz - 8f * d)) + return RectF(sqX, sqY, sqX + sqSz, sqY + sqSz) + } + + fun pairingIosStyleTorchCenterYPx( + holeBottomPx: Float, + bandBottomPx: Float, + headerBottomPx: Float, + safeBottomPx: Float, + density: Float + ): Float { + val torchH = 56f * density + val d = density + var torchCy = (holeBottomPx + bandBottomPx) * 0.5f + val minC = holeBottomPx + torchH * 0.5f + 6f * d + val maxC = bandBottomPx - torchH * 0.5f - max(6f * d, safeBottomPx) + torchCy = max(minC, min(maxC, torchCy)) + if (minC > maxC) { + torchCy = (minC + maxC) * 0.5f + } + val hdr = headerBottomPx + torchH * 0.5f + 10f * d + return max(torchCy, hdr) + } + + fun pairingIosStyleHoleInImageCoords( + viewW: Int, + viewH: Int, + headerBottomPx: Float, + statusBarTopPx: Float, + density: Float, + imageW: Int, + imageH: Int + ): RectF { + val hv = pairingIosStyleHoleRectF(viewW, viewH, headerBottomPx, statusBarTopPx, density) + return viewRectToInputImageRectFillCenter(viewW, viewH, imageW, imageH, hv) + } +} diff --git a/client/android/src/org/amnezia/vpn/PairingQrScanOverlayView.kt b/client/android/src/org/amnezia/vpn/PairingQrScanOverlayView.kt new file mode 100644 index 0000000000..4861afde7e --- /dev/null +++ b/client/android/src/org/amnezia/vpn/PairingQrScanOverlayView.kt @@ -0,0 +1,115 @@ +package org.amnezia.vpn + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import kotlin.math.max + +class PairingQrScanOverlayView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + init { + isClickable = false + isFocusable = false + } + + @Suppress("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean = false + + private val dimPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0x8C000000.toInt() + style = Paint.Style.FILL + } + + private val bracketPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0xFFE8E8EC.toInt() + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + } + + private var hole = RectF() + + private val bracketPaths = arrayOfNulls(4) + + private val dimPath = Path() + + private var pairingHeaderBottomPx = 0f + + fun setPairingHeaderBottomPx(px: Float) { + if (pairingHeaderBottomPx == px) { + return + } + pairingHeaderBottomPx = px + recomputePairingHole() + invalidate() + } + + private fun recomputePairingHole() { + val w = width + val h = height + if (w <= 0 || h <= 0) { + return + } + val topInset = ViewCompat.getRootWindowInsets(this) + ?.getInsets(WindowInsetsCompat.Type.statusBars())?.top?.toFloat() ?: 0f + val d = resources.displayMetrics.density + hole = PairingQrScanGeometry.pairingIosStyleHoleRectF(w, h, pairingHeaderBottomPx, topInset, d) + rebuildBracketPaths() + } + + private fun rebuildBracketPaths() { + val s = hole.width() + if (s <= 0f) { + bracketPaths.fill(null) + return + } + val x0 = hole.left + val y0 = hole.top + val t = bracketPaint.strokeWidth + val d = resources.displayMetrics.density + val l = max(28f * d, s * 0.13f) + val r = PairingQrScanGeometry.pairingIosStyleHoleCornerRadiusPx(s, d) + for (i in 0..3) { + bracketPaths[i] = PairingQrScanBracketPaths.bracketStrokePath(i, x0, y0, s, r, l, t) + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + bracketPaint.strokeWidth = max(3f, 5f * resources.displayMetrics.density) + recomputePairingHole() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val w = width.toFloat() + val h = height.toFloat() + val side = hole.width() + if (side > 0f) { + val d = resources.displayMetrics.density + val rx = PairingQrScanGeometry.pairingIosStyleHoleCornerRadiusPx(side, d) + dimPath.rewind() + dimPath.fillType = Path.FillType.EVEN_ODD + dimPath.addRect(0f, 0f, w, h, Path.Direction.CW) + dimPath.addRoundRect(hole, rx, rx, Path.Direction.CW) + canvas.drawPath(dimPath, dimPaint) + } else { + canvas.drawRect(0f, 0f, w, h, dimPaint) + } + + for (i in 0..3) { + bracketPaths[i]?.let { canvas.drawPath(it, bracketPaint) } + } + } +} diff --git a/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt b/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt index ec143635d4..a33fd5856c 100644 --- a/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt +++ b/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt @@ -34,4 +34,10 @@ object QtAndroidController { external fun onActivityPaused() external fun onActivityResumed() + + external fun onCameraPermissionResult(granted: Boolean) + + external fun onPairingQrCameraClosed() + + external fun onPairingQrCameraUserDismissed() } \ No newline at end of file diff --git a/client/cmake/ios.cmake b/client/cmake/ios.cmake index 86df23d25f..65df0426dd 100644 --- a/client/cmake/ios.cmake +++ b/client/cmake/ios.cmake @@ -28,6 +28,7 @@ set(LIBS ${LIBS} set(HEADERS ${HEADERS} + ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosPairingQrOverlayWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.h @@ -44,6 +45,8 @@ set(SOURCES ${SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosglue.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QRCodeReaderBase.mm + ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosPairingCameraAccess.mm + ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosPairingQrOverlayWindow.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/AmneziaSceneDelegateHooks.mm diff --git a/client/cmake/macos_ne.cmake b/client/cmake/macos_ne.cmake index ac1796ad08..f9c22382f9 100644 --- a/client/cmake/macos_ne.cmake +++ b/client/cmake/macos_ne.cmake @@ -49,6 +49,7 @@ set(SOURCES ${SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosglue.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QRCodeReaderBase.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm + ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosPairingCameraAccess_stub.cpp ) set(ICON_FILE ${CMAKE_CURRENT_SOURCE_DIR}/images/app.icns) diff --git a/client/cmake/sources.cmake b/client/cmake/sources.cmake index ddc44d47be..d7cdf81311 100644 --- a/client/cmake/sources.cmake +++ b/client/cmake/sources.cmake @@ -45,6 +45,7 @@ set(HEADERS ${HEADERS} ${CLIENT_ROOT_DIR}/core/controllers/settingsController.h ${CLIENT_ROOT_DIR}/core/controllers/api/servicesCatalogController.h ${CLIENT_ROOT_DIR}/core/controllers/api/subscriptionController.h + ${CLIENT_ROOT_DIR}/core/controllers/api/pairingController.h ${CLIENT_ROOT_DIR}/core/controllers/api/newsController.h ${CLIENT_ROOT_DIR}/core/controllers/updateController.h ${CLIENT_ROOT_DIR}/core/repositories/secureServersRepository.h @@ -65,6 +66,8 @@ set(HEADERS ${HEADERS} ${CLIENT_ROOT_DIR}/core/utils/utilities.h ${CLIENT_ROOT_DIR}/core/utils/managementServer.h ${CLIENT_ROOT_DIR}/core/utils/constants.h + ${CLIENT_ROOT_DIR}/platforms/ios/iosPairingCameraAccess.h + ${CLIENT_ROOT_DIR}/platforms/ios/iosPairingQrOverlayWindow.h ) # Mozilla headres @@ -122,6 +125,7 @@ set(SOURCES ${SOURCES} ${CLIENT_ROOT_DIR}/core/controllers/settingsController.cpp ${CLIENT_ROOT_DIR}/core/controllers/api/servicesCatalogController.cpp ${CLIENT_ROOT_DIR}/core/controllers/api/subscriptionController.cpp + ${CLIENT_ROOT_DIR}/core/controllers/api/pairingController.cpp ${CLIENT_ROOT_DIR}/core/controllers/api/newsController.cpp ${CLIENT_ROOT_DIR}/core/controllers/updateController.cpp ${CLIENT_ROOT_DIR}/core/repositories/secureServersRepository.cpp @@ -157,6 +161,7 @@ set(SOURCES ${SOURCES} if(NOT IOS AND NOT MACOS_NE) set(SOURCES ${SOURCES} ${CLIENT_ROOT_DIR}/platforms/ios/QRCodeReaderBase.cpp + ${CLIENT_ROOT_DIR}/platforms/ios/iosPairingCameraAccess_stub.cpp ) endif() diff --git a/client/core/controllers/api/newsController.cpp b/client/core/controllers/api/newsController.cpp index 4529f29f9a..5d0b964b6f 100644 --- a/client/core/controllers/api/newsController.cpp +++ b/client/core/controllers/api/newsController.cpp @@ -90,7 +90,7 @@ QFuture> NewsController::fetchNews() payload.insert(apiDefs::key::serviceType, services.value(apiDefs::key::serviceType)); } - auto future = gatewayController->postAsync(QString("%1v1/news"), payload); + auto future = gatewayController->postAsync(QString("%1v1/news"), payload, nullptr, gatewayController); return future.then([gatewayController](QPair result) -> QPair { auto [errorCode, responseBody] = result; if (errorCode != ErrorCode::NoError) { diff --git a/client/core/controllers/api/pairingController.cpp b/client/core/controllers/api/pairingController.cpp new file mode 100644 index 0000000000..9843b1d3d6 --- /dev/null +++ b/client/core/controllers/api/pairingController.cpp @@ -0,0 +1,204 @@ +#include "pairingController.h" + +#include +#include +#include "core/repositories/secureAppSettingsRepository.h" +#include "core/utils/api/apiUtils.h" +#include "core/utils/constants/apiConstants.h" +#include "core/utils/constants/apiKeys.h" +#include "version.h" + +using namespace amnezia; + +namespace +{ +constexpr qsizetype kPairingMaxQrUuidChars = 128; +constexpr qsizetype kPairingMaxVpnConfigChars = 256 * 1024; +constexpr qsizetype kPairingMaxApiKeyChars = 8192; +constexpr qsizetype kPairingMaxServiceTypeChars = 64; +constexpr qsizetype kPairingMaxUserCountryCodeChars = 32; + +ErrorCode applyGatewayOrOpenApiGenerateError(const QJsonObject &obj, PairingController::QrPairingConfigPayload &outPayload) +{ + ErrorCode apiStatus = apiUtils::errorCodeFromGatewayJsonHttpStatus(obj); + if (apiStatus != ErrorCode::NoError) { + return apiStatus; + } + + const QString config = obj.value(apiDefs::key::config).toString(); + if (!config.isEmpty()) { + outPayload.config = config; + outPayload.serviceInfo = obj.value(apiDefs::key::serviceInfo).toObject(); + outPayload.supportedProtocols = obj.value(apiDefs::key::supportedProtocols).toArray(); + return ErrorCode::NoError; + } + + if (obj.contains(QStringLiteral("detail"))) { + return ErrorCode::ApiConfigEmptyError; + } + + const QString msg = obj.value(QStringLiteral("message")).toString(); + if (msg.contains(QStringLiteral("timeout"), Qt::CaseInsensitive)) { + return ErrorCode::ApiConfigTimeoutError; + } + if (msg.contains(QStringLiteral("Too Many"), Qt::CaseInsensitive)) { + return ErrorCode::ApiPairingRateLimitedError; + } + if (msg.contains(QStringLiteral("Unavailable"), Qt::CaseInsensitive)) { + return ErrorCode::ApiPairingServiceUnavailableError; + } + if (!msg.isEmpty()) { + return ErrorCode::ApiConfigDownloadError; + } + + return ErrorCode::ApiConfigEmptyError; +} + +ErrorCode applyGatewayOrOpenApiScanError(const QJsonObject &obj) +{ + const QString msgProbe = obj.value(QStringLiteral("message")).toString(); + if (msgProbe.contains(QStringLiteral("limit"), Qt::CaseInsensitive) + && (msgProbe.contains(QStringLiteral("device"), Qt::CaseInsensitive) + || msgProbe.contains(QStringLiteral("maximum"), Qt::CaseInsensitive) + || msgProbe.contains(QStringLiteral("max"), Qt::CaseInsensitive))) { + return ErrorCode::ApiConfigLimitError; + } + + ErrorCode apiStatus = apiUtils::errorCodeFromGatewayJsonHttpStatus(obj); + if (apiStatus != ErrorCode::NoError) { + return apiStatus; + } + + if (obj.value(QStringLiteral("message")).toString() == QLatin1String("OK")) { + return ErrorCode::NoError; + } + + if (obj.contains(QStringLiteral("detail"))) { + return ErrorCode::ApiPairingForbiddenError; + } + + const QString msg = obj.value(QStringLiteral("message")).toString(); + if (msg.contains(QStringLiteral("QR session"), Qt::CaseInsensitive) + && (msg.contains(QStringLiteral("not found"), Qt::CaseInsensitive) + || msg.contains(QStringLiteral("expired"), Qt::CaseInsensitive))) { + return ErrorCode::ApiPairingSessionExpiredError; + } + if (msg.contains(QStringLiteral("not found"), Qt::CaseInsensitive) || msg.contains(QStringLiteral("expired"), Qt::CaseInsensitive)) { + return ErrorCode::ApiNotFoundError; + } + if (msg.contains(QStringLiteral("Conflict"), Qt::CaseInsensitive) || msg.contains(QStringLiteral("already"), Qt::CaseInsensitive)) { + return ErrorCode::ApiPairingConflictError; + } + if (msg.contains(QStringLiteral("Too Many"), Qt::CaseInsensitive)) { + return ErrorCode::ApiPairingRateLimitedError; + } + if (msg.contains(QStringLiteral("Unavailable"), Qt::CaseInsensitive)) { + return ErrorCode::ApiPairingServiceUnavailableError; + } + if (!msg.isEmpty()) { + return ErrorCode::ApiConfigDownloadError; + } + + return ErrorCode::ApiConfigEmptyError; +} + +ErrorCode interpretGenerateQrJson(const QJsonObject &obj, PairingController::QrPairingConfigPayload &outPayload) +{ + return applyGatewayOrOpenApiGenerateError(obj, outPayload); +} + +ErrorCode interpretScanQrJson(const QJsonObject &obj) +{ + return applyGatewayOrOpenApiScanError(obj); +} +} // namespace + +ErrorCode PairingController::parseGenerateQrResponseBody(const QByteArray &responseBody, QrPairingConfigPayload &outPayload) +{ + outPayload = QrPairingConfigPayload {}; + const QJsonObject obj = QJsonDocument::fromJson(responseBody).object(); + return interpretGenerateQrJson(obj, outPayload); +} + +ErrorCode PairingController::parseScanQrResponseBody(const QByteArray &responseBody, QString *outOptionalDisplayName) +{ + if (outOptionalDisplayName) { + outOptionalDisplayName->clear(); + } + const QJsonObject obj = QJsonDocument::fromJson(responseBody).object(); + const ErrorCode err = interpretScanQrJson(obj); + if (err != ErrorCode::NoError) { + return err; + } + if (outOptionalDisplayName) { + const QString deviceName = obj.value(QStringLiteral("device_name")).toString().trimmed(); + if (!deviceName.isEmpty()) { + *outOptionalDisplayName = deviceName; + } + } + return ErrorCode::NoError; +} + +ErrorCode PairingController::validatePairingScanFields(const QString &qrUuid, const QString &vpnConfig, const QString &apiKey, + const QString &serviceType, const QString &userCountryCode) +{ + if (qrUuid.size() > kPairingMaxQrUuidChars) { + return ErrorCode::ApiConfigEmptyError; + } + if (vpnConfig.size() > kPairingMaxVpnConfigChars) { + return ErrorCode::ApiPairingPayloadTooLargeError; + } + if (apiKey.size() > kPairingMaxApiKeyChars) { + return ErrorCode::ApiPairingPayloadTooLargeError; + } + const QString st = serviceType.trimmed(); + const QString cc = userCountryCode.trimmed(); + if (st.isEmpty() || cc.isEmpty()) { + return ErrorCode::ApiPairingMissingMetadataError; + } + if (st.size() > kPairingMaxServiceTypeChars || cc.size() > kPairingMaxUserCountryCodeChars) { + return ErrorCode::ApiPairingPayloadTooLargeError; + } + return ErrorCode::NoError; +} + +PairingController::PairingController(SecureAppSettingsRepository *appSettingsRepository) + : m_appSettingsRepository(appSettingsRepository) +{ +} + +int PairingController::pairingLongPollTimeoutMsecs() const +{ + return 60 * 1000; +} + +QJsonObject PairingController::buildGenerateQrPayload(const QString &qrUuid) const +{ + QJsonObject o; + o[apiDefs::key::qrUuid] = qrUuid; + o[apiDefs::key::installationUuid] = m_appSettingsRepository->getInstallationUuid(true); + o[apiDefs::key::appVersion] = QString(APP_VERSION); + o[apiDefs::key::osVersion] = QSysInfo::productType(); + return o; +} + +QJsonObject PairingController::buildScanQrPayload(const QString &qrUuid, const QString &vpnConfig, const QJsonObject &serviceInfo, + const QJsonArray &supportedProtocols, const QString &apiKey, + const QString &serviceType, const QString &userCountryCode) const +{ + QJsonObject auth; + auth[apiDefs::key::apiKey] = apiKey; + + QJsonObject o; + o[apiDefs::key::qrUuid] = qrUuid; + o[apiDefs::key::config] = vpnConfig; + o[apiDefs::key::serviceInfo] = serviceInfo; + o[apiDefs::key::supportedProtocols] = supportedProtocols; + o[apiDefs::key::authData] = auth; + o[apiDefs::key::installationUuid] = m_appSettingsRepository->getInstallationUuid(true); + o[apiDefs::key::appVersion] = QString(APP_VERSION); + o[apiDefs::key::osVersion] = QSysInfo::productType(); + o[apiDefs::key::serviceType] = serviceType.trimmed(); + o[apiDefs::key::userCountryCode] = userCountryCode.trimmed(); + return o; +} diff --git a/client/core/controllers/api/pairingController.h b/client/core/controllers/api/pairingController.h new file mode 100644 index 0000000000..61d4242a4f --- /dev/null +++ b/client/core/controllers/api/pairingController.h @@ -0,0 +1,41 @@ +#ifndef PAIRINGCONTROLLER_H +#define PAIRINGCONTROLLER_H + +#include +#include +#include + +#include "core/utils/errorCodes.h" + +class SecureAppSettingsRepository; + +class PairingController +{ +public: + struct QrPairingConfigPayload + { + QString config; + QJsonObject serviceInfo; + QJsonArray supportedProtocols; + }; + + explicit PairingController(SecureAppSettingsRepository *appSettingsRepository); + + int pairingLongPollTimeoutMsecs() const; + + QJsonObject buildGenerateQrPayload(const QString &qrUuid) const; + QJsonObject buildScanQrPayload(const QString &qrUuid, const QString &vpnConfig, const QJsonObject &serviceInfo, + const QJsonArray &supportedProtocols, const QString &apiKey, const QString &serviceType, + const QString &userCountryCode) const; + + static amnezia::ErrorCode parseGenerateQrResponseBody(const QByteArray &responseBody, QrPairingConfigPayload &outPayload); + static amnezia::ErrorCode parseScanQrResponseBody(const QByteArray &responseBody, QString *outOptionalDisplayName = nullptr); + + static amnezia::ErrorCode validatePairingScanFields(const QString &qrUuid, const QString &vpnConfig, const QString &apiKey, + const QString &serviceType, const QString &userCountryCode); + +private: + SecureAppSettingsRepository *m_appSettingsRepository; +}; + +#endif // PAIRINGCONTROLLER_H diff --git a/client/core/controllers/api/subscriptionController.cpp b/client/core/controllers/api/subscriptionController.cpp index 8efc145585..5af9542dec 100644 --- a/client/core/controllers/api/subscriptionController.cpp +++ b/client/core/controllers/api/subscriptionController.cpp @@ -312,6 +312,71 @@ ErrorCode SubscriptionController::importTrialFromGateway(const QString &userCoun return ErrorCode::NoError; } +ErrorCode SubscriptionController::importServerFromQrPairingResponse(const QString &vpnConfigKey, const QJsonObject &serviceInfo, + const QJsonArray &supportedProtocols, + int *duplicateServerIndex) +{ + if (vpnConfigKey.isEmpty()) { + return ErrorCode::ApiConfigEmptyError; + } + + QString normalizedKey = vpnConfigKey; + normalizedKey.replace(QStringLiteral("vpn://"), QString()); + + for (int i = 0; i < m_serversRepository->serversCount(); ++i) { + const auto apiV2 = m_serversRepository->apiV2Config(m_serversRepository->serverIdAt(i)); + QString existingVpnKey = apiV2.has_value() ? apiV2->vpnKey() : QString(); + existingVpnKey.replace(QStringLiteral("vpn://"), QString()); + if (!existingVpnKey.isEmpty() && existingVpnKey == normalizedKey) { + if (duplicateServerIndex) { + *duplicateServerIndex = i; + } + return ErrorCode::ApiConfigAlreadyAdded; + } + } + + QByteArray configString = + QByteArray::fromBase64(normalizedKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); + QByteArray configUncompressed = qUncompress(configString); + if (!configUncompressed.isEmpty()) { + configString = configUncompressed; + } + if (configString.isEmpty()) { + return ErrorCode::ApiConfigEmptyError; + } + + QJsonObject serverJson = QJsonDocument::fromJson(configString).object(); + if (serverJson.isEmpty()) { + return ErrorCode::ApiConfigEmptyError; + } + + if (serverJson.value(configKey::configVersion).toInt() != serverConfigUtils::ConfigSource::AmneziaGateway) { + return ErrorCode::InternalError; + } + + QJsonObject apiConfig = serverJson.value(apiDefs::key::apiConfig).toObject(); + if (!serviceInfo.isEmpty()) { + apiConfig.insert(apiDefs::key::serviceInfo, serviceInfo); + } + if (!supportedProtocols.isEmpty()) { + apiConfig.insert(apiDefs::key::supportedProtocols, supportedProtocols); + } + serverJson[apiDefs::key::apiConfig] = apiConfig; + + ApiV2ServerConfig apiV2ServerConfig = ApiV2ServerConfig::fromJson(serverJson); + if (apiV2ServerConfig.apiConfig.vpnKey.isEmpty()) { + QString fullKey = vpnConfigKey.trimmed(); + if (!fullKey.startsWith(QStringLiteral("vpn://"))) { + fullKey = QStringLiteral("vpn://") + fullKey; + } + apiV2ServerConfig.apiConfig.vpnKey = fullKey; + } + + m_serversRepository->addServer(QString(), apiV2ServerConfig.toJson(), + serverConfigUtils::configTypeFromJson(apiV2ServerConfig.toJson())); + return ErrorCode::NoError; +} + ErrorCode SubscriptionController::importServiceFromAppStore(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol, const ProtocolData &protocolData, const QString &transactionId, bool isTestPurchase, @@ -934,7 +999,7 @@ QFuture> SubscriptionController::getRenewalLink(const m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), apiDefs::requestTimeoutMsecs, m_appSettingsRepository->isStrictKillSwitchEnabled()); - auto postFuture = gatewayController->postAsync(QString("%1v1/renewal_link"), apiPayload); + auto postFuture = gatewayController->postAsync(QString("%1v1/renewal_link"), apiPayload, nullptr, gatewayController); auto *watcher = new QFutureWatcher>(); QObject::connect(watcher, &QFutureWatcher>::finished, [promise, watcher, gatewayController]() { diff --git a/client/core/controllers/api/subscriptionController.h b/client/core/controllers/api/subscriptionController.h index a0ac5d24b2..c8cc14b93d 100644 --- a/client/core/controllers/api/subscriptionController.h +++ b/client/core/controllers/api/subscriptionController.h @@ -1,6 +1,7 @@ #ifndef SUBSCRIPTIONCONTROLLER_H #define SUBSCRIPTIONCONTROLLER_H +#include #include #include #include @@ -53,6 +54,9 @@ class SubscriptionController ErrorCode importTrialFromGateway(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol, const QString &email); + ErrorCode importServerFromQrPairingResponse(const QString &vpnConfigKey, const QJsonObject &serviceInfo, + const QJsonArray &supportedProtocols, int *duplicateServerIndex = nullptr); + ErrorCode importServiceFromAppStore(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol, const ProtocolData &protocolData, const QString &transactionId, bool isTestPurchase, diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index 77b951f9e3..313add4cc4 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -153,6 +153,7 @@ void CoreController::initCoreControllers() m_allowedDnsController = new AllowedDnsController(m_appSettingsRepository); m_servicesCatalogController = new ServicesCatalogController(m_appSettingsRepository); m_subscriptionController = new SubscriptionController(m_serversRepository, m_appSettingsRepository); + m_pairingController = new PairingController(m_appSettingsRepository); m_newsController = new NewsController(m_appSettingsRepository, m_serversRepository); m_updateController = new UpdateController(m_appSettingsRepository, this); @@ -223,6 +224,9 @@ void CoreController::initControllers() m_apiCountryModel, m_apiDevicesModel, m_settingsController, this); setQmlContextProperty("SubscriptionUiController", m_subscriptionUiController); + m_pairingUiController = new PairingUiController(m_pairingController, m_serversController, m_subscriptionController, m_appSettingsRepository, this); + setQmlContextProperty("PairingUiController", m_pairingUiController); + m_apiNewsUiController = new ApiNewsUiController(m_newsModel, m_newsController, this); setQmlContextProperty("ApiNewsController", m_apiNewsUiController); diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index 70033d61b1..af66c98a25 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -10,6 +10,8 @@ #endif #include "ui/controllers/api/subscriptionUiController.h" +#include "ui/controllers/api/pairingUiController.h" +#include "core/controllers/api/pairingController.h" #include "ui/controllers/api/apiNewsUiController.h" #include "ui/controllers/appSplitTunnelingUiController.h" #include "ui/controllers/allowedDnsUiController.h" @@ -168,6 +170,7 @@ class CoreController : public QObject UpdateUiController* m_updateUiController; SubscriptionUiController* m_subscriptionUiController; + PairingUiController* m_pairingUiController; ApiNewsUiController* m_apiNewsUiController; ServicesCatalogUiController* m_servicesCatalogUiController; @@ -179,6 +182,7 @@ class CoreController : public QObject AllowedDnsController* m_allowedDnsController; ServicesCatalogController* m_servicesCatalogController; SubscriptionController* m_subscriptionController; + PairingController* m_pairingController; NewsController* m_newsController; UpdateController* m_updateController; InstallController* m_installController; diff --git a/client/core/controllers/coreSignalHandlers.cpp b/client/core/controllers/coreSignalHandlers.cpp index 934f20f6ae..dbb6d8afe7 100644 --- a/client/core/controllers/coreSignalHandlers.cpp +++ b/client/core/controllers/coreSignalHandlers.cpp @@ -21,6 +21,7 @@ #include "ui/controllers/selfhosted/installUiController.h" #include "ui/controllers/importUiController.h" #include "ui/controllers/api/subscriptionUiController.h" +#include "ui/controllers/api/pairingUiController.h" #include "ui/controllers/updateUiController.h" #include "ui/models/serversModel.h" #include "core/controllers/serversController.h" @@ -98,6 +99,9 @@ void CoreSignalHandlers::initErrorMessagesHandler() connect(m_coreController->m_subscriptionUiController, &SubscriptionUiController::errorOccurred, m_coreController->m_pageController, qOverload(&PageController::showErrorMessage)); + connect(m_coreController->m_pairingUiController, &PairingUiController::errorOccurred, m_coreController->m_pageController, + qOverload(&PageController::showErrorMessage)); + connect(m_coreController->m_settingsUiController, &SettingsUiController::errorOccurred, m_coreController->m_pageController, qOverload(&PageController::showErrorMessage)); } diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 9b22ad6adc..54660738c4 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include "QBlockCipher.h" @@ -21,12 +22,25 @@ #include "core/utils/networkUtilities.h" #include "core/utils/utilities.h" +#ifdef Q_OS_IOS + #include "platforms/ios/ios_controller.h" +#endif + #ifdef AMNEZIA_DESKTOP #include "core/utils/ipcClient.h" #endif namespace { + void execNetworkWaitLoop(QEventLoop &wait) + { +#ifdef Q_OS_IOS + wait.exec(); +#else + wait.exec(QEventLoop::ExcludeUserInputEvents); +#endif + } + constexpr QLatin1String errorResponsePattern1("No active configuration found for"); constexpr QLatin1String errorResponsePattern2("No non-revoked public key found for"); constexpr QLatin1String errorResponsePattern3("Account not found."); @@ -42,12 +56,24 @@ namespace constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); constexpr int proxyStorageRequestTimeoutMsecs = 3000; -} + + QString normalizedGatewayBase(const QString &endpoint) + { + QString e = endpoint.trimmed(); + if (e.isEmpty()) { + return e; + } + if (!e.endsWith(QLatin1Char('/'))) { + e.append(QLatin1Char('/')); + } + return e; + } +} // namespace GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs, const bool isStrictKillSwitchEnabled, QObject *parent) : QObject(parent), - m_gatewayEndpoint(gatewayEndpoint), + m_gatewayEndpoint(normalizedGatewayBase(gatewayEndpoint)), m_isDevEnvironment(isDevEnvironment), m_requestTimeoutMsecs(requestTimeoutMsecs), m_isStrictKillSwitchEnabled(isStrictKillSwitchEnabled) @@ -135,6 +161,8 @@ GatewayController::DecryptionResult GatewayController::tryDecryptResponseBody(co QNetworkReply::NetworkError replyError, const QByteArray &key, const QByteArray &iv, const QByteArray &salt) { + Q_UNUSED(replyError); + DecryptionResult result; result.decryptedBody = encryptedResponseBody; result.isDecryptionSuccessful = false; @@ -151,6 +179,29 @@ GatewayController::DecryptionResult GatewayController::tryDecryptResponseBody(co return result; } +GatewayController::DecryptionResult GatewayController::resolveResponseBody(const QByteArray &responseBody, + QNetworkReply::NetworkError replyError, const QByteArray &key, + const QByteArray &iv, const QByteArray &salt) +{ + DecryptionResult result = tryDecryptResponseBody(responseBody, replyError, key, iv, salt); + if (result.isDecryptionSuccessful || !m_isDevEnvironment) { + return result; + } + + const QByteArray trimmed = responseBody.trimmed(); + if (trimmed.isEmpty() || trimmed.front() != '{') { + return result; + } + + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(trimmed, &parseError); + if (parseError.error == QJsonParseError::NoError && doc.isObject()) { + result.decryptedBody = trimmed; + result.isDecryptionSuccessful = true; + } + return result; +} + ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody) { EncryptedRequestData encRequestData = prepareRequest(endpoint, apiPayload); @@ -165,7 +216,7 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api QList sslErrors; connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList &errors) { sslErrors = errors; }); - wait.exec(QEventLoop::ExcludeUserInputEvents); + execNetworkWaitLoop(wait); QByteArray encryptedResponseBody = reply->readAll(); QString replyErrorString = reply->errorString(); @@ -174,8 +225,18 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api reply->deleteLater(); + if (encRequestData.isPlaintextLocalGateway) { + const auto errorCode = + apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, encryptedResponseBody); + if (errorCode) { + return errorCode; + } + responseBody = encryptedResponseBody; + return ErrorCode::NoError; + } + auto decryptionResult = - tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt); + resolveResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt); if (sslErrors.isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { auto requestFunction = [&encRequestData, &encryptedResponseBody](const QString &url) { @@ -191,7 +252,7 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); decryptionResult = - tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt); + resolveResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt); if (!sslErrors.isEmpty() || shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { @@ -221,11 +282,15 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api return ErrorCode::NoError; } -QFuture> GatewayController::postAsync(const QString &endpoint, const QJsonObject apiPayload) +QFuture> GatewayController::postAsync(const QString &endpoint, const QJsonObject &apiPayload, + QNetworkReply **activeReplyOut, + const QSharedPointer &keepAlive) { auto promise = QSharedPointer>>::create(); promise->start(); + const QSharedPointer life = keepAlive; + EncryptedRequestData encRequestData = prepareRequest(endpoint, apiPayload); if (encRequestData.errorCode != ErrorCode::NoError) { promise->addResult(qMakePair(encRequestData.errorCode, QByteArray())); @@ -234,12 +299,22 @@ QFuture> GatewayController::postAsync(const QString } QNetworkReply *reply = amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody); + if (activeReplyOut) { + *activeReplyOut = reply; + } auto sslErrors = QSharedPointer>::create(); connect(reply, &QNetworkReply::sslErrors, [sslErrors](const QList &errors) { *sslErrors = errors; }); - connect(reply, &QNetworkReply::finished, this, [promise, sslErrors, encRequestData, endpoint, apiPayload, reply, this]() mutable { + connect(reply, &QNetworkReply::finished, reply, [promise, sslErrors, encRequestData, endpoint, apiPayload, reply, life]() mutable { + if (!life) { + promise->addResult(qMakePair(ErrorCode::ApiConfigDecryptionError, QByteArray())); + promise->finish(); + return; + } + + GatewayController *const ctl = life.data(); QByteArray encryptedResponseBody = reply->readAll(); QString replyErrorString = reply->errorString(); auto replyError = reply->error(); @@ -247,8 +322,20 @@ QFuture> GatewayController::postAsync(const QString reply->deleteLater(); + if (encRequestData.isPlaintextLocalGateway) { + const auto errorCode = apiUtils::checkNetworkReplyErrors(*sslErrors, replyErrorString, replyError, httpStatusCode, + encryptedResponseBody); + if (errorCode) { + promise->addResult(qMakePair(errorCode, QByteArray())); + } else { + promise->addResult(qMakePair(ErrorCode::NoError, encryptedResponseBody)); + } + promise->finish(); + return; + } + auto decryptionResult = - tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt); + ctl->resolveResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt); auto processResponse = [promise, encRequestData](const GatewayController::DecryptionResult &decryptionResult, const QList &sslErrors, QNetworkReply::NetworkError replyError, @@ -273,13 +360,13 @@ QFuture> GatewayController::postAsync(const QString promise->finish(); }; - if (sslErrors->isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { + if (sslErrors->isEmpty() && ctl->shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { auto serviceType = apiPayload.value(apiDefs::key::serviceType).toString(""); auto userCountryCode = apiPayload.value(apiDefs::key::userCountryCode).toString(""); QStringList primaryBaseUrls; QStringList fallbackBaseUrls; - if (m_isDevEnvironment) { + if (ctl->m_isDevEnvironment) { primaryBaseUrls = QString(DEV_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); } else { primaryBaseUrls = QString(PROD_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts); @@ -306,19 +393,27 @@ QFuture> GatewayController::postAsync(const QString 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) { - bypassProxyAsync(endpoint, proxyUrl, encRequestData, - [processResponse, this](const QByteArray &decryptedBody, bool isDecryptionSuccessful, - const QList &sslErrors, QNetworkReply::NetworkError replyError, - const QString &replyErrorString, int httpStatusCode) { - GatewayController::DecryptionResult result; - result.decryptedBody = decryptedBody; - result.isDecryptionSuccessful = isDecryptionSuccessful; - processResponse(result, sslErrors, replyError, replyErrorString, httpStatusCode); - }); - }); - }); + life->getProxyUrlsAsync(life, proxyStorageUrls, 0, + [life, encRequestData, endpoint, processResponse](const QStringList &proxyUrls) { + life->getProxyUrlAsync(life, proxyUrls, 0, + [life, encRequestData, endpoint, processResponse]( + const QString &proxyUrl) { + life->bypassProxyAsync( + life, endpoint, proxyUrl, encRequestData, + [processResponse](const QByteArray &decryptedBody, + bool isDecryptionSuccessful, + const QList &sslErrors, + QNetworkReply::NetworkError replyError, + const QString &replyErrorString, + int httpStatusCode) { + GatewayController::DecryptionResult result; + result.decryptedBody = decryptedBody; + result.isDecryptionSuccessful = isDecryptionSuccessful; + processResponse(result, sslErrors, replyError, + replyErrorString, httpStatusCode); + }); + }); + }); } else { processResponse(decryptionResult, *sslErrors, replyError, replyErrorString, httpStatusCode); @@ -381,7 +476,7 @@ QStringList GatewayController::getProxyUrls(const QString &serviceType, const QS connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit); connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList &errors) { sslErrors = errors; }); - wait.exec(QEventLoop::ExcludeUserInputEvents); + execNetworkWaitLoop(wait); if (reply->error() == QNetworkReply::NetworkError::NoError) { auto encryptedResponseBody = reply->readAll(); @@ -434,6 +529,10 @@ QStringList GatewayController::getProxyUrls(const QString &serviceType, const QS bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody, bool isDecryptionSuccessful) { + if (m_isDevEnvironment) { + return false; + } + const QByteArray &responseBody = decryptedResponseBody; int apiHttpStatus = -1; @@ -514,7 +613,7 @@ void GatewayController::bypassProxy(const QString &endpoint, const QString &serv QObject::connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit); connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList &errors) { sslErrors = errors; }); - wait.exec(QEventLoop::ExcludeUserInputEvents); + execNetworkWaitLoop(wait); auto result = replyProcessingFunction(reply, sslErrors); reply->deleteLater(); @@ -536,7 +635,7 @@ void GatewayController::bypassProxy(const QString &endpoint, const QString &serv connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit); connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList &errors) { sslErrors = errors; }); - wait.exec(QEventLoop::ExcludeUserInputEvents); + execNetworkWaitLoop(wait); if (reply->error() == QNetworkReply::NetworkError::NoError) { reply->deleteLater(); @@ -565,9 +664,14 @@ void GatewayController::bypassProxy(const QString &endpoint, const QString &serv } } -void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, const int currentProxyStorageIndex, - std::function onComplete) +void GatewayController::getProxyUrlsAsync(const QSharedPointer &life, const QStringList &proxyStorageUrls, + const int currentProxyStorageIndex, const std::function &onComplete) { + if (!life) { + onComplete({}); + return; + } + if (currentProxyStorageIndex >= proxyStorageUrls.size()) { onComplete({}); return; @@ -580,17 +684,23 @@ void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, co QNetworkReply *reply = amnApp->networkManager()->get(request); - // connect(reply, &QNetworkReply::sslErrors, this, [state](const QList &e) { *(state->sslErrors) = e; }); + connect(reply, &QNetworkReply::finished, reply, [life, proxyStorageUrls, currentProxyStorageIndex, onComplete, reply]() { + if (!life) { + onComplete({}); + reply->deleteLater(); + return; + } + + GatewayController *const ctl = life.data(); - connect(reply, &QNetworkReply::finished, this, [this, proxyStorageUrls, currentProxyStorageIndex, onComplete, reply]() { if (reply->error() == QNetworkReply::NoError) { QByteArray encrypted = reply->readAll(); reply->deleteLater(); QByteArray responseBody; try { - QByteArray key = m_isDevEnvironment ? DEV_AGW_PUBLIC_KEY : PROD_AGW_PUBLIC_KEY; - if (!m_isDevEnvironment) { + QByteArray key = ctl->m_isDevEnvironment ? DEV_AGW_PUBLIC_KEY : PROD_AGW_PUBLIC_KEY; + if (!ctl->m_isDevEnvironment) { QCryptographicHash hash(QCryptographicHash::Sha512); hash.addData(key); QByteArray h = hash.result().toHex(); @@ -607,15 +717,21 @@ void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, co } catch (...) { Utils::logException(); qCritical() << "error decrypting payload"; - QMetaObject::invokeMethod( - this, [=]() { getProxyUrlsAsync(proxyStorageUrls, currentProxyStorageIndex + 1, onComplete); }, Qt::QueuedConnection); + QTimer::singleShot(0, ctl, [life, proxyStorageUrls, currentProxyStorageIndex, onComplete]() { + if (life) { + life->getProxyUrlsAsync(life, proxyStorageUrls, currentProxyStorageIndex + 1, onComplete); + } else { + onComplete({}); + } + }); return; } QJsonArray endpointsArray = QJsonDocument::fromJson(responseBody).array(); QStringList endpoints; - for (const QJsonValue &endpoint : endpointsArray) + for (const QJsonValue &endpoint : endpointsArray) { endpoints.push_back(endpoint.toString()); + } QStringList shuffled = endpoints; std::random_device randomDevice; @@ -630,16 +746,26 @@ void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, co qDebug() << httpStatusCode; qDebug() << "go to the next storage endpoint"; reply->deleteLater(); - QMetaObject::invokeMethod( - this, [=]() { getProxyUrlsAsync(proxyStorageUrls, currentProxyStorageIndex + 1, onComplete); }, Qt::QueuedConnection); + QTimer::singleShot(0, ctl, [life, proxyStorageUrls, currentProxyStorageIndex, onComplete]() { + if (life) { + life->getProxyUrlsAsync(life, proxyStorageUrls, currentProxyStorageIndex + 1, onComplete); + } else { + onComplete({}); + } + }); }); } -void GatewayController::getProxyUrlAsync(const QStringList proxyUrls, const int currentProxyIndex, - std::function onComplete) +void GatewayController::getProxyUrlAsync(const QSharedPointer &life, const QStringList &proxyUrls, + const int currentProxyIndex, const std::function &onComplete) { + if (!life) { + onComplete(QString()); + return; + } + if (currentProxyIndex >= proxyUrls.size()) { - onComplete(""); + onComplete(QString()); return; } @@ -650,13 +776,16 @@ void GatewayController::getProxyUrlAsync(const QStringList proxyUrls, const int QNetworkReply *reply = amnApp->networkManager()->get(request); - // connect(reply, &QNetworkReply::sslErrors, this, [state](const QList &e) { - // *(state->sslErrors) = e; - // }); - - connect(reply, &QNetworkReply::finished, this, [this, proxyUrls, currentProxyIndex, onComplete, reply]() { + connect(reply, &QNetworkReply::finished, reply, [life, proxyUrls, currentProxyIndex, onComplete, reply]() { reply->deleteLater(); + if (!life) { + onComplete(QString()); + return; + } + + GatewayController *const ctl = life.data(); + if (reply->error() == QNetworkReply::NoError) { m_proxyUrl = proxyUrls[currentProxyIndex]; onComplete(m_proxyUrl); @@ -664,15 +793,28 @@ void GatewayController::getProxyUrlAsync(const QStringList proxyUrls, const int } qDebug() << "go to the next proxy endpoint"; - QMetaObject::invokeMethod(this, [=]() { getProxyUrlAsync(proxyUrls, currentProxyIndex + 1, onComplete); }, Qt::QueuedConnection); + QTimer::singleShot(0, ctl, [life, proxyUrls, currentProxyIndex, onComplete]() { + if (life) { + life->getProxyUrlAsync(life, proxyUrls, currentProxyIndex + 1, onComplete); + } else { + onComplete(QString()); + } + }); }); } void GatewayController::bypassProxyAsync( - const QString &endpoint, const QString &proxyUrl, EncryptedRequestData encRequestData, - std::function &, QNetworkReply::NetworkError, const QString &, int)> onComplete) + const QSharedPointer &life, const QString &endpoint, const QString &proxyUrl, + const EncryptedRequestData &encRequestData, + const std::function &, QNetworkReply::NetworkError, const QString &, int)> + &onComplete) { auto sslErrors = QSharedPointer>::create(); + if (!life) { + onComplete(QByteArray(), false, *sslErrors, QNetworkReply::InternalServerError, QStringLiteral("gateway gone"), 0); + return; + } + if (proxyUrl.isEmpty()) { onComplete(QByteArray(), false, *sslErrors, QNetworkReply::InternalServerError, "empty proxy url", 0); return; @@ -683,9 +825,9 @@ void GatewayController::bypassProxyAsync( QNetworkReply *reply = amnApp->networkManager()->post(request, encRequestData.requestBody); - connect(reply, &QNetworkReply::sslErrors, this, [sslErrors](const QList &errors) { *sslErrors = errors; }); + connect(reply, &QNetworkReply::sslErrors, reply, [sslErrors](const QList &errors) { *sslErrors = errors; }); - connect(reply, &QNetworkReply::finished, this, [sslErrors, onComplete, encRequestData, reply, this]() { + connect(reply, &QNetworkReply::finished, reply, [life, sslErrors, onComplete, encRequestData, reply]() { QByteArray encryptedResponseBody = reply->readAll(); QString replyErrorString = reply->errorString(); auto replyError = reply->error(); @@ -693,8 +835,13 @@ void GatewayController::bypassProxyAsync( reply->deleteLater(); - auto decryptionResult = - tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt); + if (!life) { + onComplete(QByteArray(), false, *sslErrors, QNetworkReply::InternalServerError, QStringLiteral("gateway gone"), 0); + return; + } + + auto decryptionResult = life->resolveResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, + encRequestData.salt); onComplete(decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful, *sslErrors, replyError, replyErrorString, httpStatusCode); diff --git a/client/core/controllers/gatewayController.h b/client/core/controllers/gatewayController.h index ef29947096..d01bb9d7f3 100644 --- a/client/core/controllers/gatewayController.h +++ b/client/core/controllers/gatewayController.h @@ -1,6 +1,8 @@ #ifndef GATEWAYCONTROLLER_H #define GATEWAYCONTROLLER_H +#include + #include #include #include @@ -25,7 +27,9 @@ class GatewayController : public QObject const bool isStrictKillSwitchEnabled, QObject *parent = nullptr); amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody); - QFuture> postAsync(const QString &endpoint, const QJsonObject apiPayload); + QFuture> postAsync(const QString &endpoint, const QJsonObject &apiPayload, + QNetworkReply **activeReplyOut = nullptr, + const QSharedPointer &keepAlive = {}); private: struct EncryptedRequestData @@ -36,6 +40,7 @@ class GatewayController : public QObject QByteArray iv; QByteArray salt; amnezia::ErrorCode errorCode; + bool isPlaintextLocalGateway = false; }; struct DecryptionResult @@ -47,6 +52,8 @@ class GatewayController : public QObject EncryptedRequestData prepareRequest(const QString &endpoint, const QJsonObject &apiPayload); DecryptionResult tryDecryptResponseBody(const QByteArray &encryptedResponseBody, QNetworkReply::NetworkError replyError, const QByteArray &key, const QByteArray &iv, const QByteArray &salt); + DecryptionResult resolveResponseBody(const QByteArray &responseBody, QNetworkReply::NetworkError replyError, const QByteArray &key, + const QByteArray &iv, const QByteArray &salt); QStringList getProxyUrls(const QString &serviceType, const QString &userCountryCode); bool shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody, bool isDecryptionSuccessful); @@ -54,12 +61,13 @@ class GatewayController : public QObject std::function requestFunction, std::function &sslErrors)> replyProcessingFunction); - void getProxyUrlsAsync(const QStringList proxyStorageUrls, const int currentProxyStorageIndex, - std::function onComplete); - void getProxyUrlAsync(const QStringList proxyUrls, const int currentProxyIndex, std::function onComplete); + void getProxyUrlsAsync(const QSharedPointer &life, const QStringList &proxyStorageUrls, int currentProxyStorageIndex, + const std::function &onComplete); + void getProxyUrlAsync(const QSharedPointer &life, const QStringList &proxyUrls, int currentProxyIndex, + const std::function &onComplete); void bypassProxyAsync( - const QString &endpoint, const QString &proxyUrl, EncryptedRequestData encRequestData, - std::function &, QNetworkReply::NetworkError, const QString &, int)> onComplete); + const QSharedPointer &life, const QString &endpoint, const QString &proxyUrl, const EncryptedRequestData &encRequestData, + const std::function &, QNetworkReply::NetworkError, const QString &, int)> &onComplete); int m_requestTimeoutMsecs; QString m_gatewayEndpoint; diff --git a/client/core/controllers/updateController.cpp b/client/core/controllers/updateController.cpp index de7106c000..897e877dd7 100644 --- a/client/core/controllers/updateController.cpp +++ b/client/core/controllers/updateController.cpp @@ -57,6 +57,10 @@ void UpdateController::checkForUpdates() if (m_updateCheckRunning || !m_appSettingsRepository) { return; } + + if (m_appSettingsRepository->isDevGatewayEnv()) { + return; + } m_updateCheckRunning = true; fetchGatewayUrl(); @@ -93,6 +97,11 @@ void UpdateController::doGetAsync(const QString &endpoint, std::functionisDevGatewayEnv()) { + finishUpdateCheck(); + return; + } + auto gatewayController = QSharedPointer::create(m_appSettingsRepository->getGatewayEndpoint(), m_appSettingsRepository->isDevGatewayEnv(), 7000, @@ -105,11 +114,19 @@ void UpdateController::fetchGatewayUrl() // Workaround: wait before contacting gateway to avoid rate limit triggered by other requests (news etc.) QTimer::singleShot(1000, this, [this, gatewayController, apiPayload]() { - gatewayController->postAsync(QStringLiteral("%1v1/updater_endpoint"), apiPayload) + if (!m_appSettingsRepository || m_appSettingsRepository->isDevGatewayEnv()) { + finishUpdateCheck(); + return; + } + gatewayController->postAsync(QStringLiteral("%1v1/updater_endpoint"), apiPayload, nullptr, gatewayController) .then(this, [this](QPair result) { auto [err, gatewayResponse] = result; if (err != ErrorCode::NoError) { - logger.error() << errorString(err); + if (err == ErrorCode::ApiNotFoundError) { + logger.debug() << "Update check: updater_endpoint not found on gateway"; + } else { + logger.error() << errorString(err); + } finishUpdateCheck(); return; } diff --git a/client/core/utils/api/apiUtils.cpp b/client/core/utils/api/apiUtils.cpp index 4555d80dcf..7882f94914 100644 --- a/client/core/utils/api/apiUtils.cpp +++ b/client/core/utils/api/apiUtils.cpp @@ -77,6 +77,26 @@ bool apiUtils::isSubscriptionExpiringSoon(const QString &subscriptionEndDate, in return endDate <= nowUtc.addDays(withinDays); } +amnezia::ErrorCode apiUtils::errorCodeFromGatewayJsonHttpStatus(const QJsonObject &jsonObj) +{ + if (!jsonObj.contains(QStringLiteral("http_status"))) { + return amnezia::ErrorCode::NoError; + } + const int st = jsonObj.value(QStringLiteral("http_status")).toInt(-1); + switch (st) { + case 200: return amnezia::ErrorCode::NoError; + case 400: return amnezia::ErrorCode::ApiConfigEmptyError; + case 403: return amnezia::ErrorCode::ApiPairingForbiddenError; + case 404: return amnezia::ErrorCode::ApiNotFoundError; + case 408: return amnezia::ErrorCode::ApiConfigTimeoutError; + case 409: return amnezia::ErrorCode::ApiPairingConflictError; + case 429: return amnezia::ErrorCode::ApiPairingRateLimitedError; + case 500: return amnezia::ErrorCode::ApiConfigDownloadError; + case 503: return amnezia::ErrorCode::ApiPairingServiceUnavailableError; + default: return amnezia::ErrorCode::ApiConfigDownloadError; + } +} + amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &sslErrors, const QString &replyErrorString, const QNetworkReply::NetworkError &replyError, const int httpStatusCode, const QByteArray &responseBody) @@ -133,9 +153,28 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl if (httpStatusFromBody == httpStatusCodePaymentRequired) { return amnezia::ErrorCode::ApiSubscriptionNotActiveError; } + + const QString msg = apiErrorMessageFromJson(jsonObj); + if (msg.contains(QStringLiteral("QR session"), Qt::CaseInsensitive) + && (msg.contains(QStringLiteral("not found"), Qt::CaseInsensitive) + || msg.contains(QStringLiteral("expired"), Qt::CaseInsensitive))) { + return amnezia::ErrorCode::ApiPairingSessionExpiredError; + } + if (msg.contains(QStringLiteral("not found"), Qt::CaseInsensitive) + || msg.contains(QStringLiteral("expired"), Qt::CaseInsensitive)) { + return amnezia::ErrorCode::ApiNotFoundError; + } + if (httpStatusCode == httpStatusCodeNotFound) { + return amnezia::ErrorCode::ApiNotFoundError; + } + return amnezia::ErrorCode::ApiConfigDownloadError; } + if (httpStatusCode == httpStatusCodeNotFound) { + return amnezia::ErrorCode::ApiNotFoundError; + } + qDebug() << "something went wrong"; return amnezia::ErrorCode::ApiConfigDownloadError; } diff --git a/client/core/utils/api/apiUtils.h b/client/core/utils/api/apiUtils.h index c601ec895b..9caef883fe 100644 --- a/client/core/utils/api/apiUtils.h +++ b/client/core/utils/api/apiUtils.h @@ -23,6 +23,8 @@ namespace apiUtils const QNetworkReply::NetworkError &replyError, const int httpStatusCode, const QByteArray &responseBody); + amnezia::ErrorCode errorCodeFromGatewayJsonHttpStatus(const QJsonObject &jsonObj); + QString getPremiumV1VpnKey(const QJsonObject &serverConfigObject); QString getPremiumV2VpnKey(const QJsonObject &serverConfigObject); diff --git a/client/core/utils/constants/apiKeys.h b/client/core/utils/constants/apiKeys.h index 46833706c7..388bc42a93 100644 --- a/client/core/utils/constants/apiKeys.h +++ b/client/core/utils/constants/apiKeys.h @@ -22,6 +22,7 @@ namespace apiDefs constexpr QLatin1String availableCountries("available_countries"); constexpr QLatin1String installationUuid("installation_uuid"); constexpr QLatin1String uuid("installation_uuid"); + constexpr QLatin1String qrUuid("qr_uuid"); constexpr QLatin1String osVersion("os_version"); constexpr QLatin1String userCountryCode("user_country_code"); constexpr QLatin1String serverCountryCode("server_country_code"); diff --git a/client/core/utils/errorCodes.h b/client/core/utils/errorCodes.h index 0750553be1..408bed0910 100644 --- a/client/core/utils/errorCodes.h +++ b/client/core/utils/errorCodes.h @@ -99,6 +99,15 @@ namespace amnezia ApiNoPurchasedSubscriptionsError = 1115, ApiTrialAlreadyUsedError = 1116, + // QR pairing (gateway /v1/generate_qr, /v1/scan_qr) + ApiPairingForbiddenError = 1117, + ApiPairingConflictError = 1118, + ApiPairingRateLimitedError = 1119, + ApiPairingServiceUnavailableError = 1120, + ApiPairingPayloadTooLargeError = 1121, + ApiPairingMissingMetadataError = 1122, + ApiPairingSessionExpiredError = 1123, + // QFile errors OpenError = 1200, ReadError = 1201, diff --git a/client/core/utils/errorStrings.cpp b/client/core/utils/errorStrings.cpp index 05e4813032..77f934fa6a 100644 --- a/client/core/utils/errorStrings.cpp +++ b/client/core/utils/errorStrings.cpp @@ -84,6 +84,13 @@ QString errorString(ErrorCode code) { 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 address has already been used to activate a trial"); break; + case (ErrorCode::ApiPairingForbiddenError): errorMessage = QObject::tr("QR pairing was rejected (forbidden)"); break; + case (ErrorCode::ApiPairingConflictError): errorMessage = QObject::tr("This QR code has already been used"); break; + case (ErrorCode::ApiPairingRateLimitedError): errorMessage = QObject::tr("Too many requests. Please try again later"); break; + case (ErrorCode::ApiPairingServiceUnavailableError): errorMessage = QObject::tr("Service temporarily unavailable. Please try again later"); break; + case (ErrorCode::ApiPairingPayloadTooLargeError): errorMessage = QObject::tr("QR pairing data is too large to send"); break; + case (ErrorCode::ApiPairingMissingMetadataError): errorMessage = QObject::tr("This subscription is missing data required to transfer via QR (service type or country). Refresh the subscription or pick another server."); break; + case (ErrorCode::ApiPairingSessionExpiredError): errorMessage = QObject::tr("The QR code session has ended. Show a new QR code on the other device and scan again."); break; // QFile errors case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break; diff --git a/client/core/utils/qrCodeUtils.cpp b/client/core/utils/qrCodeUtils.cpp index a18af1726b..bff9ded91b 100644 --- a/client/core/utils/qrCodeUtils.cpp +++ b/client/core/utils/qrCodeUtils.cpp @@ -3,6 +3,14 @@ #include #include +QList qrCodeUtils::generateQrCodeImageSeriesPlainText(const QByteArray &utf8Text) +{ + const QString text = QString::fromUtf8(utf8Text); + qrcodegen::QrCode qr = qrcodegen::QrCode::encodeText(text.toUtf8().constData(), qrcodegen::QrCode::Ecc::LOW); + const QString svg = QString::fromStdString(toSvgString(qr, 1)); + return { svgToBase64(svg) }; +} + QList qrCodeUtils::generateQrCodeImageSeries(const QByteArray &data) { double k = 850; diff --git a/client/core/utils/qrCodeUtils.h b/client/core/utils/qrCodeUtils.h index cda0723b2d..e2bd93828a 100644 --- a/client/core/utils/qrCodeUtils.h +++ b/client/core/utils/qrCodeUtils.h @@ -10,6 +10,7 @@ namespace qrCodeUtils constexpr const qint16 qrMagicCode = 1984; QList generateQrCodeImageSeries(const QByteArray &data); + QList generateQrCodeImageSeriesPlainText(const QByteArray &utf8Text); qrcodegen::QrCode generateQrCode(const QByteArray &data); QString svgToBase64(const QString &image); }; diff --git a/client/platforms/android/android_controller.cpp b/client/platforms/android/android_controller.cpp index c7f9721320..ca8792db8a 100644 --- a/client/platforms/android/android_controller.cpp +++ b/client/platforms/android/android_controller.cpp @@ -9,6 +9,7 @@ #include "android_controller.h" #include "android_utils.h" #include "ui/controllers/importUiController.h" +#include "ui/controllers/api/pairingUiController.h" namespace { @@ -103,7 +104,10 @@ bool AndroidController::initialize() {"onImeInsetsChanged", "(I)V", reinterpret_cast(onImeInsetsChanged)}, {"onSystemBarsInsetsChanged", "(II)V", reinterpret_cast(onSystemBarsInsetsChanged)}, {"onActivityPaused", "()V", reinterpret_cast(onActivityPaused)}, - {"onActivityResumed", "()V", reinterpret_cast(onActivityResumed)} + {"onActivityResumed", "()V", reinterpret_cast(onActivityResumed)}, + {"onCameraPermissionResult", "(Z)V", reinterpret_cast(onCameraPermissionResult)}, + {"onPairingQrCameraClosed", "()V", reinterpret_cast(onPairingQrCameraClosed)}, + {"onPairingQrCameraUserDismissed", "()V", reinterpret_cast(onPairingQrCameraUserDismissed)} }; QJniEnvironment env; @@ -201,6 +205,21 @@ bool AndroidController::isCameraPresent() return callActivityMethod("isCameraPresent", "()Z"); } +bool AndroidController::isCameraPermissionGranted() +{ + return callActivityMethod("isCameraPermissionGranted", "()Z"); +} + +void AndroidController::requestCameraPermissionForQrPairing() +{ + callActivityMethod("requestCameraPermissionForQrPairing", "()V"); +} + +void AndroidController::openApplicationDetailsSettings() +{ + callActivityMethod("openApplicationDetailsSettings", "()V"); +} + bool AndroidController::isOnTv() { return callActivityMethod("isOnTv", "()Z"); @@ -226,6 +245,11 @@ void AndroidController::startQrReaderActivity() callActivityMethod("startQrCodeReader", "()V"); } +void AndroidController::startPairingQrReaderActivity() +{ + callActivityMethod("startPairingQrCodeReader", "()V"); +} + void AndroidController::setSaveLogs(bool enabled) { callActivityMethod("setSaveLogs", "(Z)V", enabled); @@ -538,7 +562,11 @@ bool AndroidController::decodeQrCode(JNIEnv *env, jobject thiz, jstring data) { Q_UNUSED(thiz); - return ImportUiController::decodeQrCode(AndroidUtils::convertJString(env, data)); + const QString code = AndroidUtils::convertJString(env, data); + if (PairingUiController::tryConsumeAndroidQrScan(code)) { + return true; + } + return ImportUiController::decodeQrCode(code); } // static void AndroidController::onImeInsetsChanged(JNIEnv *env, jobject thiz, jint heightDp) @@ -578,4 +606,31 @@ void AndroidController::onActivityResumed(JNIEnv *env, jobject thiz) emit AndroidController::instance()->activityResumed(); } +// static +void AndroidController::onCameraPermissionResult(JNIEnv *env, jobject thiz, jboolean granted) +{ + Q_UNUSED(env); + Q_UNUSED(thiz); + + emit AndroidController::instance()->cameraPermissionResult(static_cast(granted)); +} + +// static +void AndroidController::onPairingQrCameraClosed(JNIEnv *env, jobject thiz) +{ + Q_UNUSED(env); + Q_UNUSED(thiz); + + PairingUiController::notifyAndroidPairingQrCameraClosed(); +} + +// static +void AndroidController::onPairingQrCameraUserDismissed(JNIEnv *env, jobject thiz) +{ + Q_UNUSED(env); + Q_UNUSED(thiz); + + PairingUiController::notifyAndroidPairingQrCameraUserDismissed(); +} + diff --git a/client/platforms/android/android_controller.h b/client/platforms/android/android_controller.h index 2294cc78b8..a694597a66 100644 --- a/client/platforms/android/android_controller.h +++ b/client/platforms/android/android_controller.h @@ -38,11 +38,15 @@ class AndroidController : public QObject void closeFd(); QString getFileName(const QString &uri); bool isCameraPresent(); + bool isCameraPermissionGranted(); + void requestCameraPermissionForQrPairing(); + void openApplicationDetailsSettings(); bool isOnTv(); bool isEdgeToEdgeEnabled(); int getStatusBarHeight(); int getNavigationBarHeight(); void startQrReaderActivity(); + void startPairingQrReaderActivity(); void setSaveLogs(bool enabled); void exportLogsFile(const QString &fileName); void clearLogs(); @@ -77,6 +81,7 @@ class AndroidController : public QObject void systemBarsInsetsChanged(int navBarHeightDp, int statusBarHeightDp); void activityPaused(); void activityResumed(); + void cameraPermissionResult(bool granted); private: bool isWaitingStatus = true; @@ -109,6 +114,9 @@ class AndroidController : public QObject 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); + static void onCameraPermissionResult(JNIEnv *env, jobject thiz, jboolean granted); + static void onPairingQrCameraClosed(JNIEnv *env, jobject thiz); + static void onPairingQrCameraUserDismissed(JNIEnv *env, jobject thiz); template static auto callActivityMethod(const char *methodName, const char *signature, Args &&...args); diff --git a/client/platforms/ios/QRCodeReaderBase.cpp b/client/platforms/ios/QRCodeReaderBase.cpp index c3148a854a..7e7c2dbd8b 100644 --- a/client/platforms/ios/QRCodeReaderBase.cpp +++ b/client/platforms/ios/QRCodeReaderBase.cpp @@ -12,3 +12,4 @@ QRect QRCodeReader::cameraSize() { void QRCodeReader::startReading() {} void QRCodeReader::stopReading() {} void QRCodeReader::setCameraSize(QRect) {} +void QRCodeReader::notifyCodeRead(const QString &) {} diff --git a/client/platforms/ios/QRCodeReaderBase.h b/client/platforms/ios/QRCodeReaderBase.h index 29a4946d8e..ec83d2e515 100644 --- a/client/platforms/ios/QRCodeReaderBase.h +++ b/client/platforms/ios/QRCodeReaderBase.h @@ -16,6 +16,7 @@ public slots: void startReading(); void stopReading(); void setCameraSize(QRect value); + void notifyCodeRead(const QString &code); signals: void codeReaded(QString code); diff --git a/client/platforms/ios/QRCodeReaderBase.mm b/client/platforms/ios/QRCodeReaderBase.mm index 963c35a85d..6a03fe3300 100644 --- a/client/platforms/ios/QRCodeReaderBase.mm +++ b/client/platforms/ios/QRCodeReaderBase.mm @@ -1,16 +1,56 @@ #if !MACOS_NE #include "QRCodeReaderBase.h" +#include + #import #import +static UIWindow *amneziaKeyWindowForQrCamera(void) +{ + UIApplication *app = [UIApplication sharedApplication]; + + if (@available(iOS 13.0, *)) { + for (UIScene *scene in app.connectedScenes) { + if (scene.activationState != UISceneActivationStateForegroundActive) { + continue; + } + if (![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + UIWindowScene *windowScene = (UIWindowScene *)scene; + for (UIWindow *window in windowScene.windows) { + if (window.isKeyWindow) { + return window; + } + } + for (UIWindow *window in windowScene.windows) { + if (!window.isHidden) { + return window; + } + } + } + } + + if (app.keyWindow) { + return app.keyWindow; + } + for (UIWindow *window in app.windows) { + if (window.isKeyWindow) { + return window; + } + } + return app.windows.firstObject; +} + @interface QRCodeReaderImpl : UIViewController @end @interface QRCodeReaderImpl () -@property (nonatomic) QRCodeReader* qrCodeReader; -@property (nonatomic, strong) AVCaptureSession *captureSession; -@property (nonatomic, strong) AVCaptureVideoPreviewLayer *videoPreviewPlayer; +@property (nonatomic, assign) QRCodeReader *qrCodeReader; +@property (nonatomic, retain) AVCaptureSession *captureSession; +@property (nonatomic, retain) AVCaptureVideoPreviewLayer *videoPreviewPlayer; +@property (nonatomic) dispatch_queue_t sessionQueue; @end @@ -19,61 +59,115 @@ @implementation QRCodeReaderImpl - (void)viewDidLoad { [super viewDidLoad]; - _captureSession = nil; + self.captureSession = nil; + if (!_sessionQueue) { + _sessionQueue = dispatch_queue_create("org.amnezia.qr.session", DISPATCH_QUEUE_SERIAL); + } } -- (void)setQrCodeReader: (QRCodeReader*)value { +- (void)setQrCodeReader:(QRCodeReader *)value { _qrCodeReader = value; } -- (BOOL)startReading { - NSError *error; +- (BOOL)startReadingOnMainThread { + [self stopReadingOnMainThread]; + + NSError *error = nil; - AVCaptureDevice *captureDevice = [AVCaptureDevice defaultDeviceWithMediaType: AVMediaTypeVideo]; - AVCaptureDeviceInput *deviceInput = [AVCaptureDeviceInput deviceInputWithDevice: captureDevice error: &error]; + AVCaptureDevice *captureDevice = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; + if (!captureDevice) { + return NO; + } - if(!deviceInput) { - NSLog(@"Error %@", error.localizedDescription); + AVCaptureDeviceInput *deviceInput = [AVCaptureDeviceInput deviceInputWithDevice:captureDevice error:&error]; + + if (!deviceInput) { return NO; } - _captureSession = [[AVCaptureSession alloc]init]; - [_captureSession addInput:deviceInput]; + AVCaptureSession *session = [[AVCaptureSession alloc] init]; + [session addInput:deviceInput]; AVCaptureMetadataOutput *capturedMetadataOutput = [[AVCaptureMetadataOutput alloc] init]; - [_captureSession addOutput:capturedMetadataOutput]; + [session addOutput:capturedMetadataOutput]; - dispatch_queue_t dispatchQueue; - dispatchQueue = dispatch_queue_create("myQueue", NULL); - [capturedMetadataOutput setMetadataObjectsDelegate: self queue: dispatchQueue]; - [capturedMetadataOutput setMetadataObjectTypes: [NSArray arrayWithObject:AVMetadataObjectTypeQRCode]]; + if (!_sessionQueue) { + _sessionQueue = dispatch_queue_create("org.amnezia.qr.session", DISPATCH_QUEUE_SERIAL); + } + [capturedMetadataOutput setMetadataObjectsDelegate:self queue:_sessionQueue]; + [capturedMetadataOutput setMetadataObjectTypes:[NSArray arrayWithObject:AVMetadataObjectTypeQRCode]]; - _videoPreviewPlayer = [[AVCaptureVideoPreviewLayer alloc] initWithSession: _captureSession]; - - CGFloat statusBarHeight = [UIApplication sharedApplication].statusBarFrame.size.height; + self.captureSession = session; + [session release]; - QRect cameraRect = _qrCodeReader->cameraSize(); - CGRect cameraCGRect = CGRectMake(cameraRect.x(), - cameraRect.y() + statusBarHeight, - cameraRect.width(), - cameraRect.height()); + AVCaptureVideoPreviewLayer *preview = [[AVCaptureVideoPreviewLayer alloc] initWithSession:self.captureSession]; + [preview setVideoGravity:AVLayerVideoGravityResizeAspectFill]; + self.videoPreviewPlayer = preview; + [preview release]; - [_videoPreviewPlayer setVideoGravity: AVLayerVideoGravityResizeAspectFill]; - [_videoPreviewPlayer setFrame: cameraCGRect]; + UIWindow *keyWindow = amneziaKeyWindowForQrCamera(); + if (!keyWindow) { + [self stopReadingOnMainThread]; + return NO; + } - CALayer* layer = [UIApplication sharedApplication].keyWindow.layer; - [layer addSublayer: _videoPreviewPlayer]; + CGRect bounds = keyWindow.bounds; + [self.videoPreviewPlayer setFrame:bounds]; + self.videoPreviewPlayer.zPosition = -1000.f; + [keyWindow.layer insertSublayer:self.videoPreviewPlayer atIndex:0]; - [_captureSession startRunning]; + AVCaptureSession *runningSession = self.captureSession; + dispatch_async(_sessionQueue, ^{ + [runningSession startRunning]; + }); return YES; } -- (void)stopReading { - [_captureSession stopRunning]; - _captureSession = nil; +- (BOOL)startReading { + if ([NSThread isMainThread]) { + return [self startReadingOnMainThread]; + } + __block BOOL ok = NO; + dispatch_sync(dispatch_get_main_queue(), ^{ + ok = [self startReadingOnMainThread]; + }); + return ok; +} + +- (void)stopReadingOnMainThread { + AVCaptureSession *session = self.captureSession; + self.captureSession = nil; - [_videoPreviewPlayer removeFromSuperlayer]; + if (session) { + if (!_sessionQueue) { + _sessionQueue = dispatch_queue_create("org.amnezia.qr.session", DISPATCH_QUEUE_SERIAL); + } + dispatch_sync(_sessionQueue, ^{ + @try { + if ([session isRunning]) { + [session stopRunning]; + } + } @catch (NSException *ex) { + NSLog(@"Session stopRunning exception: %@", ex); + } + }); + } + + if (self.videoPreviewPlayer) { + [self.videoPreviewPlayer removeFromSuperlayer]; + self.videoPreviewPlayer = nil; + } +} + +- (void)stopReading { + if ([NSThread isMainThread]) { + [self stopReadingOnMainThread]; + } else { + dispatch_sync(dispatch_get_main_queue(), ^{ + [self stopReadingOnMainThread]; + }); + } } - (void)captureOutput:(AVCaptureOutput *)output didOutputMetadataObjects:(NSArray<__kindof AVMetadataObject *> *)metadataObjects fromConnection:(AVCaptureConnection *)connection { @@ -82,7 +176,15 @@ - (void)captureOutput:(AVCaptureOutput *)output didOutputMetadataObjects:(NSArra AVMetadataMachineReadableCodeObject *metadataObject = [metadataObjects objectAtIndex:0]; if ([[metadataObject type] isEqualToString: AVMetadataObjectTypeQRCode]) { - _qrCodeReader->emit codeReaded([metadataObject stringValue].UTF8String); + NSString *value = [metadataObject stringValue]; + if (value.length == 0) { + return; + } + QRCodeReader *cpp = _qrCodeReader; + const QByteArray utf8([value UTF8String]); + dispatch_async(dispatch_get_main_queue(), ^{ + cpp->notifyCodeRead(QString::fromUtf8(utf8)); + }); } } } @@ -109,6 +211,10 @@ - (void)captureOutput:(AVCaptureOutput *)output didOutputMetadataObjects:(NSArra void QRCodeReader::stopReading() { [m_qrCodeReader stopReading]; } + +void QRCodeReader::notifyCodeRead(const QString &code) { + emit codeReaded(code); +} #else #include "QRCodeReaderBase.h" @@ -124,4 +230,5 @@ - (void)captureOutput:(AVCaptureOutput *)output didOutputMetadataObjects:(NSArra void QRCodeReader::startReading() {} void QRCodeReader::stopReading() {} void QRCodeReader::setCameraSize(QRect) {} +void QRCodeReader::notifyCodeRead(const QString &) {} #endif diff --git a/client/platforms/ios/iosPairingCameraAccess.h b/client/platforms/ios/iosPairingCameraAccess.h new file mode 100644 index 0000000000..a6af9a89d5 --- /dev/null +++ b/client/platforms/ios/iosPairingCameraAccess.h @@ -0,0 +1,10 @@ +#ifndef IOS_PAIRING_CAMERA_ACCESS_H +#define IOS_PAIRING_CAMERA_ACCESS_H + +#include + +bool amneziaIosPairingCameraAccessGranted(); +void amneziaIosRequestPairingCameraAccess(const std::function &onDone); +void amneziaIosOpenApplicationSettings(); + +#endif diff --git a/client/platforms/ios/iosPairingCameraAccess.mm b/client/platforms/ios/iosPairingCameraAccess.mm new file mode 100644 index 0000000000..6f937c193a --- /dev/null +++ b/client/platforms/ios/iosPairingCameraAccess.mm @@ -0,0 +1,37 @@ +#include "platforms/ios/iosPairingCameraAccess.h" + +#import +#import + +bool amneziaIosPairingCameraAccessGranted() +{ + const AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]; + return status == AVAuthorizationStatusAuthorized; +} + +void amneziaIosRequestPairingCameraAccess(const std::function &onDone) +{ + const AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]; + if (status == AVAuthorizationStatusAuthorized) { + onDone(true); + return; + } + if (status == AVAuthorizationStatusDenied || status == AVAuthorizationStatusRestricted) { + onDone(false); + return; + } + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo + completionHandler:^(BOOL granted) { + dispatch_async(dispatch_get_main_queue(), ^{ + onDone(static_cast(granted)); + }); + }]; +} + +void amneziaIosOpenApplicationSettings() +{ + NSURL *url = [NSURL URLWithString:UIApplicationOpenSettingsURLString]; + if (url != nil) { + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; + } +} diff --git a/client/platforms/ios/iosPairingCameraAccess_stub.cpp b/client/platforms/ios/iosPairingCameraAccess_stub.cpp new file mode 100644 index 0000000000..e331914d63 --- /dev/null +++ b/client/platforms/ios/iosPairingCameraAccess_stub.cpp @@ -0,0 +1,13 @@ +#include "platforms/ios/iosPairingCameraAccess.h" + +bool amneziaIosPairingCameraAccessGranted() +{ + return true; +} + +void amneziaIosRequestPairingCameraAccess(const std::function &onDone) +{ + onDone(true); +} + +void amneziaIosOpenApplicationSettings() {} diff --git a/client/platforms/ios/iosPairingQrOverlayWindow.h b/client/platforms/ios/iosPairingQrOverlayWindow.h new file mode 100644 index 0000000000..67a8159f6e --- /dev/null +++ b/client/platforms/ios/iosPairingQrOverlayWindow.h @@ -0,0 +1,16 @@ +#ifndef IOS_PAIRING_QR_OVERLAY_WINDOW_H +#define IOS_PAIRING_QR_OVERLAY_WINDOW_H + +#include +#include + +using AmneziaPairingQrScannedUtf8Handler = std::function; +using AmneziaPairingQrOverlayBackHandler = std::function; + +void amneziaIosPairingQrOverlayPresent(AmneziaPairingQrScannedUtf8Handler onScanned, AmneziaPairingQrOverlayBackHandler onBack, + const std::string &titleUtf8, const std::string &subtitleUtf8); +void amneziaIosPairingQrOverlayDismiss(); +void amneziaIosPairingQrOverlaySetTorchEnabled(bool on); +void amneziaIosPairingQrOverlayRestartCapture(); + +#endif diff --git a/client/platforms/ios/iosPairingQrOverlayWindow.mm b/client/platforms/ios/iosPairingQrOverlayWindow.mm new file mode 100644 index 0000000000..ca4db9da13 --- /dev/null +++ b/client/platforms/ios/iosPairingQrOverlayWindow.mm @@ -0,0 +1,836 @@ +#include "platforms/ios/iosPairingQrOverlayWindow.h" + +#import +#import +#import +#import + +#include + +static const CGFloat kAmneziaPairingQrOverlayWindowLevel = (CGFloat)UIWindowLevelAlert + 1000.f; + +static AmneziaPairingQrScannedUtf8Handler gOnScanned; +static AmneziaPairingQrOverlayBackHandler gOnBack; +static UIWindow *gPairingQrOverlayWindow = nil; +static bool gTorchRequested = false; +static CFAbsoluteTime gPairingQrOverlayKeySince = -1.0; + +static UIWindowScene *amneziaForegroundWindowScene(void) +{ + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + if (scene.activationState == UISceneActivationStateForegroundActive + && [scene isKindOfClass:[UIWindowScene class]]) { + return (UIWindowScene *)scene; + } + } + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + return (UIWindowScene *)scene; + } + } + return nil; +} + +static UIWindow *amneziaPickQtAppWindowToRestore(void) +{ + UIWindow *best = nil; + for (UIWindow *cw in UIApplication.sharedApplication.windows) { + if (cw == gPairingQrOverlayWindow || cw.hidden) { + continue; + } + if (cw.windowScene && cw.windowLevel <= UIWindowLevelNormal + 1) { + if (!best || cw.isKeyWindow) { + best = cw; + } + } + } + return best; +} + +static CGFloat amneziaPairingQrBottomTabStripReserve(UIWindowScene *scene) +{ + Class qios = NSClassFromString(@"QIOSViewController"); + if (!qios) { + return 83.f; + } + for (UIWindow *cw in scene.windows) { + if (!cw.rootViewController) { + continue; + } + if ([cw.rootViewController isKindOfClass:qios]) { + const CGFloat inset = cw.safeAreaInsets.bottom; + const CGFloat reserve = inset + 49.f; + return MIN(MAX(reserve, 72.f), 140.f); + } + } + return 83.f; +} + +static void amneziaApplyReadableOverCameraShadow(UIView *v) +{ + v.layer.shadowColor = [UIColor blackColor].CGColor; + v.layer.shadowOffset = CGSizeMake(0, 1); + v.layer.shadowRadius = 4; + v.layer.shadowOpacity = 0.9; + v.layer.masksToBounds = NO; +} + +static UIColor *amneziaPaleGray(void) +{ + return [UIColor colorWithRed:(CGFloat)0xD7 / 255.0 green:(CGFloat)0xD8 / 255.0 blue:(CGFloat)0xDB / 255.0 alpha:1.0]; +} + +static void amneziaAddCornerMinorArc(UIBezierPath *p, CGPoint C, CGFloat r, CGPoint S, CGPoint E) +{ + const CGFloat as = atan2f((float)(S.y - C.y), (float)(S.x - C.x)); + CGFloat ae = atan2f((float)(E.y - C.y), (float)(E.x - C.x)); + while (ae - as > (CGFloat)M_PI) { + ae -= (CGFloat)(2.0 * M_PI); + } + while (ae - as < (CGFloat)(-M_PI)) { + ae += (CGFloat)(2.0 * M_PI); + } + const CGFloat minor = ae - as; + const BOOL cw = minor > 0; + [p addArcWithCenter:C radius:r startAngle:as endAngle:ae clockwise:cw]; +} + +static UIBezierPath *amneziaScanBracketStrokePath(int corner, CGFloat x0, CGFloat y0, CGFloat s, CGFloat R, CGFloat L, CGFloat t) +{ + const CGFloat r = MAX(1.5, R - t * 0.5); + UIBezierPath *p = [UIBezierPath bezierPath]; + const CGFloat yy = y0 + t * 0.5f; + const CGFloat yyb = y0 + s - t * 0.5f; + const CGFloat xx = x0 + t * 0.5f; + const CGFloat xxb = x0 + s - t * 0.5f; + + switch (corner) { + case 0: { + const CGPoint cTL = CGPointMake(x0 + R, y0 + R); + const CGPoint sTL = CGPointMake(x0 + R, yy); + const CGPoint eTL = CGPointMake(xx, y0 + R); + [p moveToPoint:CGPointMake(x0 + R + L, yy)]; + [p addLineToPoint:sTL]; + amneziaAddCornerMinorArc(p, cTL, r, sTL, eTL); + const CGFloat yEndTL = MIN(y0 + R + L, y0 + s - R - t * 0.5f); + [p addLineToPoint:CGPointMake(xx, MAX(yEndTL, y0 + R + 2.f))]; + } break; + case 1: { + const CGPoint cTR = CGPointMake(x0 + s - R, y0 + R); + const CGPoint sTR = CGPointMake(x0 + s - R, yy); + const CGPoint eTR = CGPointMake(xxb, y0 + R); + [p moveToPoint:CGPointMake(x0 + s - R - L, yy)]; + [p addLineToPoint:sTR]; + amneziaAddCornerMinorArc(p, cTR, r, sTR, eTR); + const CGFloat yEndTR = MIN(y0 + R + L, y0 + s - R - t * 0.5f); + [p addLineToPoint:CGPointMake(xxb, MAX(yEndTR, y0 + R + 2.f))]; + } break; + case 2: { + const CGPoint cBL = CGPointMake(x0 + R, y0 + s - R); + const CGPoint sBL = CGPointMake(x0 + R, yyb); + const CGPoint eBL = CGPointMake(xx, y0 + s - R); + [p moveToPoint:CGPointMake(x0 + R + L, yyb)]; + [p addLineToPoint:sBL]; + amneziaAddCornerMinorArc(p, cBL, r, sBL, eBL); + const CGFloat yEndTopRef = MAX(MIN(y0 + R + L, y0 + s - R - t * 0.5f), y0 + R + 2.f); + const CGFloat yLegBL = y0 + s + y0 - yEndTopRef; + [p addLineToPoint:CGPointMake(xx, yLegBL)]; + } break; + case 3: { + const CGPoint cBR = CGPointMake(x0 + s - R, y0 + s - R); + const CGPoint sBR = CGPointMake(x0 + s - R, yyb); + const CGPoint eBR = CGPointMake(xxb, y0 + s - R); + [p moveToPoint:CGPointMake(x0 + s - R - L, yyb)]; + [p addLineToPoint:sBR]; + amneziaAddCornerMinorArc(p, cBR, r, sBR, eBR); + const CGFloat yEndTopRef = MAX(MIN(y0 + R + L, y0 + s - R - t * 0.5f), y0 + R + 2.f); + const CGFloat yLegBR = y0 + s + y0 - yEndTopRef; + [p addLineToPoint:CGPointMake(xxb, yLegBR)]; + } break; + default: + break; + } + return p; +} + +@interface AmneziaPairingQrOverlayViewController : UIViewController +@end + +@interface AmneziaPairingQrOverlayViewController () +@property (nonatomic, strong) AVCaptureSession *captureSession; +@property (nonatomic, strong) AVCaptureMetadataOutput *metadataOutput; +@property (nonatomic, strong) AVCaptureVideoPreviewLayer *previewLayer; +@property (nonatomic, strong) AVCaptureDevice *videoDevice; +@property (nonatomic, strong) dispatch_queue_t sessionQueue; +@property (nonatomic, strong) UIView *cameraContainer; +@property (nonatomic, strong) UIView *headerContainer; +@property (nonatomic, strong) UIButton *backButton; +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *subtitleLabel; +@property (nonatomic, strong) UIButton *torchButton; +@property (nonatomic, strong) NSLayoutConstraint *torchCenterYConstraint; +@property (nonatomic, copy) NSString *chromeTitleText; +@property (nonatomic, copy) NSString *chromeSubtitleText; +@property (nonatomic, strong) UIView *scanDimView; +@property (nonatomic, strong) CAShapeLayer *scanDimMaskLayer; +@property (nonatomic, strong) UIView *scanHoleFillView; +@property (nonatomic, strong) CAShapeLayer *scanHoleHighlightLayer; +@property (nonatomic, strong) UIView *bracketContainer; +@property (nonatomic, strong) NSMutableArray *bracketCornerLayers; +@end + +@implementation AmneziaPairingQrOverlayViewController + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.view.backgroundColor = [UIColor clearColor]; + if (!self.sessionQueue) { + self.sessionQueue = dispatch_queue_create("org.amnezia.pairingqr.overlay", DISPATCH_QUEUE_SERIAL); + } + [self buildChromeUi]; +} + +- (void)buildChromeUi +{ + if (self.headerContainer) { + return; + } + UIView *cam = [[UIView alloc] init]; + cam.translatesAutoresizingMaskIntoConstraints = NO; + cam.backgroundColor = [UIColor clearColor]; + cam.clipsToBounds = YES; + self.cameraContainer = cam; + [self.view addSubview:cam]; + + UIView *holeFill = [[UIView alloc] init]; + holeFill.translatesAutoresizingMaskIntoConstraints = NO; + holeFill.backgroundColor = [UIColor clearColor]; + holeFill.opaque = NO; + holeFill.userInteractionEnabled = NO; + self.scanHoleFillView = holeFill; + CAShapeLayer *hi = [CAShapeLayer layer]; + hi.fillColor = [UIColor colorWithWhite:1.0 alpha:0.14].CGColor; + hi.strokeColor = nil; + [holeFill.layer addSublayer:hi]; + self.scanHoleHighlightLayer = hi; + [self.view addSubview:holeFill]; + + UIView *dim = [[UIView alloc] init]; + dim.translatesAutoresizingMaskIntoConstraints = NO; + dim.backgroundColor = [UIColor colorWithWhite:0.02 alpha:0.55]; + dim.userInteractionEnabled = NO; + dim.opaque = NO; + self.scanDimView = dim; + [self.view addSubview:dim]; + + CAShapeLayer *dimMask = [CAShapeLayer layer]; + dimMask.fillRule = kCAFillRuleEvenOdd; + dimMask.fillColor = [UIColor blackColor].CGColor; + dim.layer.mask = dimMask; + self.scanDimMaskLayer = dimMask; + + UIView *bracketHost = [[UIView alloc] init]; + bracketHost.translatesAutoresizingMaskIntoConstraints = NO; + bracketHost.backgroundColor = [UIColor clearColor]; + bracketHost.opaque = NO; + bracketHost.userInteractionEnabled = NO; + self.bracketContainer = bracketHost; + [self.view addSubview:bracketHost]; + + self.bracketCornerLayers = [NSMutableArray arrayWithCapacity:4]; + for (NSInteger i = 0; i < 4; i++) { + CAShapeLayer *sl = [CAShapeLayer layer]; + sl.fillColor = nil; + sl.strokeColor = [UIColor colorWithWhite:0.94 alpha:1].CGColor; + sl.lineWidth = 5.0; + sl.lineCap = kCALineCapRound; + sl.lineJoin = kCALineJoinRound; + [bracketHost.layer addSublayer:sl]; + [self.bracketCornerLayers addObject:sl]; + } + + UIView *header = [[UIView alloc] init]; + header.translatesAutoresizingMaskIntoConstraints = NO; + header.backgroundColor = [UIColor clearColor]; + header.opaque = NO; + header.userInteractionEnabled = YES; + self.headerContainer = header; + [self.view addSubview:header]; + + UIButton *back = [UIButton buttonWithType:UIButtonTypeSystem]; + back.translatesAutoresizingMaskIntoConstraints = NO; + back.tintColor = amneziaPaleGray(); + if (@available(iOS 13.0, *)) { + const CGFloat kBackArrowPt = 20.0; + UIImageSymbolConfiguration *sym = + [UIImageSymbolConfiguration configurationWithPointSize:kBackArrowPt weight:UIImageSymbolWeightMedium + scale:UIImageSymbolScaleDefault]; + UIImage *img = [UIImage systemImageNamed:@"arrow.left" withConfiguration:sym]; + [back setImage:[img imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate] forState:UIControlStateNormal]; + } else { + [back setTitle:@"<" forState:UIControlStateNormal]; + } + [back addTarget:self action:@selector(backTapped) forControlEvents:UIControlEventTouchUpInside]; + self.backButton = back; + [header addSubview:back]; + + UILabel *title = [[UILabel alloc] init]; + title.translatesAutoresizingMaskIntoConstraints = NO; + title.textColor = [UIColor colorWithWhite:0.96 alpha:1]; + title.font = [UIFont systemFontOfSize:22 weight:UIFontWeightBold]; + title.numberOfLines = 0; + title.text = self.chromeTitleText.length ? self.chromeTitleText : @"Add device via QR"; + self.titleLabel = title; + [header addSubview:title]; + amneziaApplyReadableOverCameraShadow(title); + + UILabel *sub = [[UILabel alloc] init]; + sub.translatesAutoresizingMaskIntoConstraints = NO; + sub.textColor = [UIColor colorWithWhite:0.88 alpha:0.95]; + sub.font = [UIFont systemFontOfSize:14 weight:UIFontWeightRegular]; + sub.numberOfLines = 0; + sub.text = self.chromeSubtitleText.length + ? self.chromeSubtitleText + : @"Scan the session QR shown on the device you want to add."; + self.subtitleLabel = sub; + [header addSubview:sub]; + amneziaApplyReadableOverCameraShadow(sub); + + UIButton *torch = [UIButton buttonWithType:UIButtonTypeSystem]; + torch.translatesAutoresizingMaskIntoConstraints = NO; + [torch setTitle:@"πŸ”¦" forState:UIControlStateNormal]; + torch.titleLabel.font = [UIFont systemFontOfSize:26]; + torch.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.22]; + torch.layer.cornerRadius = 28; + torch.clipsToBounds = YES; + [torch addTarget:self action:@selector(torchTapped) forControlEvents:UIControlEventTouchUpInside]; + self.torchButton = torch; + [self.view addSubview:torch]; + + UILayoutGuide *safe = self.view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [cam.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [cam.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [cam.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [cam.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + + [holeFill.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [holeFill.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [holeFill.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [holeFill.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + + [dim.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [dim.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [dim.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [dim.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + + [bracketHost.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [bracketHost.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [bracketHost.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [bracketHost.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + + [header.topAnchor constraintEqualToAnchor:safe.topAnchor], + [header.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [header.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [header.heightAnchor constraintGreaterThanOrEqualToConstant:120], + + [back.leadingAnchor constraintEqualToAnchor:header.leadingAnchor constant:8], + [back.topAnchor constraintEqualToAnchor:header.topAnchor constant:20], + [back.widthAnchor constraintEqualToConstant:40], + [back.heightAnchor constraintEqualToConstant:40], + + [title.leadingAnchor constraintEqualToAnchor:header.leadingAnchor constant:16], + [title.trailingAnchor constraintEqualToAnchor:header.trailingAnchor constant:-16], + [title.topAnchor constraintEqualToAnchor:back.bottomAnchor], + + [sub.leadingAnchor constraintEqualToAnchor:title.leadingAnchor], + [sub.trailingAnchor constraintEqualToAnchor:title.trailingAnchor], + [sub.topAnchor constraintEqualToAnchor:title.bottomAnchor constant:8], + [sub.bottomAnchor constraintEqualToAnchor:header.bottomAnchor constant:-10], + + [torch.topAnchor constraintGreaterThanOrEqualToAnchor:header.bottomAnchor constant:8], + [torch.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], + [torch.widthAnchor constraintEqualToConstant:56], + [torch.heightAnchor constraintEqualToConstant:56], + ]]; + NSLayoutConstraint *torchCy = [torch.centerYAnchor constraintEqualToAnchor:self.view.topAnchor constant:200.0]; + self.torchCenterYConstraint = torchCy; + torchCy.active = YES; + [header setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisVertical]; + [header setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisVertical]; +} + +- (void)applyMetadataRectOfInterestForScanHole:(CGRect)holeInScanDimBounds +{ + if (!self.previewLayer || !self.metadataOutput || !self.scanDimView || !self.cameraContainer) { + return; + } + if (CGRectIsEmpty(holeInScanDimBounds) || holeInScanDimBounds.size.width < 24.0 || holeInScanDimBounds.size.height < 24.0) { + return; + } + + CGRect holeInCam = [self.scanDimView convertRect:holeInScanDimBounds toView:self.cameraContainer]; + holeInCam = CGRectIntersection(holeInCam, self.cameraContainer.bounds); + if (CGRectIsEmpty(holeInCam)) { + return; + } + + const CGRect plFrame = self.previewLayer.frame; + CGRect holeInPreview = CGRectOffset(holeInCam, -plFrame.origin.x, -plFrame.origin.y); + holeInPreview = CGRectIntersection(holeInPreview, self.previewLayer.bounds); + if (CGRectIsEmpty(holeInPreview)) { + return; + } + + CGRect roi = [self.previewLayer metadataOutputRectOfInterestForRect:holeInPreview]; + roi.origin.x = MAX(0.0, MIN(1.0, roi.origin.x)); + roi.origin.y = MAX(0.0, MIN(1.0, roi.origin.y)); + roi.size.width = MAX(0.02, MIN(1.0 - roi.origin.x, roi.size.width)); + roi.size.height = MAX(0.02, MIN(1.0 - roi.origin.y, roi.size.height)); + + AVCaptureMetadataOutput *mo = self.metadataOutput; + dispatch_queue_t sq = self.sessionQueue; + if (!mo || !sq) { + return; + } + dispatch_async(sq, ^{ + mo.rectOfInterest = roi; + }); +} + +- (void)layoutScanOverlayGeometry +{ + if (!self.scanDimView || !self.scanDimMaskLayer || !self.scanHoleHighlightLayer || self.bracketCornerLayers.count != 4) { + return; + } + const CGRect vb = self.scanDimView.bounds; + if (vb.size.width < 32 || vb.size.height < 32) { + return; + } + + CGFloat sqSz = floor(MIN(vb.size.width, vb.size.height) * 0.72); + CGFloat sqX = (vb.size.width - sqSz) / 2.0; + CGFloat sqY = (vb.size.height - sqSz) / 2.0; + + CGFloat headerBottom = CGRectGetMaxY(self.headerContainer.frame); + if (headerBottom < 8.0) { + headerBottom = 132.0 + self.view.safeAreaInsets.top; + } + sqY = MAX(sqY, headerBottom + 8.0); + + const CGFloat kBottomBandForTorch = 80.0; + const CGFloat maxHoleBottom = vb.size.height - kBottomBandForTorch; + if (sqY + sqSz > maxHoleBottom) { + sqY = maxHoleBottom - sqSz; + sqY = MAX(sqY, headerBottom + 8.0); + } + + sqX = MAX(8.0, MIN(sqX, vb.size.width - sqSz - 8.0)); + sqY = MAX(headerBottom + 4.0, MIN(sqY, vb.size.height - sqSz - 8.0)); + + const CGRect hole = CGRectMake(sqX, sqY, sqSz, sqSz); + CGFloat holeR = MIN(28.0, MAX(10.0, sqSz * 0.056)); + { + const CGFloat half = 0.5 * MIN(hole.size.width, hole.size.height); + holeR = MIN(holeR, MAX(6.0, half - 2.0)); + } + UIBezierPath *holeRoundPath = [UIBezierPath bezierPathWithRoundedRect:hole cornerRadius:holeR]; + + UIBezierPath *path = [UIBezierPath bezierPathWithRect:vb]; + [path appendPath:holeRoundPath]; + self.scanDimMaskLayer.frame = vb; + self.scanDimMaskLayer.path = path.CGPath; + + self.scanHoleHighlightLayer.frame = CGRectMake(0, 0, vb.size.width, vb.size.height); + self.scanHoleHighlightLayer.path = holeRoundPath.CGPath; + + const CGFloat bracketThick = 5.0; + const CGFloat bracketLen = (CGFloat)MAX(28, (NSInteger)floor(sqSz * 0.13)); + const CGFloat x0 = hole.origin.x; + const CGFloat y0 = hole.origin.y; + const CGFloat s = hole.size.width; + + const CGFloat t = bracketThick; + const CGFloat L = bracketLen; + + for (NSUInteger i = 0; i < 4; i++) { + CAShapeLayer *layer = self.bracketCornerLayers[i]; + layer.lineWidth = t; + layer.path = amneziaScanBracketStrokePath((int)i, x0, y0, s, holeR, L, t).CGPath; + } + + if (self.torchCenterYConstraint && self.torchButton) { + const CGFloat holeBottom = CGRectGetMaxY(hole); + const CGFloat bandBottom = vb.size.height; + const CGFloat torchH = 56.0; + CGFloat torchCenterY = (holeBottom + bandBottom) * 0.5; + const CGFloat minC = holeBottom + torchH * 0.5 + 6.0; + const CGFloat maxC = bandBottom - torchH * 0.5 - MAX(6.0, self.view.safeAreaInsets.bottom); + torchCenterY = MAX(minC, MIN(maxC, torchCenterY)); + if (minC > maxC) { + torchCenterY = (minC + maxC) * 0.5; + } + const CGFloat hdr = headerBottom + torchH * 0.5 + 10.0; + torchCenterY = MAX(torchCenterY, hdr); + self.torchCenterYConstraint.constant = torchCenterY; + } + + [self applyMetadataRectOfInterestForScanHole:hole]; +} + +- (void)backTapped +{ + if (gOnBack) { + gOnBack(); + } +} + +- (void)torchTapped +{ + gTorchRequested = !gTorchRequested; + [self applyTorchFromGlobalFlag]; + if (gTorchRequested) { + self.torchButton.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.42]; + self.torchButton.layer.borderWidth = 2; + self.torchButton.layer.borderColor = [UIColor colorWithRed:1 green:0.75 blue:0.45 alpha:1].CGColor; + } else { + self.torchButton.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.22]; + self.torchButton.layer.borderWidth = 0; + } +} + +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + if (self.previewLayer && self.cameraContainer) { + self.previewLayer.frame = self.cameraContainer.bounds; + } + [self layoutScanOverlayGeometry]; + if (self.scanHoleFillView) { + [self.view bringSubviewToFront:self.scanHoleFillView]; + } + if (self.scanDimView) { + [self.view bringSubviewToFront:self.scanDimView]; + } + if (self.bracketContainer) { + [self.view bringSubviewToFront:self.bracketContainer]; + } + if (self.headerContainer) { + [self.view bringSubviewToFront:self.headerContainer]; + } + if (self.torchButton) { + [self.view bringSubviewToFront:self.torchButton]; + } +} + +- (void)applyTorchOnMainThread:(BOOL)on +{ + AVCaptureDevice *device = self.videoDevice; + if (!device || ![device hasTorch]) { + if (on && gTorchRequested) { + __unsafe_unretained AmneziaPairingQrOverlayViewController *unsafeSelf = self; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.12 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + AmneziaPairingQrOverlayViewController *strongSelf = unsafeSelf; + if (strongSelf && gTorchRequested) { + [strongSelf applyTorchOnMainThread:YES]; + } + }); + } + return; + } + AVCaptureSession *session = self.captureSession; + if (on && session && ![session isRunning]) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.25 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + if (gTorchRequested) { + [self applyTorchOnMainThread:YES]; + } + }); + return; + } + NSError *err = nil; + if (![device lockForConfiguration:&err]) { + return; + } + if (on) { + err = nil; + if (![device setTorchModeOnWithLevel:AVCaptureMaxAvailableTorchLevel error:&err]) { + if ([device isTorchModeSupported:AVCaptureTorchModeOn]) { + device.torchMode = AVCaptureTorchModeOn; + } + } + } else { + device.torchMode = AVCaptureTorchModeOff; + } + [device unlockForConfiguration]; +} + +- (void)applyTorchFromGlobalFlag +{ + [self applyTorchOnMainThread:gTorchRequested ? YES : NO]; +} + +- (void)stopCapturePipelineOnMainThread +{ + [self applyTorchOnMainThread:NO]; + self.videoDevice = nil; + + AVCaptureSession *session = self.captureSession; + self.captureSession = nil; + self.metadataOutput = nil; + + if (self.previewLayer) { + [self.previewLayer removeFromSuperlayer]; + self.previewLayer = nil; + } + + if (session) { + dispatch_queue_t q = self.sessionQueue; + if (!q) { + q = dispatch_queue_create("org.amnezia.pairingqr.overlay", DISPATCH_QUEUE_SERIAL); + self.sessionQueue = q; + } + dispatch_sync(q, ^{ + @try { + if ([session isRunning]) { + [session stopRunning]; + } + } @catch (NSException *ex) { + NSLog(@"Stop running exception: %@", ex); + } + }); + } +} + +- (BOOL)startCapturePipelineOnMainThread +{ + + [self stopCapturePipelineOnMainThread]; + + if (!self.cameraContainer) { + return NO; + } + + NSError *error = nil; + AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; + if (!device) { + return NO; + } + AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device error:&error]; + if (!input) { + return NO; + } + self.videoDevice = device; + + AVCaptureSession *session = [[AVCaptureSession alloc] init]; + if ([session canSetSessionPreset:AVCaptureSessionPresetHigh]) { + session.sessionPreset = AVCaptureSessionPresetHigh; + } + + [session addInput:input]; + + AVCaptureMetadataOutput *meta = [[AVCaptureMetadataOutput alloc] init]; + if (![session canAddOutput:meta]) { + return NO; + } + [session addOutput:meta]; + dispatch_queue_t q = self.sessionQueue; + if (!q) { + q = dispatch_queue_create("org.amnezia.pairingqr.overlay", DISPATCH_QUEUE_SERIAL); + self.sessionQueue = q; + } + [meta setMetadataObjectsDelegate:self queue:q]; + meta.metadataObjectTypes = @[ AVMetadataObjectTypeQRCode ]; + + self.captureSession = session; + self.metadataOutput = meta; + + AVCaptureVideoPreviewLayer *preview = [[AVCaptureVideoPreviewLayer alloc] initWithSession:session]; + preview.videoGravity = AVLayerVideoGravityResizeAspectFill; + self.previewLayer = preview; + [self.cameraContainer.layer insertSublayer:preview atIndex:0]; + preview.frame = self.cameraContainer.bounds; + + [self.view layoutIfNeeded]; + [self layoutScanOverlayGeometry]; + + AVCaptureSession *runningSession = session; + __unsafe_unretained AmneziaPairingQrOverlayViewController *weakSelf = self; + dispatch_async(q, ^{ + @try { + [runningSession startRunning]; + } @catch (NSException *ex) { + NSLog(@"Start running exception: %@", ex); + } + dispatch_async(dispatch_get_main_queue(), ^{ + AmneziaPairingQrOverlayViewController *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + [strongSelf applyTorchFromGlobalFlag]; + }); + }); + + return YES; +} + +- (void)captureOutput:(AVCaptureOutput *)output + didOutputMetadataObjects:(NSArray<__kindof AVMetadataMachineReadableCodeObject *> *)metadataObjects + fromConnection:(AVCaptureConnection *)connection +{ + (void)output; + (void)connection; + for (AVMetadataMachineReadableCodeObject *obj in metadataObjects) { + NSString *value = obj.stringValue; + if (value.length == 0) { + continue; + } + const char *utf8 = value.UTF8String; + std::string copy(utf8 ? utf8 : ""); + if (copy.empty()) { + continue; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (gOnScanned) { + gOnScanned(copy.c_str()); + } + }); + break; + } +} + +@end + +static void amneziaPairingQrOverlayTeardownOnMain(void) +{ + UIWindow *w = gPairingQrOverlayWindow; + gPairingQrOverlayWindow = nil; + gOnScanned = nullptr; + gOnBack = nullptr; + gTorchRequested = false; + gPairingQrOverlayKeySince = -1.0; + + if (w) { + UIViewController *root = w.rootViewController; + w.rootViewController = nil; + w.hidden = YES; + if ([root isKindOfClass:[AmneziaPairingQrOverlayViewController class]]) { + [(AmneziaPairingQrOverlayViewController *)root stopCapturePipelineOnMainThread]; + } + } + + UIWindow *restore = amneziaPickQtAppWindowToRestore(); + if (restore) { + [restore makeKeyWindow]; + } else { + } +} + +void amneziaIosPairingQrOverlayPresent(AmneziaPairingQrScannedUtf8Handler onScanned, AmneziaPairingQrOverlayBackHandler onBack, + const std::string &titleUtf8, const std::string &subtitleUtf8) +{ + const bool hasScan = static_cast(onScanned); + const bool hasBack = static_cast(onBack); + AmneziaPairingQrScannedUtf8Handler scanH = std::move(onScanned); + AmneziaPairingQrOverlayBackHandler backH = std::move(onBack); + const std::string titleCopy = titleUtf8; + const std::string subCopy = subtitleUtf8; + + dispatch_async(dispatch_get_main_queue(), ^{ + + amneziaPairingQrOverlayTeardownOnMain(); + gOnScanned = std::move(scanH); + gOnBack = std::move(backH); + + UIWindowScene *scene = amneziaForegroundWindowScene(); + if (!scene) { + gOnScanned = nullptr; + gOnBack = nullptr; + return; + } + + const CGFloat bottomReserve = amneziaPairingQrBottomTabStripReserve(scene); + const CGRect sceneBounds = scene.coordinateSpace.bounds; + const CGRect overlayFrame = CGRectMake(0, 0, sceneBounds.size.width, sceneBounds.size.height - bottomReserve); + + AmneziaPairingQrOverlayViewController *vc = [[AmneziaPairingQrOverlayViewController alloc] init]; + NSString *nsTitle = titleCopy.empty() ? nil : [NSString stringWithUTF8String:titleCopy.c_str()]; + NSString *nsSub = subCopy.empty() ? nil : [NSString stringWithUTF8String:subCopy.c_str()]; + vc.chromeTitleText = nsTitle; + vc.chromeSubtitleText = nsSub; + + UIWindow *w = [[UIWindow alloc] initWithWindowScene:scene]; + w.frame = overlayFrame; + w.windowLevel = kAmneziaPairingQrOverlayWindowLevel; + w.backgroundColor = [UIColor blackColor]; + w.rootViewController = vc; + gPairingQrOverlayWindow = w; + + [w makeKeyAndVisible]; + [w layoutIfNeeded]; + [vc.view setNeedsLayout]; + [vc.view layoutIfNeeded]; + + gPairingQrOverlayKeySince = CFAbsoluteTimeGetCurrent(); + + if (![vc startCapturePipelineOnMainThread]) { + NSLog(@"Start capture failed"); + } + }); +} + +void amneziaIosPairingQrOverlayDismiss() +{ + dispatch_async(dispatch_get_main_queue(), ^{ + amneziaPairingQrOverlayTeardownOnMain(); + }); +} + +void amneziaIosPairingQrOverlaySetTorchEnabled(bool on) +{ + gTorchRequested = on; + dispatch_async(dispatch_get_main_queue(), ^{ + UIWindow *win = gPairingQrOverlayWindow; + if (!win) { + return; + } + UIViewController *root = win.rootViewController; + if ([root isKindOfClass:[AmneziaPairingQrOverlayViewController class]]) { + AmneziaPairingQrOverlayViewController *vc = (AmneziaPairingQrOverlayViewController *)root; + [vc applyTorchFromGlobalFlag]; + if (vc.torchButton) { + if (on) { + vc.torchButton.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.42]; + vc.torchButton.layer.borderWidth = 2; + vc.torchButton.layer.borderColor = [UIColor colorWithRed:1 green:0.75 blue:0.45 alpha:1].CGColor; + } else { + vc.torchButton.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.18]; + vc.torchButton.layer.borderWidth = 0; + } + } + } + }); +} + +void amneziaIosPairingQrOverlayRestartCapture() +{ + dispatch_async(dispatch_get_main_queue(), ^{ + const CFAbsoluteTime now = CFAbsoluteTimeGetCurrent(); + if (gPairingQrOverlayKeySince > 0 && (now - gPairingQrOverlayKeySince) < 1.0) { + return; + } + UIWindow *w = gPairingQrOverlayWindow; + if (!w) { + return; + } + UIViewController *root = w.rootViewController; + if (![root isKindOfClass:[AmneziaPairingQrOverlayViewController class]]) { + return; + } + AmneziaPairingQrOverlayViewController *vc = (AmneziaPairingQrOverlayViewController *)root; + [vc stopCapturePipelineOnMainThread]; + if (![vc startCapturePipelineOnMainThread]) { + NSLog(@"Restart startCapture failed"); + } + }); +} diff --git a/client/tests/CMakeLists.txt b/client/tests/CMakeLists.txt index a8ba122464..a9ce68f37e 100644 --- a/client/tests/CMakeLists.txt +++ b/client/tests/CMakeLists.txt @@ -131,6 +131,15 @@ target_link_libraries(test_self_hosted_server_setup PRIVATE test_common ) +add_executable(test_pairing_parsers + testPairingParsers.cpp +) + +target_link_libraries(test_pairing_parsers PRIVATE + Qt6::Test + test_common +) + enable_testing() add_test(NAME ImportExportTest COMMAND test_import_export) add_test(NAME MultipleImportsTest COMMAND test_multiple_imports) @@ -143,3 +152,4 @@ add_test(NAME ComplexOperationsTest COMMAND test_complex_operations) add_test(NAME SettingsSignalsTest COMMAND test_settings_signals) add_test(NAME UiServersModelAndControllerTest COMMAND test_ui_servers_model_and_controller) add_test(NAME SelfHostedServerSetupTest COMMAND test_self_hosted_server_setup) +add_test(NAME PairingParsersTest COMMAND test_pairing_parsers) diff --git a/client/tests/testPairingParsers.cpp b/client/tests/testPairingParsers.cpp new file mode 100644 index 0000000000..183d9d213b --- /dev/null +++ b/client/tests/testPairingParsers.cpp @@ -0,0 +1,165 @@ +#include +#include +#include +#include +#include + +#include "core/controllers/api/pairingController.h" +#include "ui/controllers/api/pairingUiController.h" +#include "core/utils/constants/apiKeys.h" + +using namespace amnezia; + +class TestPairingParsers : public QObject +{ + Q_OBJECT + +private slots: + void generateQr_success_extractsConfigAndMeta() + { + PairingController::QrPairingConfigPayload out; + QJsonObject o; + o[apiDefs::key::config] = QStringLiteral("vpn://dummy"); + o[apiDefs::key::serviceInfo] = QJsonObject { { QStringLiteral("is_ad_visible"), false } }; + o[apiDefs::key::supportedProtocols] = QJsonArray { QStringLiteral("awg") }; + const QByteArray body = QJsonDocument(o).toJson(); + + QCOMPARE(PairingController::parseGenerateQrResponseBody(body, out), ErrorCode::NoError); + QCOMPARE(out.config, QStringLiteral("vpn://dummy")); + QCOMPARE(out.supportedProtocols.size(), 1); + } + + void generateQr_http408() + { + PairingController::QrPairingConfigPayload out; + QJsonObject o; + o[QStringLiteral("http_status")] = 408; + o[QStringLiteral("message")] = QStringLiteral("Request Timeout"); + const QByteArray body = QJsonDocument(o).toJson(); + + QCOMPARE(PairingController::parseGenerateQrResponseBody(body, out), ErrorCode::ApiConfigTimeoutError); + QVERIFY(out.config.isEmpty()); + } + + void generateQr_http429() + { + PairingController::QrPairingConfigPayload out; + QJsonObject o; + o[QStringLiteral("http_status")] = 429; + o[QStringLiteral("message")] = QStringLiteral("Too Many Requests"); + const QByteArray body = QJsonDocument(o).toJson(); + + QCOMPARE(PairingController::parseGenerateQrResponseBody(body, out), ErrorCode::ApiPairingRateLimitedError); + } + + void scanQr_messageOk() + { + QJsonObject o; + o[QStringLiteral("message")] = QStringLiteral("OK"); + const QByteArray body = QJsonDocument(o).toJson(); + + QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::NoError); + } + + void scanQr_messageOk_extractsDeviceName() + { + QJsonObject o; + o[QStringLiteral("message")] = QStringLiteral("OK"); + o[QStringLiteral("device_name")] = QStringLiteral("TestPhone"); + const QByteArray body = QJsonDocument(o).toJson(); + QString name; + QCOMPARE(PairingController::parseScanQrResponseBody(body, &name), ErrorCode::NoError); + QCOMPARE(name, QStringLiteral("TestPhone")); + } + + void scanQr_deviceLimitMessage() + { + QJsonObject o; + o[QStringLiteral("message")] = QStringLiteral("Device limit reached for subscription"); + const QByteArray body = QJsonDocument(o).toJson(); + + QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::ApiConfigLimitError); + } + + void scanQr_http403() + { + QJsonObject o; + o[QStringLiteral("http_status")] = 403; + const QByteArray body = QJsonDocument(o).toJson(); + + QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::ApiPairingForbiddenError); + } + + void scanQr_http409() + { + QJsonObject o; + o[QStringLiteral("http_status")] = 409; + const QByteArray body = QJsonDocument(o).toJson(); + + QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::ApiPairingConflictError); + } + + void scanQr_notFoundMessage() + { + QJsonObject o; + o[QStringLiteral("message")] = QStringLiteral("Session not found"); + const QByteArray body = QJsonDocument(o).toJson(); + + QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::ApiNotFoundError); + } + + void scanQr_qrSessionExpiredMessage() + { + QJsonObject o; + o[QStringLiteral("message")] = QStringLiteral("QR session not found or expired"); + const QByteArray body = QJsonDocument(o).toJson(); + + QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::ApiPairingSessionExpiredError); + } + + void validateScanFields_oversizedVpnKey() + { + QString vpnKey; + vpnKey.fill(QLatin1Char('x'), 256 * 1024 + 1); + QCOMPARE(PairingController::validatePairingScanFields(QStringLiteral("ab"), vpnKey, QStringLiteral("k"), + QStringLiteral("amnezia-premium"), QStringLiteral("ru")), + ErrorCode::ApiPairingPayloadTooLargeError); + } + + void validateScanFields_uuidTooLong() + { + QString uuid(200, QLatin1Char('a')); + QCOMPARE(PairingController::validatePairingScanFields(uuid, QStringLiteral("vpn://a"), QStringLiteral("k"), + QStringLiteral("amnezia-premium"), QStringLiteral("ru")), + ErrorCode::ApiConfigEmptyError); + } + + void validateScanFields_missingServiceType() + { + QCOMPARE(PairingController::validatePairingScanFields(QStringLiteral("ab"), QStringLiteral("vpn://x"), + QStringLiteral("k"), QString(), + QStringLiteral("ru")), + ErrorCode::ApiPairingMissingMetadataError); + } + + void pairingUi_applyScanned_extractsUuid_emitsSignal() + { + PairingUiController ctl(nullptr, nullptr, nullptr, nullptr); + QSignalSpy spy(&ctl, &PairingUiController::pairingUuidFromScan); + const QString u = QStringLiteral("123e4567-e89b-12d3-a456-426614174000"); + QVERIFY(ctl.applyScannedTextAsPairingUuid(QStringLiteral("prefix ") + u + QStringLiteral(" suffix"))); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.first().first().toString(), u); + } + + void pairingUi_applyScanned_rejectsVpnKey() + { + PairingUiController ctl(nullptr, nullptr, nullptr, nullptr); + QSignalSpy spy(&ctl, &PairingUiController::pairingUuidFromScan); + QVERIFY(!ctl.applyScannedTextAsPairingUuid(QStringLiteral("vpn://AAAA"))); + QCOMPARE(spy.count(), 0); + } +}; + +QTEST_MAIN(TestPairingParsers) +#include "testPairingParsers.moc" diff --git a/client/translations/amneziavpn_ru_RU.ts b/client/translations/amneziavpn_ru_RU.ts index b1f95a688c..91905ea253 100644 --- a/client/translations/amneziavpn_ru_RU.ts +++ b/client/translations/amneziavpn_ru_RU.ts @@ -1822,6 +1822,16 @@ Thank you for staying with us! Cancel ΠžΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ + + + Configuration Files: %1 + Π€Π°ΠΉΠ»Ρ‹ ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΈ: %1 + + + + Generated configuration files also count towards the device limit + Π‘Π³Π΅Π½Π΅Ρ€ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Π΅ Ρ„Π°ΠΉΠ»Ρ‹ ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΈ Ρ‚ΠΎΠΆΠ΅ ΡƒΡ‡ΠΈΡ‚Ρ‹Π²Π°ΡŽΡ‚ΡΡ Π² Π»ΠΈΠΌΠΈΡ‚Π΅ устройств + PageSettingsApiInstructions diff --git a/client/ui/controllers/api/pairingUiController.cpp b/client/ui/controllers/api/pairingUiController.cpp new file mode 100644 index 0000000000..6e5df0ba4a --- /dev/null +++ b/client/ui/controllers/api/pairingUiController.cpp @@ -0,0 +1,739 @@ +#include "pairingUiController.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "platforms/ios/iosPairingCameraAccess.h" +#if defined(Q_OS_IOS) + #include "platforms/ios/iosPairingQrOverlayWindow.h" +#endif + +#if defined(Q_OS_ANDROID) + #include "platforms/android/android_controller.h" +#endif + +#include "core/controllers/gatewayController.h" +#include "core/models/api/apiV2ServerConfig.h" +#include "core/utils/constants/apiConstants.h" +#include "core/utils/constants/apiKeys.h" +#include "core/utils/qrCodeUtils.h" + +using namespace amnezia; + +namespace +{ +constexpr auto kGenerateQrPath = "%1v1/generate_qr"; +constexpr auto kScanQrPath = "%1v1/scan_qr"; +constexpr auto kGatewayProbePath = "%1v1/news"; +constexpr int kPairingRetryMaxAttempts = 3; +constexpr int kGatewayProbeTimeoutMsecs = 3000; + +QJsonObject apiGatewayServicesFromServers(const ServersController *serversController) +{ + if (!serversController || serversController->getServersCount() == 0) { + return {}; + } + + QSet userCountryCodes; + QSet serviceTypes; + for (int i = 0; i < serversController->getServersCount(); ++i) { + const QString serverId = serversController->getServerId(i); + const auto apiV2 = serversController->apiV2Config(serverId); + if (!apiV2.has_value()) { + continue; + } + if (!apiV2->apiConfig.userCountryCode.isEmpty()) { + userCountryCodes.insert(apiV2->apiConfig.userCountryCode); + } + const QString serviceType = apiV2->serviceType(); + if (!serviceType.isEmpty()) { + serviceTypes.insert(serviceType); + } + } + + if (userCountryCodes.isEmpty() && serviceTypes.isEmpty()) { + return {}; + } + + QJsonObject json; + QJsonArray userCountryCodesArray; + for (const QString &code : userCountryCodes) { + userCountryCodesArray.append(code); + } + json.insert(apiDefs::key::userCountryCode, userCountryCodesArray); + + QJsonArray serviceTypesArray; + for (const QString &type : serviceTypes) { + serviceTypesArray.append(type); + } + json.insert(apiDefs::key::serviceType, serviceTypesArray); + return json; +} + +bool isPairingRetriableError(ErrorCode code) +{ + switch (code) { + case ErrorCode::ApiPairingRateLimitedError: + case ErrorCode::ApiPairingServiceUnavailableError: + case ErrorCode::ApiConfigDownloadError: + return true; + default: + return false; + } +} + +int pairingRetryDelayMs(int zeroBasedAttempt) +{ + constexpr int baseMs = 500; + return baseMs * (1 << zeroBasedAttempt); +} + +QString extractPairingSessionUuidFromScanText(const QString &raw) +{ + const QString t = raw.trimmed(); + if (t.startsWith(QStringLiteral("vpn://"), Qt::CaseInsensitive)) { + return {}; + } + static const QRegularExpression reV4(QStringLiteral( + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}")); + const QRegularExpressionMatch m = reV4.match(t); + if (m.hasMatch()) { + return m.captured(0); + } + const QUuid parsed = QUuid::fromString(t); + if (!parsed.isNull()) { + return parsed.toString(QUuid::WithoutBraces); + } + return {}; +} +} // namespace + +#if defined(Q_OS_ANDROID) +namespace { +PairingUiController *g_pairingUiForAndroidQr = nullptr; +} +#endif + +PairingUiController::PairingUiController(PairingController *pairingController, ServersController *serversController, + SubscriptionController *subscriptionController, + SecureAppSettingsRepository *appSettingsRepository, QObject *parent) + : QObject(parent), + m_pairingController(pairingController), + m_serversController(serversController), + m_subscriptionController(subscriptionController), + m_appSettingsRepository(appSettingsRepository) +{ +#if defined(Q_OS_ANDROID) + g_pairingUiForAndroidQr = this; + connect(AndroidController::instance(), &AndroidController::cameraPermissionResult, this, + [this](bool granted) { emit pairingCameraAccessFinished(granted); }); +#endif +} + +PairingUiController::~PairingUiController() +{ +#if defined(Q_OS_ANDROID) + if (g_pairingUiForAndroidQr == this) { + g_pairingUiForAndroidQr = nullptr; + } +#endif +#if defined(Q_OS_IOS) + amneziaIosPairingQrOverlayDismiss(); +#endif +} + +void PairingUiController::setPendingPhonePairingUuid(const QString &uuid) +{ + const QString trimmed = uuid.trimmed(); + if (m_pendingPhonePairingUuid == trimmed) { + return; + } + m_pendingPhonePairingUuid = trimmed; + emit pendingPhonePairingUuidChanged(); +} + +void PairingUiController::clearPendingPhonePairingUuid() +{ + if (m_pendingPhonePairingUuid.isEmpty()) { + return; + } + m_pendingPhonePairingUuid.clear(); + emit pendingPhonePairingUuidChanged(); +} + +void PairingUiController::openPairingQrScanner() +{ +#if defined(Q_OS_ANDROID) + AndroidController::instance()->startPairingQrReaderActivity(); +#endif +} + +bool PairingUiController::isPairingCameraAccessGranted() const +{ +#if defined(Q_OS_ANDROID) + return AndroidController::instance()->isCameraPermissionGranted(); +#elif defined(Q_OS_IOS) + return amneziaIosPairingCameraAccessGranted(); +#else + return true; +#endif +} + +void PairingUiController::requestPairingCameraAccess() +{ +#if defined(Q_OS_ANDROID) + AndroidController::instance()->requestCameraPermissionForQrPairing(); +#elif defined(Q_OS_IOS) + amneziaIosRequestPairingCameraAccess([this](bool granted) { + QMetaObject::invokeMethod( + this, [this, granted]() { emit pairingCameraAccessFinished(granted); }, Qt::QueuedConnection); + }); +#else + emit pairingCameraAccessFinished(true); +#endif +} + +void PairingUiController::openPairingCameraAppSettings() +{ +#if defined(Q_OS_ANDROID) + AndroidController::instance()->openApplicationDetailsSettings(); +#elif defined(Q_OS_IOS) + amneziaIosOpenApplicationSettings(); +#endif +} + +void PairingUiController::setPairingQrTorchEnabled(bool enabled) +{ +#if defined(Q_OS_ANDROID) + Q_UNUSED(enabled); +#elif defined(Q_OS_IOS) + amneziaIosPairingQrOverlaySetTorchEnabled(enabled); +#else + Q_UNUSED(enabled); +#endif +} + +void PairingUiController::presentIosPairingQrNativeOverlayScanner(const QString &title, const QString &subtitle) +{ +#if defined(Q_OS_IOS) + const std::string titleUtf8 = title.isEmpty() ? std::string() : title.toStdString(); + const std::string subtitleUtf8 = subtitle.isEmpty() ? std::string() : subtitle.toStdString(); + amneziaIosPairingQrOverlayPresent( + [this](const char *utf8) { + const QString code = QString::fromUtf8(utf8); + QMetaObject::invokeMethod( + this, + [this, code]() { + if (!applyScannedTextAsPairingUuid(code)) { + emit pairingSendQrScanRejectedInvalidPayload(); + } + }, + Qt::QueuedConnection); + }, + [this]() { + QMetaObject::invokeMethod( + this, + [this]() { emit pairingIosNativeQrOverlayBackRequested(); }, + Qt::QueuedConnection); + }, + titleUtf8, subtitleUtf8); +#else + Q_UNUSED(title); + Q_UNUSED(subtitle); +#endif +} + +void PairingUiController::dismissIosPairingQrNativeOverlayScanner() +{ +#if defined(Q_OS_IOS) + amneziaIosPairingQrOverlayDismiss(); +#endif +} + +void PairingUiController::restartIosPairingQrNativeOverlayCapture() +{ +#if defined(Q_OS_IOS) + amneziaIosPairingQrOverlayRestartCapture(); +#endif +} + +bool PairingUiController::applyScannedTextAsPairingUuid(const QString &raw) +{ + const QString uuid = extractPairingSessionUuidFromScanText(raw); + if (uuid.isEmpty()) { + return false; + } + + emit pairingUuidFromScan(uuid); + return true; +} + +#if defined(Q_OS_ANDROID) +bool PairingUiController::tryConsumeAndroidQrScan(const QString &code) +{ + if (!g_pairingUiForAndroidQr) { + return false; + } + const QString codeCopy = code; + // Parse on this thread: while CameraActivity is foreground, AmneziaActivity is stopped and the Qt + // event loop may not process BlockingQueuedConnection until the user returns β€” UI would lag behind. + if (extractPairingSessionUuidFromScanText(codeCopy).isEmpty()) { + return false; + } + PairingUiController *const ctl = g_pairingUiForAndroidQr; + QPointer ctlPtr(ctl); + QTimer::singleShot(0, ctl, [ctlPtr, codeCopy]() { + if (!ctlPtr) { + return; + } + ctlPtr->applyScannedTextAsPairingUuid(codeCopy); + }); + return true; +} + +void PairingUiController::notifyAndroidPairingQrCameraClosed() +{ + if (g_pairingUiForAndroidQr) { + g_pairingUiForAndroidQr->suppressAndroidNativePairingReaderStarts(2000); + } +} + +void PairingUiController::notifyAndroidPairingQrCameraUserDismissed() +{ + if (!g_pairingUiForAndroidQr) { + return; + } + PairingUiController *const ctl = g_pairingUiForAndroidQr; + QPointer ptr(ctl); + QTimer::singleShot(0, ctl, [ptr]() { + if (!ptr) { + return; + } + emit ptr->pairingAndroidNativeQrScannerUserDismissed(); + }); +} +#endif + +void PairingUiController::suppressAndroidNativePairingReaderStarts(int ms) +{ + if (ms <= 0) { + return; + } +#if defined(Q_OS_ANDROID) + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + const qint64 until = now + ms; + if (until <= m_androidPairingReaderCooldownUntilEpochMs) { + return; + } + m_androidPairingReaderCooldownUntilEpochMs = until; + emit androidPairingReaderCooldownUntilEpochMsChanged(); +#else + Q_UNUSED(ms); +#endif +} + +QVariantList PairingUiController::tvQrCodes() const +{ + QVariantList list; + list.reserve(m_tvQrCodes.size()); + for (const QString &s : m_tvQrCodes) { + list.append(s); + } + return list; +} + +int PairingUiController::tvQrCodesCount() const +{ + return m_tvQrCodes.size(); +} + +int PairingUiController::tvPairingWaitWindowSeconds() const +{ + if (!m_pairingController) { + return 30; + } + const int msec = m_pairingController->pairingLongPollTimeoutMsecs(); + return qMax(1, (msec + 999) / 1000); +} + +bool PairingUiController::phonePairingBusy() const +{ + return m_phonePairingBusy; +} + +void PairingUiController::setTvBusy(bool busy) +{ + m_tvPairingBusy = busy; +} + +void PairingUiController::setPhoneBusy(bool busy) +{ + if (m_phonePairingBusy == busy) { + return; + } + m_phonePairingBusy = busy; + emit phonePairingBusyChanged(); +} + +bool PairingUiController::canOpenTvQrPairingPage() +{ + if (!m_appSettingsRepository) { + emit errorOccurred(ErrorCode::InternalError); + return false; + } + + const QJsonObject gatewayServices = apiGatewayServicesFromServers(m_serversController); + if (gatewayServices.isEmpty()) { + return true; + } + + QJsonObject payload; + payload.insert(QStringLiteral("locale"), m_appSettingsRepository->getAppLanguage().name().split(QLatin1Char('_')).first()); + + if (gatewayServices.contains(apiDefs::key::userCountryCode)) { + payload.insert(apiDefs::key::userCountryCode, gatewayServices.value(apiDefs::key::userCountryCode)); + } + if (gatewayServices.contains(apiDefs::key::serviceType)) { + payload.insert(apiDefs::key::serviceType, gatewayServices.value(apiDefs::key::serviceType)); + } + + const bool isTestPurchase = false; + GatewayController gatewayController(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase), + m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), kGatewayProbeTimeoutMsecs, + m_appSettingsRepository->isStrictKillSwitchEnabled()); + QByteArray responseBody; + const ErrorCode err = gatewayController.post(QString::fromLatin1(kGatewayProbePath), payload, responseBody); + if (err != ErrorCode::NoError) { + emit errorOccurred(err); + return false; + } + return true; +} + +void PairingUiController::resetTvQrDisplay() +{ + m_tvQrCodes.clear(); + m_tvSessionUuid.clear(); + emit tvQrCodesChanged(); +} + +void PairingUiController::startTvQrSession() +{ + if (!m_pairingController || !m_appSettingsRepository) { + return; + } + if (m_tvPairingBusy) { + return; + } + rotateTvQrSession(); +} + +void PairingUiController::rotateTvQrSession() +{ + if (!m_pairingController || !m_appSettingsRepository) { + return; + } + + if (m_tvWatcher) { + m_tvWatcher->disconnect(); + m_tvWatcher->deleteLater(); + m_tvWatcher.clear(); + } + if (m_tvNetworkReply) { + m_tvNetworkReply->abort(); + m_tvNetworkReply.clear(); + } + + ++m_tvSessionGeneration; + const quint64 generation = m_tvSessionGeneration; + + m_tvSessionUuid = QUuid::createUuid().toString(QUuid::WithoutBraces); + const QByteArray qrPayload = m_tvSessionUuid.toUtf8(); + m_tvQrCodes = qrCodeUtils::generateQrCodeImageSeriesPlainText(qrPayload); + emit tvQrCodesChanged(); + + setTvBusy(true); + + dispatchTvGenerateQrAttempt(generation, 0); +} + +void PairingUiController::dispatchTvGenerateQrAttempt(quint64 generation, int retryAttempt) +{ + if (!m_pairingController || !m_appSettingsRepository) { + return; + } + if (generation != m_tvSessionGeneration) { + return; + } + + const bool isTestPurchase = false; + auto gatewayController = QSharedPointer::create(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase), + m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), + m_pairingController->pairingLongPollTimeoutMsecs(), + m_appSettingsRepository->isStrictKillSwitchEnabled()); + + const QJsonObject payload = m_pairingController->buildGenerateQrPayload(m_tvSessionUuid); + QNetworkReply *replyRaw = nullptr; + const QFuture> future = + gatewayController->postAsync(QString::fromLatin1(kGenerateQrPath), payload, &replyRaw, gatewayController); + m_tvNetworkReply = replyRaw; + + auto *watcher = new QFutureWatcher>(this); + m_tvWatcher = watcher; + QObject::connect(watcher, &QFutureWatcher>::finished, this, + [this, gatewayController, watcher, generation, retryAttempt]() { + Q_UNUSED(gatewayController); + const auto result = watcher->result(); + watcher->deleteLater(); + if (m_tvWatcher == watcher) { + m_tvWatcher.clear(); + } + + if (generation != m_tvSessionGeneration) { + return; + } + + m_tvNetworkReply.clear(); + + PairingController::QrPairingConfigPayload out; + ErrorCode logicalErr = result.first; + if (logicalErr == ErrorCode::NoError) { + logicalErr = PairingController::parseGenerateQrResponseBody(result.second, out); + } + + if (logicalErr == ErrorCode::NoError) { + const ErrorCode impErr = m_subscriptionController->importServerFromQrPairingResponse( + out.config, out.serviceInfo, out.supportedProtocols); + setTvBusy(false); + if (impErr != ErrorCode::NoError) { + emit errorOccurred(impErr); + if (impErr == ErrorCode::ApiConfigAlreadyAdded) { + emit tvPairingConfigAlreadyAdded(); + QTimer::singleShot(0, this, [this]() { rotateTvQrSession(); }); + return; + } + resetTvQrDisplay(); + return; + } + resetTvQrDisplay(); + emit tvPairingConfigReceived(); + return; + } + + if (isPairingRetriableError(logicalErr) && retryAttempt + 1 < kPairingRetryMaxAttempts) { + const int delayMs = pairingRetryDelayMs(retryAttempt); + QTimer::singleShot(delayMs, this, [this, generation, retryAttempt]() { + if (generation != m_tvSessionGeneration) { + return; + } + dispatchTvGenerateQrAttempt(generation, retryAttempt + 1); + }); + return; + } + + if (logicalErr == ErrorCode::ApiConfigTimeoutError) { + setTvBusy(false); + QTimer::singleShot(0, this, [this]() { rotateTvQrSession(); }); + return; + } + + setTvBusy(false); + emit errorOccurred(logicalErr); + }); + watcher->setFuture(future); +} + +void PairingUiController::cancelTvQrSession() +{ + ++m_tvSessionGeneration; + if (m_tvNetworkReply) { + m_tvNetworkReply->abort(); + } + m_tvNetworkReply.clear(); + if (m_tvWatcher) { + m_tvWatcher->disconnect(); + m_tvWatcher->deleteLater(); + m_tvWatcher.clear(); + } + setTvBusy(false); + resetTvQrDisplay(); +} + +void PairingUiController::cancelAllPairingActivity() +{ + ++m_phoneSessionGeneration; + if (m_phoneNetworkReply) { + m_phoneNetworkReply->abort(); + } + m_phoneNetworkReply.clear(); + if (m_phoneWatcher) { + m_phoneWatcher->disconnect(); + m_phoneWatcher->deleteLater(); + m_phoneWatcher.clear(); + } + setPhoneBusy(false); + + clearPendingPhonePairingUuid(); + if (!m_lastSuccessfulPhonePairingDisplayName.isEmpty()) { + m_lastSuccessfulPhonePairingDisplayName.clear(); + emit lastSuccessfulPhonePairingDisplayNameChanged(); + } + + cancelTvQrSession(); +} + +void PairingUiController::submitPhonePairing(const QString &qrUuid, int serverIndex) +{ + if (!m_pairingController || !m_serversController || !m_subscriptionController || !m_appSettingsRepository) { + return; + } + if (m_phonePairingBusy) { + return; + } + + const QString trimmedUuid = qrUuid.trimmed(); + if (trimmedUuid.isEmpty()) { + emit errorOccurred(ErrorCode::ApiConfigEmptyError); + return; + } + + if (serverIndex < 0 || serverIndex >= m_serversController->getServersCount()) { + emit errorOccurred(ErrorCode::InternalError); + return; + } + + const QString serverId = m_serversController->getServerId(serverIndex); + const auto apiV2Opt = m_serversController->apiV2Config(serverId); + if (!apiV2Opt.has_value()) { + emit errorOccurred(ErrorCode::InternalError); + return; + } + + const ApiV2ServerConfig &apiV2 = *apiV2Opt; + + QString vpnKey; + const ErrorCode keyErr = m_subscriptionController->prepareVpnKeyExport(serverId, vpnKey); + if (keyErr != ErrorCode::NoError) { + emit errorOccurred(keyErr); + return; + } + + const QJsonObject serviceInfo = apiV2.apiConfig.serviceInfo.toJson(); + const QJsonArray supportedProtocols = apiV2.apiConfig.supportedProtocols; + const QString apiKey = apiV2.authData.apiKey; + if (apiKey.isEmpty()) { + emit errorOccurred(ErrorCode::ApiConfigEmptyError); + return; + } + + const QString serviceType = apiV2.apiConfig.serviceType.trimmed(); + const QString userCountryCode = apiV2.apiConfig.userCountryCode.trimmed(); + + const ErrorCode fieldErr = + PairingController::validatePairingScanFields(trimmedUuid, vpnKey, apiKey, serviceType, userCountryCode); + if (fieldErr != ErrorCode::NoError) { + emit errorOccurred(fieldErr); + return; + } + + ++m_phoneSessionGeneration; + const quint64 phoneGeneration = m_phoneSessionGeneration; + + if (!m_lastSuccessfulPhonePairingDisplayName.isEmpty()) { + m_lastSuccessfulPhonePairingDisplayName.clear(); + emit lastSuccessfulPhonePairingDisplayNameChanged(); + } + + setPhoneBusy(true); + + dispatchPhoneScanQrAttempt(trimmedUuid, apiV2.apiConfig.isTestPurchase, vpnKey, serviceInfo, supportedProtocols, apiKey, + serviceType, userCountryCode, phoneGeneration, 0); +} + +void PairingUiController::dispatchPhoneScanQrAttempt(const QString &qrUuid, const bool isTestPurchase, const QString &vpnKey, + const QJsonObject &serviceInfo, const QJsonArray &supportedProtocols, + const QString &apiKey, const QString &serviceType, const QString &userCountryCode, + quint64 generation, int retryAttempt) +{ + if (!m_pairingController || !m_appSettingsRepository) { + return; + } + if (generation != m_phoneSessionGeneration) { + return; + } + + auto gatewayController = QSharedPointer::create(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase), + m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), + apiDefs::requestTimeoutMsecs, + m_appSettingsRepository->isStrictKillSwitchEnabled()); + + const QJsonObject payload = m_pairingController->buildScanQrPayload(qrUuid, vpnKey, serviceInfo, supportedProtocols, apiKey, + serviceType, userCountryCode); + QNetworkReply *replyRaw = nullptr; + const QFuture> future = + gatewayController->postAsync(QString::fromLatin1(kScanQrPath), payload, &replyRaw, gatewayController); + m_phoneNetworkReply = replyRaw; + + auto *watcher = new QFutureWatcher>(this); + m_phoneWatcher = watcher; + QObject::connect(watcher, &QFutureWatcher>::finished, this, + [this, gatewayController, watcher, generation, retryAttempt, qrUuid, isTestPurchase, vpnKey, serviceInfo, + supportedProtocols, apiKey, serviceType, userCountryCode]() { + Q_UNUSED(gatewayController); + const auto result = watcher->result(); + watcher->deleteLater(); + if (m_phoneWatcher == watcher) { + m_phoneWatcher.clear(); + } + + if (generation != m_phoneSessionGeneration) { + return; + } + + m_phoneNetworkReply.clear(); + + ErrorCode logicalErr = result.first; + QString scanDisplayName; + if (logicalErr == ErrorCode::NoError) { + logicalErr = PairingController::parseScanQrResponseBody(result.second, &scanDisplayName); + } + + if (logicalErr == ErrorCode::NoError) { + setPhoneBusy(false); + if (m_lastSuccessfulPhonePairingDisplayName != scanDisplayName) { + m_lastSuccessfulPhonePairingDisplayName = scanDisplayName; + emit lastSuccessfulPhonePairingDisplayNameChanged(); + } + clearPendingPhonePairingUuid(); + emit phonePairingSucceeded(); + return; + } + + if (isPairingRetriableError(logicalErr) && retryAttempt + 1 < kPairingRetryMaxAttempts) { + const int delayMs = pairingRetryDelayMs(retryAttempt); + QTimer::singleShot(delayMs, this, [this, qrUuid, isTestPurchase, vpnKey, serviceInfo, supportedProtocols, + apiKey, serviceType, userCountryCode, generation, retryAttempt]() { + if (generation != m_phoneSessionGeneration) { + return; + } + dispatchPhoneScanQrAttempt(qrUuid, isTestPurchase, vpnKey, serviceInfo, supportedProtocols, apiKey, + serviceType, userCountryCode, generation, retryAttempt + 1); + }); + return; + } + + setPhoneBusy(false); + emit errorOccurred(logicalErr); + }); + watcher->setFuture(future); +} diff --git a/client/ui/controllers/api/pairingUiController.h b/client/ui/controllers/api/pairingUiController.h new file mode 100644 index 0000000000..8503109abf --- /dev/null +++ b/client/ui/controllers/api/pairingUiController.h @@ -0,0 +1,131 @@ +#ifndef PAIRINGUICONTROLLER_H +#define PAIRINGUICONTROLLER_H + +#include +#include +#include +#include +#include +#include + +#include "core/controllers/api/pairingController.h" +#include "core/controllers/api/subscriptionController.h" +#include "core/controllers/serversController.h" +#include "core/repositories/secureAppSettingsRepository.h" + +#include "core/utils/errorCodes.h" + +class PairingUiController : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QVariantList tvQrCodes READ tvQrCodes NOTIFY tvQrCodesChanged) + Q_PROPERTY(int tvQrCodesCount READ tvQrCodesCount NOTIFY tvQrCodesChanged) + Q_PROPERTY(int tvPairingWaitWindowSeconds READ tvPairingWaitWindowSeconds NOTIFY tvQrCodesChanged) + + Q_PROPERTY(bool phonePairingBusy READ phonePairingBusy NOTIFY phonePairingBusyChanged) + Q_PROPERTY(QString pendingPhonePairingUuid READ pendingPhonePairingUuid WRITE setPendingPhonePairingUuid NOTIFY + pendingPhonePairingUuidChanged) + Q_PROPERTY(QString lastSuccessfulPhonePairingDisplayName READ lastSuccessfulPhonePairingDisplayName NOTIFY + lastSuccessfulPhonePairingDisplayNameChanged) + Q_PROPERTY(qint64 androidPairingReaderCooldownUntilEpochMs READ androidPairingReaderCooldownUntilEpochMs NOTIFY + androidPairingReaderCooldownUntilEpochMsChanged) + +public: + PairingUiController(PairingController *pairingController, ServersController *serversController, + SubscriptionController *subscriptionController, SecureAppSettingsRepository *appSettingsRepository, + QObject *parent = nullptr); + ~PairingUiController() override; + + QVariantList tvQrCodes() const; + int tvQrCodesCount() const; + int tvPairingWaitWindowSeconds() const; + + bool phonePairingBusy() const; + QString pendingPhonePairingUuid() const { return m_pendingPhonePairingUuid; } + void setPendingPhonePairingUuid(const QString &uuid); + QString lastSuccessfulPhonePairingDisplayName() const { return m_lastSuccessfulPhonePairingDisplayName; } + + qint64 androidPairingReaderCooldownUntilEpochMs() const { return m_androidPairingReaderCooldownUntilEpochMs; } + + Q_INVOKABLE void presentIosPairingQrNativeOverlayScanner(const QString &title = QString(), + const QString &subtitle = QString()); + Q_INVOKABLE void dismissIosPairingQrNativeOverlayScanner(); + Q_INVOKABLE void restartIosPairingQrNativeOverlayCapture(); + +#if defined(Q_OS_ANDROID) + static bool tryConsumeAndroidQrScan(const QString &code); + static void notifyAndroidPairingQrCameraClosed(); + static void notifyAndroidPairingQrCameraUserDismissed(); +#endif + +public slots: + bool canOpenTvQrPairingPage(); + void startTvQrSession(); + void rotateTvQrSession(); + void cancelTvQrSession(); + void cancelAllPairingActivity(); + + void submitPhonePairing(const QString &qrUuid, int serverIndex); + + void openPairingQrScanner(); + + Q_INVOKABLE bool isPairingCameraAccessGranted() const; + Q_INVOKABLE void requestPairingCameraAccess(); + Q_INVOKABLE void openPairingCameraAppSettings(); + Q_INVOKABLE void setPairingQrTorchEnabled(bool enabled); + + bool applyScannedTextAsPairingUuid(const QString &raw); + +signals: + void errorOccurred(amnezia::ErrorCode errorCode); + void tvQrCodesChanged(); + void phonePairingBusyChanged(); + void pendingPhonePairingUuidChanged(); + void lastSuccessfulPhonePairingDisplayNameChanged(); + + void tvPairingConfigReceived(); + void tvPairingConfigAlreadyAdded(); + void phonePairingSucceeded(); + + void pairingUuidFromScan(const QString &uuid); + void pairingCameraAccessFinished(bool granted); + void androidPairingReaderCooldownUntilEpochMsChanged(); + void pairingSendQrScanRejectedInvalidPayload(); + void pairingIosNativeQrOverlayBackRequested(); + void pairingAndroidNativeQrScannerUserDismissed(); + +private: + void setTvBusy(bool busy); + void setPhoneBusy(bool busy); + void resetTvQrDisplay(); + void clearPendingPhonePairingUuid(); + void suppressAndroidNativePairingReaderStarts(int ms); + void dispatchTvGenerateQrAttempt(quint64 generation, int retryAttempt); + void dispatchPhoneScanQrAttempt(const QString &qrUuid, bool isTestPurchase, const QString &vpnKey, const QJsonObject &serviceInfo, + const QJsonArray &supportedProtocols, const QString &apiKey, const QString &serviceType, + const QString &userCountryCode, quint64 generation, int retryAttempt); + + PairingController *m_pairingController {}; + ServersController *m_serversController {}; + SubscriptionController *m_subscriptionController {}; + SecureAppSettingsRepository *m_appSettingsRepository {}; + + QList m_tvQrCodes; + QString m_tvSessionUuid; + bool m_tvPairingBusy = false; + QPointer>> m_tvWatcher; + QPointer m_tvNetworkReply; + quint64 m_tvSessionGeneration { 0 }; + + bool m_phonePairingBusy = false; + QString m_pendingPhonePairingUuid; + QString m_lastSuccessfulPhonePairingDisplayName; + QPointer>> m_phoneWatcher; + QPointer m_phoneNetworkReply; + quint64 m_phoneSessionGeneration { 0 }; + + qint64 m_androidPairingReaderCooldownUntilEpochMs = 0; +}; + +#endif // PAIRINGUICONTROLLER_H diff --git a/client/ui/controllers/api/subscriptionUiController.cpp b/client/ui/controllers/api/subscriptionUiController.cpp index 581a10a2d7..00363ea18c 100644 --- a/client/ui/controllers/api/subscriptionUiController.cpp +++ b/client/ui/controllers/api/subscriptionUiController.cpp @@ -16,6 +16,10 @@ #include #include +#ifdef Q_OS_IOS + #include "platforms/ios/ios_controller.h" +#endif + namespace { namespace configKey @@ -436,7 +440,11 @@ bool SubscriptionUiController::getAccountInfo(const QString &serverId, bool relo if (reload) { QEventLoop wait; QTimer::singleShot(1000, &wait, &QEventLoop::quit); +#ifdef Q_OS_IOS + wait.exec(); +#else wait.exec(QEventLoop::ExcludeUserInputEvents); +#endif } QJsonObject accountInfo; ErrorCode errorCode = m_subscriptionController->getAccountInfo(serverId, accountInfo); diff --git a/client/ui/controllers/qml/pageController.h b/client/ui/controllers/qml/pageController.h index 216328daf9..c57423968b 100644 --- a/client/ui/controllers/qml/pageController.h +++ b/client/ui/controllers/qml/pageController.h @@ -82,6 +82,9 @@ namespace PageLoader PageSetupWizardApiPremiumInfo, PageSetupWizardApiTrialEmail, + PageSettingsApiQrPairingSend, + PageSetupWizardApiQrPairingReceive, + PageDevMenu, PageProtocolXraySnapshots, diff --git a/client/ui/models/api/apiAccountInfoModel.cpp b/client/ui/models/api/apiAccountInfoModel.cpp index 85b1226bb1..5cbf1d1db8 100644 --- a/client/ui/models/api/apiAccountInfoModel.cpp +++ b/client/ui/models/api/apiAccountInfoModel.cpp @@ -1,5 +1,7 @@ #include "apiAccountInfoModel.h" +#include + #include #include @@ -10,6 +12,8 @@ namespace { Logger logger("AccountInfoModel"); + + constexpr QLatin1String kCountryConfigSourceType("country_config"); } ApiAccountInfoModel::ApiAccountInfoModel(QObject *parent) : QAbstractListModel(parent) @@ -106,6 +110,9 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const case IsInAppPurchaseRole: { return m_accountInfoData.isInAppPurchase; } + case ConfigurationFilesCountRole: { + return m_accountInfoData.configurationFilesCount; + } } return QVariant(); @@ -120,6 +127,15 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons m_availableCountries = accountInfoObject.value(apiDefs::key::availableCountries).toArray(); m_issuedConfigsInfo = accountInfoObject.value(apiDefs::key::issuedConfigs).toArray(); + int configurationFilesCount = 0; + for (int i = 0; i < m_issuedConfigsInfo.size(); ++i) { + const QJsonObject issued = m_issuedConfigsInfo.at(i).toObject(); + if (issued.value(apiDefs::key::sourceType).toString() == kCountryConfigSourceType) { + ++configurationFilesCount; + } + } + accountInfoData.configurationFilesCount = configurationFilesCount; + accountInfoData.activeDeviceCount = accountInfoObject.value(apiDefs::key::activeDeviceCount).toInt(); accountInfoData.maxDeviceCount = accountInfoObject.value(apiDefs::key::maxDeviceCount).toInt(); accountInfoData.subscriptionEndDate = accountInfoObject.value(apiDefs::key::subscriptionEndDate).toString(); @@ -205,6 +221,7 @@ QHash ApiAccountInfoModel::roleNames() const roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired"; roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon"; roles[IsInAppPurchaseRole] = "isInAppPurchase"; + roles[ConfigurationFilesCountRole] = "configurationFilesCount"; return roles; } diff --git a/client/ui/models/api/apiAccountInfoModel.h b/client/ui/models/api/apiAccountInfoModel.h index b0c559a4e4..f9b4875375 100644 --- a/client/ui/models/api/apiAccountInfoModel.h +++ b/client/ui/models/api/apiAccountInfoModel.h @@ -25,7 +25,8 @@ class ApiAccountInfoModel : public QAbstractListModel IsProtocolSelectionSupportedRole, IsSubscriptionExpiredRole, IsSubscriptionExpiringSoonRole, - IsInAppPurchaseRole + IsInAppPurchaseRole, + ConfigurationFilesCountRole }; explicit ApiAccountInfoModel(QObject *parent = nullptr); @@ -64,6 +65,7 @@ public slots: bool isInAppPurchase = false; bool isRenewalAvailable = false; + int configurationFilesCount = 0; }; AccountInfoData m_accountInfoData; diff --git a/client/ui/qml/Controls2/ContextMenuType.qml b/client/ui/qml/Controls2/ContextMenuType.qml index 6b3c3ae1cb..03b0397a41 100644 --- a/client/ui/qml/Controls2/ContextMenuType.qml +++ b/client/ui/qml/Controls2/ContextMenuType.qml @@ -4,7 +4,7 @@ import QtQuick.Controls Menu { property var textObj - popupType: Popup.Native + popupType: Qt.platform.os === "ios" ? Popup.Item : Popup.Native onAboutToShow: blocker.enabled = true onClosed: blocker.enabled = false diff --git a/client/ui/qml/Pages2/PageSettingsApiDevices.qml b/client/ui/qml/Pages2/PageSettingsApiDevices.qml index 6aecdc634c..b94f225286 100644 --- a/client/ui/qml/Pages2/PageSettingsApiDevices.qml +++ b/client/ui/qml/Pages2/PageSettingsApiDevices.qml @@ -19,6 +19,111 @@ import "../Components" PageType { id: root + property bool pendingOpenQrPageAfterCamera: false + property bool waitingSettingsReturnForQrPage: false + + function proceedOpenQrPairingPage() { + PageController.goToPage(PageEnum.PageSettingsApiQrPairingSend) + pendingOpenQrPageAfterCamera = false + waitingSettingsReturnForQrPage = false + } + + function showCameraDeniedDrawer() { + showQuestionDrawer( + qsTr("Camera access is required"), + qsTr("Allow camera access to scan the pairing QR code. You can enable it in the system settings for Amnezia VPN."), + qsTr("Open settings"), + qsTr("Cancel"), + function() { + PairingUiController.openPairingCameraAppSettings() + }, + function() { + waitingSettingsReturnForQrPage = false + }) + } + + function tryResumeQrPageAfterCameraSettings() { + if (!waitingSettingsReturnForQrPage || !root.visible) { + return + } + if (PairingUiController.isPairingCameraAccessGranted()) { + proceedOpenQrPairingPage() + } + } + + function openAddDeviceViaQr() { + if (Qt.platform.os !== "android" && Qt.platform.os !== "ios") { + PageController.goToPage(PageEnum.PageSettingsApiQrPairingSend) + return + } + if (PairingUiController.isPairingCameraAccessGranted()) { + proceedOpenQrPairingPage() + return + } + pendingOpenQrPageAfterCamera = true + PairingUiController.requestPairingCameraAccess() + } + + onVisibleChanged: { + if (!visible) { + pendingOpenQrPageAfterCamera = false + waitingSettingsReturnForQrPage = false + } + } + + Connections { + target: Qt.application + + function onStateChanged() { + if (Qt.application.state !== Qt.ApplicationActive) { + return + } + root.tryResumeQrPageAfterCameraSettings() + } + } + + Connections { + target: SettingsController + + enabled: Qt.platform.os === "android" + + function onActivityResumed() { + root.tryResumeQrPageAfterCameraSettings() + } + } + + Connections { + target: PairingUiController + + function onPairingCameraAccessFinished(granted) { + if (!root.pendingOpenQrPageAfterCamera) { + return + } + root.pendingOpenQrPageAfterCamera = false + if (granted) { + root.proceedOpenQrPairingPage() + } else { + root.waitingSettingsReturnForQrPage = true + root.showCameraDeniedDrawer() + } + } + + function onPhonePairingSucceeded() { + SubscriptionUiController.updateApiDevicesModel() + const label = PairingUiController.lastSuccessfulPhonePairingDisplayName + if (label.length > 0) { + PageController.showNotificationMessage( + qsTr("Configuration was sent (%1). Finish setup on the device that displayed the QR code β€” " + + "if it already has this config, that device will show a message.").arg(label)) + } else { + PageController.showNotificationMessage( + qsTr("Configuration was sent to the device that displayed the QR code. " + + "If it already has this config, that device will show a message.")) + } + } + + } + ListViewType { id: listView @@ -46,6 +151,41 @@ PageType { descriptionText: qsTr("Manage currently connected devices") } + BasicButtonType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 20 + + implicitHeight: 52 + + defaultColor: AmneziaStyle.color.transparent + hoveredColor: AmneziaStyle.color.translucentWhite + pressedColor: AmneziaStyle.color.sheerWhite + textColor: AmneziaStyle.color.paleGray + borderColor: AmneziaStyle.color.paleGray + borderWidth: 1 + + text: qsTr("Add Device via QR Code") + + clickedFunc: function() { + root.openAddDeviceViaQr() + } + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 12 + + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + font.pixelSize: 13 + color: AmneziaStyle.color.mutedGray + text: qsTr("On the other device, tap + at the bottom, then choose Connect to Amnezia Premium") + } + WarningType { Layout.topMargin: 16 Layout.rightMargin: 16 @@ -94,6 +234,26 @@ PageType { DividerType {} } + + footer: ColumnLayout { + width: listView.width + + LabelWithButtonType { + Layout.fillWidth: true + Layout.topMargin: 8 + + text: qsTr("Configuration Files: %1").arg(ApiAccountInfoModel.data("configurationFilesCount")) + descriptionText: qsTr("Generated configuration files also count towards the device limit") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + + clickedFunction: function() { + SubscriptionUiController.updateApiCountryModel() + PageController.goToPage(PageEnum.PageSettingsApiNativeConfigs) + } + } + + DividerType {} + } } function deactivateExternalDevice(serverId, supportTag, countryCode) { diff --git a/client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml b/client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml new file mode 100644 index 0000000000..5bac797184 --- /dev/null +++ b/client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml @@ -0,0 +1,339 @@ +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 + + readonly property bool useIosNativePairingQrOverlay: GC.isMobile() && Qt.platform.os === "ios" + + readonly property bool useAndroidNativePairingQrOverlay: GC.isMobile() && Qt.platform.os === "android" + + property int pairingWizardStep: 0 + property bool keepPhonePairingInBackgroundOnClose: false + + property int lastInvalidPairingQrToastClockMs: 0 + property bool addDeviceConfirmNavigationScheduled: false + property bool awaitingCameraPermissionForScan: false + property bool waitingSettingsReturnForScan: false + + /** Suppress double startActivity when StackView fires both Component.onCompleted and onVisibleChanged. */ + property int _androidPairingReaderLastStartMs: 0 + + function stopMobileScanner() { + if (root.useIosNativePairingQrOverlay) { + PairingUiController.setPairingQrTorchEnabled(false) + PairingUiController.dismissIosPairingQrNativeOverlayScanner() + } + } + + function startMobileScanner() { + if (!GC.isMobile()) { + return + } + if (!root.visible) { + return + } + if (root.pairingWizardStep !== 0) { + return + } + if (addDeviceConfirmNavigationScheduled) { + return + } + if (!PairingUiController.isPairingCameraAccessGranted()) { + awaitingCameraPermissionForScan = true + PairingUiController.requestPairingCameraAccess() + return + } + if (root.useIosNativePairingQrOverlay) { + PairingUiController.presentIosPairingQrNativeOverlayScanner( + qsTr("Add device via QR"), + qsTr("Scan the session QR shown on the device you want to add. You will confirm before the subscription is sent.")) + return + } + if (Qt.platform.os === "android") { + const coolUntil = PairingUiController.androidPairingReaderCooldownUntilEpochMs + if (Date.now() < coolUntil) { + return + } + const now = Date.now() + if (now - _androidPairingReaderLastStartMs < 700) { + return + } + _androidPairingReaderLastStartMs = now + PairingUiController.openPairingQrScanner() + } + } + + function showScanCameraDeniedDrawer() { + showQuestionDrawer( + qsTr("Camera access is required"), + qsTr("Allow camera access to scan the pairing QR code. You can enable it in the system settings for Amnezia VPN."), + qsTr("Open settings"), + qsTr("Cancel"), + function() { + PairingUiController.openPairingCameraAppSettings() + }, + function() { + root.waitingSettingsReturnForScan = false + }) + } + + function tryResumeScanAfterCameraSettings() { + if (!waitingSettingsReturnForScan || !visible || pairingWizardStep !== 0) { + return + } + if (PairingUiController.isPairingCameraAccessGranted()) { + waitingSettingsReturnForScan = false + startMobileScanner() + } + } + + Component.onDestruction: { + if (!keepPhonePairingInBackgroundOnClose && !PairingUiController.phonePairingBusy) { + PairingUiController.cancelAllPairingActivity() + } + } + + onVisibleChanged: { + if (visible) { + if (pairingWizardStep === 0) { + addDeviceConfirmNavigationScheduled = false + Qt.callLater(startMobileScanner) + } + } else if (!PairingUiController.phonePairingBusy) { + stopMobileScanner() + _androidPairingReaderLastStartMs = 0 + pairingWizardStep = 0 + waitingSettingsReturnForScan = false + if (!keepPhonePairingInBackgroundOnClose) { + PairingUiController.cancelAllPairingActivity() + } + } + } + + onPairingWizardStepChanged: { + if (pairingWizardStep !== 0) { + stopMobileScanner() + } else if (root.visible) { + Qt.callLater(startMobileScanner) + } + } + + Component.onCompleted: { + if (visible && pairingWizardStep === 0) { + Qt.callLater(startMobileScanner) + } + } + + Connections { + target: Qt.application + + function onStateChanged() { + if (Qt.application.state !== Qt.ApplicationActive) { + return + } + root.tryResumeScanAfterCameraSettings() + if (!root.useIosNativePairingQrOverlay || root.pairingWizardStep !== 0 + || !PairingUiController.isPairingCameraAccessGranted()) { + return + } + Qt.callLater(function () { + if (!root.visible || root.pairingWizardStep !== 0 || !GC.isMobile()) { + return + } + PairingUiController.restartIosPairingQrNativeOverlayCapture() + }) + } + } + + Connections { + target: SettingsController + + enabled: Qt.platform.os === "android" + + function onActivityResumed() { + root.tryResumeScanAfterCameraSettings() + } + } + + Item { + anchors.fill: parent + + Rectangle { + anchors.fill: parent + visible: pairingWizardStep === 0 && root.useAndroidNativePairingQrOverlay + color: AmneziaStyle.color.midnightBlack + z: 1 + } + + BackButtonType { + visible: pairingWizardStep === 0 && (root.useAndroidNativePairingQrOverlay || !GC.isMobile()) + anchors.top: parent.top + anchors.topMargin: PageController.safeAreaTopMargin + anchors.left: parent.left + width: parent.width + z: 2 + backButtonFunction: function() { + PageController.closePage() + } + } + + Column { + anchors.centerIn: parent + width: parent.width - 48 + spacing: 12 + visible: pairingWizardStep === 0 && !GC.isMobile() + + Label { + width: parent.width + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + color: AmneziaStyle.color.mutedGray + font.pixelSize: 15 + text: qsTr("QR pairing is available in the mobile app.") + } + } + + ColumnLayout { + id: confirmStep + anchors.fill: parent + visible: pairingWizardStep === 1 + z: 10 + spacing: 16 + + BackButtonType { + Layout.topMargin: 20 + PageController.safeAreaTopMargin + Layout.leftMargin: 0 + backButtonFunction: function() { + addDeviceConfirmNavigationScheduled = false + pairingWizardStep = 0 + PairingUiController.cancelAllPairingActivity() + } + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + text: qsTr("Add a new device to the subscription?") + font.pixelSize: 28 + font.bold: true + color: AmneziaStyle.color.paleGray + wrapMode: Text.Wrap + } + + BasicButtonType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + + text: qsTr("Add Device") + defaultColor: AmneziaStyle.color.paleGray + hoveredColor: AmneziaStyle.color.lightGray + pressedColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.midnightBlack + + clickedFunc: function() { + keepPhonePairingInBackgroundOnClose = true + PairingUiController.submitPhonePairing(PairingUiController.pendingPhonePairingUuid, + ServersUiController.getProcessedServerIndex()) + Qt.callLater(function() { + PageController.closePage() + }) + } + } + + BasicButtonType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + + defaultColor: AmneziaStyle.color.transparent + hoveredColor: AmneziaStyle.color.translucentWhite + pressedColor: AmneziaStyle.color.sheerWhite + textColor: AmneziaStyle.color.paleGray + borderColor: AmneziaStyle.color.paleGray + borderWidth: 1 + text: qsTr("Cancel") + + clickedFunc: function() { + addDeviceConfirmNavigationScheduled = false + pairingWizardStep = 0 + PairingUiController.cancelAllPairingActivity() + } + } + + Item { + Layout.fillHeight: true + } + } + } + + Connections { + target: PairingUiController + + function onPairingCameraAccessFinished(granted) { + if (!awaitingCameraPermissionForScan) { + return + } + awaitingCameraPermissionForScan = false + if (granted) { + if (root.pairingWizardStep === 0) { + startMobileScanner() + } + } else { + waitingSettingsReturnForScan = true + showScanCameraDeniedDrawer() + } + } + + function onPairingUuidFromScan(uuid) { + if (addDeviceConfirmNavigationScheduled) { + return + } + addDeviceConfirmNavigationScheduled = true + stopMobileScanner() + PairingUiController.pendingPhonePairingUuid = uuid + pairingWizardStep = 1 + } + + function onPairingSendQrScanRejectedInvalidPayload() { + if (!root.useIosNativePairingQrOverlay || root.pairingWizardStep !== 0) { + return + } + const now = new Date().getTime() + if (now - lastInvalidPairingQrToastClockMs >= 2200) { + lastInvalidPairingQrToastClockMs = now + PageController.showNotificationMessage( + qsTr("This QR code is not a pairing session. Show the code from the other device’s β€œreceive config” screen.")) + } + } + + function onPairingIosNativeQrOverlayBackRequested() { + stopMobileScanner() + PageController.closePage() + } + + function onPairingAndroidNativeQrScannerUserDismissed() { + if (!root.useAndroidNativePairingQrOverlay) { + return + } + stopMobileScanner() + PairingUiController.cancelAllPairingActivity() + addDeviceConfirmNavigationScheduled = false + PageController.closePage() + } + } +} diff --git a/client/ui/qml/Pages2/PageSetupWizardApiQrPairingReceive.qml b/client/ui/qml/Pages2/PageSetupWizardApiQrPairingReceive.qml new file mode 100644 index 0000000000..6a1bb04a96 --- /dev/null +++ b/client/ui/qml/Pages2/PageSetupWizardApiQrPairingReceive.qml @@ -0,0 +1,192 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Style 1.0 + +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + readonly property int qrRefreshIntervalMs: Math.max(5000, PairingUiController.tvPairingWaitWindowSeconds * 1000) + + function scrollPairingToBottom() { + receiveScroll.contentY = Math.max(0, receiveScroll.contentHeight - receiveScroll.height) + } + + function beginReceiveFlow() { + PairingUiController.startTvQrSession() + qrRotationTimer.restart() + } + + Timer { + id: scrollToBottomRetryTimer + interval: 48 + repeat: true + property int retries: 0 + onTriggered: { + root.scrollPairingToBottom() + retries++ + if (retries >= 12) { + stop() + } + } + onRunningChanged: { + if (!running) { + retries = 0 + } + } + } + + Timer { + id: qrRotationTimer + interval: root.qrRefreshIntervalMs + repeat: true + running: root.visible + onTriggered: { + PairingUiController.rotateTvQrSession() + } + } + + Connections { + target: root + function onVisibleChanged() { + if (!root.visible) { + PairingUiController.cancelAllPairingActivity() + scrollToBottomRetryTimer.stop() + qrRotationTimer.stop() + } else { + Qt.callLater(root.beginReceiveFlow) + } + } + } + + Component.onCompleted: { + if (root.visible) { + Qt.callLater(root.beginReceiveFlow) + } + } + + FlickableType { + id: receiveScroll + anchors.fill: parent + contentHeight: layout.implicitHeight + + Behavior on contentY { + NumberAnimation { + duration: 320 + easing.type: Easing.OutCubic + } + } + + onContentHeightChanged: { + if (PairingUiController.tvQrCodesCount > 0) { + Qt.callLater(root.scrollPairingToBottom) + } + } + + ColumnLayout { + id: layout + width: root.width + spacing: 12 + + BackButtonType { + Layout.topMargin: 20 + PageController.safeAreaTopMargin + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + horizontalAlignment: Text.AlignHCenter + text: qsTr("Scan this QR code with a phone that has an active Amnezia Premium subscription") + font.pixelSize: 17 + font.bold: false + color: AmneziaStyle.color.paleGray + wrapMode: Text.Wrap + } + + Item { + id: qrBox + Layout.fillWidth: true + Layout.leftMargin: 24 + Layout.rightMargin: 24 + Layout.topMargin: 16 + // Avoid width*0.92 before first layout (width can be 0 β†’ zero height β†’ no QR). + Layout.preferredHeight: PairingUiController.tvQrCodesCount > 0 ? Math.max(200, layout.width - 48) : 0 + visible: PairingUiController.tvQrCodesCount > 0 + + Rectangle { + anchors.fill: parent + radius: 20 + color: "#FFFFFF" + + Image { + id: qrImage + anchors.fill: parent + anchors.margins: 20 + fillMode: Image.PreserveAspectFit + sourceSize: Qt.size(2048, 2048) + source: PairingUiController.tvQrCodesCount > 0 ? PairingUiController.tvQrCodes[0] : "" + } + } + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + horizontalAlignment: Text.AlignHCenter + color: AmneziaStyle.color.mutedGray + font.pixelSize: 13 + text: qsTr("AmneziaVPN β†’ Amnezia Premium β†’\nPersonal Dashboard β†’ Active Devices β†’\nAdd Device via QR Code") + wrapMode: Text.Wrap + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 24 + PageController.safeAreaBottomMargin + } + } + } + + Connections { + target: PairingUiController + + function onTvQrCodesChanged() { + if (PairingUiController.tvQrCodesCount > 0) { + scrollToBottomRetryTimer.retries = 0 + scrollToBottomRetryTimer.start() + Qt.callLater(function() { + root.scrollPairingToBottom() + }) + Qt.callLater(function() { + Qt.callLater(function() { + root.scrollPairingToBottom() + }) + }) + } + } + + function onTvPairingConfigReceived() { + scrollToBottomRetryTimer.stop() + qrRotationTimer.stop() + qrImage.source = "" + PageController.showNotificationMessage(qsTr("Configuration received from gateway")) + Qt.callLater(function() { + PageController.closePage() + }) + } + + function onTvPairingConfigAlreadyAdded() { + scrollToBottomRetryTimer.stop() + qrRotationTimer.restart() + } + } +} diff --git a/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml b/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml index 1f33189338..883b14ec69 100644 --- a/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml +++ b/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml @@ -269,6 +269,7 @@ PageType { selfHostVpn, backupRestore, fileOpen, + gatewayQrPairingAddServer, qrScan, restorePurchases, siteLink @@ -343,6 +344,24 @@ PageType { } } + QtObject { + id: gatewayQrPairingAddServer + + property bool featuredAmneziaConnection: false + property string title: qsTr("Scan a QR code") + property string description: qsTr("To connect to a self-hosted server") + property string imageSource: "qrc:/images/controls/folder-search-2.svg" + property bool isVisible: true + property var handler: function() { + PageController.showBusyIndicator(true) + var result = PairingUiController.canOpenTvQrPairingPage() + PageController.showBusyIndicator(false) + if (result) { + PageController.goToPage(PageEnum.PageSetupWizardApiQrPairingReceive) + } + } + } + QtObject { id: qrScan diff --git a/client/ui/qml/qml.qrc b/client/ui/qml/qml.qrc index 5785b4c78f..d038fc90fd 100644 --- a/client/ui/qml/qml.qrc +++ b/client/ui/qml/qml.qrc @@ -97,6 +97,8 @@ Pages2/PageSettingsAbout.qml Pages2/PageSettingsApiAvailableCountries.qml Pages2/PageSettingsApiServerInfo.qml + Pages2/PageSettingsApiQrPairingSend.qml + Pages2/PageSetupWizardApiQrPairingReceive.qml Pages2/PageSettingsApplication.qml Pages2/PageSettingsAppSplitTunneling.qml Pages2/PageSettingsBackup.qml