mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-05-22 19:05:39 +03:00
Compare commits
45 Commits
dev
...
feat/imple
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67dcadcd42 | ||
|
|
a4b2b3e3ad | ||
|
|
3d540b22bf | ||
|
|
d670bca9d4 | ||
|
|
b09a4ecd8d | ||
|
|
c327d3e3c8 | ||
|
|
9c9e1700af | ||
|
|
eba2097d1d | ||
|
|
22de0c2a16 | ||
|
|
614973a4ce | ||
|
|
e554e9b8b4 | ||
|
|
d4833454ef | ||
|
|
9851b4bacb | ||
|
|
29ad1f0c02 | ||
|
|
d6c34b3f60 | ||
|
|
d8668742b4 | ||
|
|
5eab5fc18b | ||
|
|
b46a9e389f | ||
|
|
81b8cd05c2 | ||
|
|
d0a9f6e4d5 | ||
|
|
8a29b49fd7 | ||
|
|
1baa2d85bd | ||
|
|
e226fadb07 | ||
|
|
bf4bf9972d | ||
|
|
f781bf6a23 | ||
|
|
2fa0ec81ad | ||
|
|
1ee0a6c9c7 | ||
|
|
14c7aab0fb | ||
|
|
d3347e6007 | ||
|
|
026826970c | ||
|
|
d2d3545961 | ||
|
|
b7e2847393 | ||
|
|
bb56008c3d | ||
|
|
a53db6eafe | ||
|
|
433ecb448f | ||
|
|
ab12a0b3f0 | ||
|
|
5a192cec15 | ||
|
|
6fc65dba8a | ||
|
|
f65fd4a8c5 | ||
|
|
c877e1e5cb | ||
|
|
2cb12c596c | ||
|
|
5beae954c7 | ||
|
|
5583c0a2a9 | ||
|
|
2cb7b30d8a | ||
|
|
2f6714e278 |
@@ -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
|
||||
|
||||
@@ -109,6 +109,9 @@ void AmneziaApplication::init()
|
||||
// install filter on main window
|
||||
if (auto win = qobject_cast<QQuickWindow*>(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) {
|
||||
|
||||
10
client/android/res/drawable/ic_pairing_back.xml
Normal file
10
client/android/res/drawable/ic_pairing_back.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFE8E8EC"
|
||||
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
|
||||
</vector>
|
||||
5
client/android/res/drawable/torch_fab_bg.xml
Normal file
5
client/android/res/drawable/torch_fab_bg.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#38FFFFFF" />
|
||||
</shape>
|
||||
@@ -8,4 +8,75 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<org.amnezia.vpn.PairingQrScanOverlayView
|
||||
android:id="@+id/pairingScanOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/pairingChrome"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top"
|
||||
android:background="@android:color/transparent"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="28dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/pairingBack"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="top"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/pairing_qr_camera_back"
|
||||
android:padding="12dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_pairing_back" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pairingTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/pairing_qr_camera_title"
|
||||
android:textColor="#FFE8E8EC"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pairingSubtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/pairing_qr_camera_subtitle"
|
||||
android:textColor="#FFB8B8C0"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/torchButton"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:background="@drawable/torch_fab_bg"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:text="🔦"
|
||||
android:textSize="26sp"
|
||||
android:contentDescription="@string/camera_torch" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -24,5 +24,13 @@
|
||||
<string name="notificationSettingsDialogMessage">Для показа уведомлений необходимо включить уведомления в системных настройках</string>
|
||||
<string name="openNotificationSettings">Открыть настройки уведомлений</string>
|
||||
|
||||
<string name="cameraPermissionDialogTitle">Доступ к камере</string>
|
||||
<string name="cameraPermissionDialogMessage">Чтобы отсканировать QR-код для добавления устройства, Amnezia VPN нужен доступ к камере.</string>
|
||||
<string name="cameraPermissionContinue">Продолжить</string>
|
||||
<string name="camera_torch">Фонарик</string>
|
||||
<string name="pairing_qr_camera_title">Добавить устройство по QR</string>
|
||||
<string name="pairing_qr_camera_subtitle">Отсканируйте QR сессии на устройстве, которое хотите добавить. Перед отправкой подписки будет подтверждение.</string>
|
||||
<string name="pairing_qr_camera_back">Назад</string>
|
||||
|
||||
<string name="tvNoFileBrowser">Пожалуйста, установите приложение для просмотра файлов</string>
|
||||
</resources>
|
||||
@@ -24,5 +24,13 @@
|
||||
<string name="notificationSettingsDialogMessage">To show notifications, you must enable notifications in the system settings</string>
|
||||
<string name="openNotificationSettings">Open notification settings</string>
|
||||
|
||||
<string name="cameraPermissionDialogTitle">Camera access</string>
|
||||
<string name="cameraPermissionDialogMessage">To scan a QR code for device pairing, Amnezia VPN needs access to the camera.</string>
|
||||
<string name="cameraPermissionContinue">Continue</string>
|
||||
<string name="camera_torch">Flashlight</string>
|
||||
<string name="pairing_qr_camera_title">Add device via QR</string>
|
||||
<string name="pairing_qr_camera_subtitle">Scan the session QR shown on the device you want to add. You will confirm before the subscription is sent.</string>
|
||||
<string name="pairing_qr_camera_back">Back</string>
|
||||
|
||||
<string name="tvNoFileBrowser">Please install a file management utility to browse files</string>
|
||||
</resources>
|
||||
@@ -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<Unit>()
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OutputTransform?>(null)
|
||||
|
||||
private var previewTransformLayoutListener: View.OnLayoutChangeListener? = null
|
||||
|
||||
private var previewStreamStateObserver: Observer<PreviewView.StreamState>? = 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<PreviewView.StreamState> { 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<String>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
101
client/android/src/org/amnezia/vpn/PairingQrScanBracketPaths.kt
Normal file
101
client/android/src/org/amnezia/vpn/PairingQrScanBracketPaths.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
152
client/android/src/org/amnezia/vpn/PairingQrScanGeometry.kt
Normal file
152
client/android/src/org/amnezia/vpn/PairingQrScanGeometry.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
115
client/android/src/org/amnezia/vpn/PairingQrScanOverlayView.kt
Normal file
115
client/android/src/org/amnezia/vpn/PairingQrScanOverlayView.kt
Normal file
@@ -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<Path>(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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,10 @@ object QtAndroidController {
|
||||
|
||||
external fun onActivityPaused()
|
||||
external fun onActivityResumed()
|
||||
|
||||
external fun onCameraPermissionResult(granted: Boolean)
|
||||
|
||||
external fun onPairingQrCameraClosed()
|
||||
|
||||
external fun onPairingQrCameraUserDismissed()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ QFuture<QPair<ErrorCode, QJsonArray>> 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<ErrorCode, QByteArray> result) -> QPair<ErrorCode, QJsonArray> {
|
||||
auto [errorCode, responseBody] = result;
|
||||
if (errorCode != ErrorCode::NoError) {
|
||||
|
||||
204
client/core/controllers/api/pairingController.cpp
Normal file
204
client/core/controllers/api/pairingController.cpp
Normal file
@@ -0,0 +1,204 @@
|
||||
#include "pairingController.h"
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <QSysInfo>
|
||||
#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;
|
||||
}
|
||||
41
client/core/controllers/api/pairingController.h
Normal file
41
client/core/controllers/api/pairingController.h
Normal file
@@ -0,0 +1,41 @@
|
||||
#ifndef PAIRINGCONTROLLER_H
|
||||
#define PAIRINGCONTROLLER_H
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
#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
|
||||
@@ -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<QPair<ErrorCode, QString>> 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<QPair<ErrorCode, QByteArray>>();
|
||||
QObject::connect(watcher, &QFutureWatcher<QPair<ErrorCode, QByteArray>>::finished,
|
||||
[promise, watcher, gatewayController]() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef SUBSCRIPTIONCONTROLLER_H
|
||||
#define SUBSCRIPTIONCONTROLLER_H
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QByteArray>
|
||||
#include <QFuture>
|
||||
@@ -53,6 +54,9 @@ public:
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 @@ private:
|
||||
UpdateUiController* m_updateUiController;
|
||||
|
||||
SubscriptionUiController* m_subscriptionUiController;
|
||||
PairingUiController* m_pairingUiController;
|
||||
ApiNewsUiController* m_apiNewsUiController;
|
||||
|
||||
ServicesCatalogUiController* m_servicesCatalogUiController;
|
||||
@@ -179,6 +182,7 @@ private:
|
||||
AllowedDnsController* m_allowedDnsController;
|
||||
ServicesCatalogController* m_servicesCatalogController;
|
||||
SubscriptionController* m_subscriptionController;
|
||||
PairingController* m_pairingController;
|
||||
NewsController* m_newsController;
|
||||
UpdateController* m_updateController;
|
||||
InstallController* m_installController;
|
||||
|
||||
@@ -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<ErrorCode>(&PageController::showErrorMessage));
|
||||
|
||||
connect(m_coreController->m_pairingUiController, &PairingUiController::errorOccurred, m_coreController->m_pageController,
|
||||
qOverload<ErrorCode>(&PageController::showErrorMessage));
|
||||
|
||||
connect(m_coreController->m_settingsUiController, &SettingsUiController::errorOccurred, m_coreController->m_pageController,
|
||||
qOverload<ErrorCode>(&PageController::showErrorMessage));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkReply>
|
||||
#include <QPromise>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
|
||||
#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<QSslError> sslErrors;
|
||||
connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList<QSslError> &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<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString &endpoint, const QJsonObject apiPayload)
|
||||
QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString &endpoint, const QJsonObject &apiPayload,
|
||||
QNetworkReply **activeReplyOut,
|
||||
const QSharedPointer<GatewayController> &keepAlive)
|
||||
{
|
||||
auto promise = QSharedPointer<QPromise<QPair<ErrorCode, QByteArray>>>::create();
|
||||
promise->start();
|
||||
|
||||
const QSharedPointer<GatewayController> life = keepAlive;
|
||||
|
||||
EncryptedRequestData encRequestData = prepareRequest(endpoint, apiPayload);
|
||||
if (encRequestData.errorCode != ErrorCode::NoError) {
|
||||
promise->addResult(qMakePair(encRequestData.errorCode, QByteArray()));
|
||||
@@ -234,12 +299,22 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
|
||||
}
|
||||
|
||||
QNetworkReply *reply = amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody);
|
||||
if (activeReplyOut) {
|
||||
*activeReplyOut = reply;
|
||||
}
|
||||
|
||||
auto sslErrors = QSharedPointer<QList<QSslError>>::create();
|
||||
|
||||
connect(reply, &QNetworkReply::sslErrors, [sslErrors](const QList<QSslError> &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<QPair<ErrorCode, QByteArray>> 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<QSslError> &sslErrors, QNetworkReply::NetworkError replyError,
|
||||
@@ -273,13 +360,13 @@ QFuture<QPair<ErrorCode, QByteArray>> 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<QPair<ErrorCode, QByteArray>> 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<QSslError> &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<QSslError> &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<QSslError> &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<QSslError> &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<QSslError> &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<void(const QStringList &)> onComplete)
|
||||
void GatewayController::getProxyUrlsAsync(const QSharedPointer<GatewayController> &life, const QStringList &proxyStorageUrls,
|
||||
const int currentProxyStorageIndex, const std::function<void(const QStringList &)> &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<QSslError> &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<void(const QString &)> onComplete)
|
||||
void GatewayController::getProxyUrlAsync(const QSharedPointer<GatewayController> &life, const QStringList &proxyUrls,
|
||||
const int currentProxyIndex, const std::function<void(const QString &)> &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<QSslError> &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<void(const QByteArray &, bool, const QList<QSslError> &, QNetworkReply::NetworkError, const QString &, int)> onComplete)
|
||||
const QSharedPointer<GatewayController> &life, const QString &endpoint, const QString &proxyUrl,
|
||||
const EncryptedRequestData &encRequestData,
|
||||
const std::function<void(const QByteArray &, bool, const QList<QSslError> &, QNetworkReply::NetworkError, const QString &, int)>
|
||||
&onComplete)
|
||||
{
|
||||
auto sslErrors = QSharedPointer<QList<QSslError>>::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<QSslError> &errors) { *sslErrors = errors; });
|
||||
connect(reply, &QNetworkReply::sslErrors, reply, [sslErrors](const QList<QSslError> &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);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#ifndef GATEWAYCONTROLLER_H
|
||||
#define GATEWAYCONTROLLER_H
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include <QFuture>
|
||||
#include <QNetworkReply>
|
||||
#include <QObject>
|
||||
@@ -25,7 +27,9 @@ public:
|
||||
const bool isStrictKillSwitchEnabled, QObject *parent = nullptr);
|
||||
|
||||
amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody);
|
||||
QFuture<QPair<amnezia::ErrorCode, QByteArray>> postAsync(const QString &endpoint, const QJsonObject apiPayload);
|
||||
QFuture<QPair<amnezia::ErrorCode, QByteArray>> postAsync(const QString &endpoint, const QJsonObject &apiPayload,
|
||||
QNetworkReply **activeReplyOut = nullptr,
|
||||
const QSharedPointer<GatewayController> &keepAlive = {});
|
||||
|
||||
private:
|
||||
struct EncryptedRequestData
|
||||
@@ -36,6 +40,7 @@ private:
|
||||
QByteArray iv;
|
||||
QByteArray salt;
|
||||
amnezia::ErrorCode errorCode;
|
||||
bool isPlaintextLocalGateway = false;
|
||||
};
|
||||
|
||||
struct DecryptionResult
|
||||
@@ -47,6 +52,8 @@ private:
|
||||
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 @@ private:
|
||||
std::function<QNetworkReply *(const QString &url)> requestFunction,
|
||||
std::function<bool(QNetworkReply *reply, const QList<QSslError> &sslErrors)> replyProcessingFunction);
|
||||
|
||||
void getProxyUrlsAsync(const QStringList proxyStorageUrls, const int currentProxyStorageIndex,
|
||||
std::function<void(const QStringList &)> onComplete);
|
||||
void getProxyUrlAsync(const QStringList proxyUrls, const int currentProxyIndex, std::function<void(const QString &)> onComplete);
|
||||
void getProxyUrlsAsync(const QSharedPointer<GatewayController> &life, const QStringList &proxyStorageUrls, int currentProxyStorageIndex,
|
||||
const std::function<void(const QStringList &)> &onComplete);
|
||||
void getProxyUrlAsync(const QSharedPointer<GatewayController> &life, const QStringList &proxyUrls, int currentProxyIndex,
|
||||
const std::function<void(const QString &)> &onComplete);
|
||||
void bypassProxyAsync(
|
||||
const QString &endpoint, const QString &proxyUrl, EncryptedRequestData encRequestData,
|
||||
std::function<void(const QByteArray &, bool, const QList<QSslError> &, QNetworkReply::NetworkError, const QString &, int)> onComplete);
|
||||
const QSharedPointer<GatewayController> &life, const QString &endpoint, const QString &proxyUrl, const EncryptedRequestData &encRequestData,
|
||||
const std::function<void(const QByteArray &, bool, const QList<QSslError> &, QNetworkReply::NetworkError, const QString &, int)> &onComplete);
|
||||
|
||||
int m_requestTimeoutMsecs;
|
||||
QString m_gatewayEndpoint;
|
||||
|
||||
@@ -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::function<void(bo
|
||||
|
||||
void UpdateController::fetchGatewayUrl()
|
||||
{
|
||||
if (!m_appSettingsRepository || m_appSettingsRepository->isDevGatewayEnv()) {
|
||||
finishUpdateCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
auto gatewayController = QSharedPointer<GatewayController>::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<ErrorCode, QByteArray> 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;
|
||||
}
|
||||
|
||||
@@ -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<QSslError> &sslErrors, const QString &replyErrorString,
|
||||
const QNetworkReply::NetworkError &replyError, const int httpStatusCode,
|
||||
const QByteArray &responseBody)
|
||||
@@ -133,9 +153,28 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList<QSslError> &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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
#include <QIODevice>
|
||||
#include <QList>
|
||||
|
||||
QList<QString> 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<QString> qrCodeUtils::generateQrCodeImageSeries(const QByteArray &data)
|
||||
{
|
||||
double k = 850;
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace qrCodeUtils
|
||||
constexpr const qint16 qrMagicCode = 1984;
|
||||
|
||||
QList<QString> generateQrCodeImageSeries(const QByteArray &data);
|
||||
QList<QString> generateQrCodeImageSeriesPlainText(const QByteArray &utf8Text);
|
||||
qrcodegen::QrCode generateQrCode(const QByteArray &data);
|
||||
QString svgToBase64(const QString &image);
|
||||
};
|
||||
|
||||
@@ -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<void *>(onImeInsetsChanged)},
|
||||
{"onSystemBarsInsetsChanged", "(II)V", reinterpret_cast<void *>(onSystemBarsInsetsChanged)},
|
||||
{"onActivityPaused", "()V", reinterpret_cast<void *>(onActivityPaused)},
|
||||
{"onActivityResumed", "()V", reinterpret_cast<void *>(onActivityResumed)}
|
||||
{"onActivityResumed", "()V", reinterpret_cast<void *>(onActivityResumed)},
|
||||
{"onCameraPermissionResult", "(Z)V", reinterpret_cast<void *>(onCameraPermissionResult)},
|
||||
{"onPairingQrCameraClosed", "()V", reinterpret_cast<void *>(onPairingQrCameraClosed)},
|
||||
{"onPairingQrCameraUserDismissed", "()V", reinterpret_cast<void *>(onPairingQrCameraUserDismissed)}
|
||||
};
|
||||
|
||||
QJniEnvironment env;
|
||||
@@ -201,6 +205,21 @@ bool AndroidController::isCameraPresent()
|
||||
return callActivityMethod<jboolean>("isCameraPresent", "()Z");
|
||||
}
|
||||
|
||||
bool AndroidController::isCameraPermissionGranted()
|
||||
{
|
||||
return callActivityMethod<jboolean>("isCameraPermissionGranted", "()Z");
|
||||
}
|
||||
|
||||
void AndroidController::requestCameraPermissionForQrPairing()
|
||||
{
|
||||
callActivityMethod("requestCameraPermissionForQrPairing", "()V");
|
||||
}
|
||||
|
||||
void AndroidController::openApplicationDetailsSettings()
|
||||
{
|
||||
callActivityMethod("openApplicationDetailsSettings", "()V");
|
||||
}
|
||||
|
||||
bool AndroidController::isOnTv()
|
||||
{
|
||||
return callActivityMethod<jboolean>("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<bool>(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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -38,11 +38,15 @@ public:
|
||||
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 @@ signals:
|
||||
void systemBarsInsetsChanged(int navBarHeightDp, int statusBarHeightDp);
|
||||
void activityPaused();
|
||||
void activityResumed();
|
||||
void cameraPermissionResult(bool granted);
|
||||
|
||||
private:
|
||||
bool isWaitingStatus = true;
|
||||
@@ -109,6 +114,9 @@ private:
|
||||
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 <typename Ret, typename ...Args>
|
||||
static auto callActivityMethod(const char *methodName, const char *signature, Args &&...args);
|
||||
|
||||
@@ -12,3 +12,4 @@ QRect QRCodeReader::cameraSize() {
|
||||
void QRCodeReader::startReading() {}
|
||||
void QRCodeReader::stopReading() {}
|
||||
void QRCodeReader::setCameraSize(QRect) {}
|
||||
void QRCodeReader::notifyCodeRead(const QString &) {}
|
||||
|
||||
@@ -16,6 +16,7 @@ public slots:
|
||||
void startReading();
|
||||
void stopReading();
|
||||
void setCameraSize(QRect value);
|
||||
void notifyCodeRead(const QString &code);
|
||||
|
||||
signals:
|
||||
void codeReaded(QString code);
|
||||
|
||||
@@ -1,16 +1,56 @@
|
||||
#if !MACOS_NE
|
||||
#include "QRCodeReaderBase.h"
|
||||
|
||||
#include <QByteArray>
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
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 () <AVCaptureMetadataOutputObjectsDelegate>
|
||||
@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 @@
|
||||
- (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];
|
||||
|
||||
AVCaptureDevice *captureDevice = [AVCaptureDevice defaultDeviceWithMediaType: AVMediaTypeVideo];
|
||||
AVCaptureDeviceInput *deviceInput = [AVCaptureDeviceInput deviceInputWithDevice: captureDevice error: &error];
|
||||
NSError *error = nil;
|
||||
|
||||
if(!deviceInput) {
|
||||
NSLog(@"Error %@", error.localizedDescription);
|
||||
AVCaptureDevice *captureDevice = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
|
||||
if (!captureDevice) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
_captureSession = [[AVCaptureSession alloc]init];
|
||||
[_captureSession addInput:deviceInput];
|
||||
AVCaptureDeviceInput *deviceInput = [AVCaptureDeviceInput deviceInputWithDevice:captureDevice error:&error];
|
||||
|
||||
if (!deviceInput) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
[_videoPreviewPlayer removeFromSuperlayer];
|
||||
- (void)stopReadingOnMainThread {
|
||||
AVCaptureSession *session = self.captureSession;
|
||||
self.captureSession = nil;
|
||||
|
||||
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 @@
|
||||
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 QRCodeReader::startReading() {
|
||||
void QRCodeReader::stopReading() {
|
||||
[m_qrCodeReader stopReading];
|
||||
}
|
||||
|
||||
void QRCodeReader::notifyCodeRead(const QString &code) {
|
||||
emit codeReaded(code);
|
||||
}
|
||||
#else
|
||||
#include "QRCodeReaderBase.h"
|
||||
|
||||
@@ -124,4 +230,5 @@ QRect QRCodeReader::cameraSize() {
|
||||
void QRCodeReader::startReading() {}
|
||||
void QRCodeReader::stopReading() {}
|
||||
void QRCodeReader::setCameraSize(QRect) {}
|
||||
void QRCodeReader::notifyCodeRead(const QString &) {}
|
||||
#endif
|
||||
|
||||
10
client/platforms/ios/iosPairingCameraAccess.h
Normal file
10
client/platforms/ios/iosPairingCameraAccess.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#ifndef IOS_PAIRING_CAMERA_ACCESS_H
|
||||
#define IOS_PAIRING_CAMERA_ACCESS_H
|
||||
|
||||
#include <functional>
|
||||
|
||||
bool amneziaIosPairingCameraAccessGranted();
|
||||
void amneziaIosRequestPairingCameraAccess(const std::function<void(bool)> &onDone);
|
||||
void amneziaIosOpenApplicationSettings();
|
||||
|
||||
#endif
|
||||
37
client/platforms/ios/iosPairingCameraAccess.mm
Normal file
37
client/platforms/ios/iosPairingCameraAccess.mm
Normal file
@@ -0,0 +1,37 @@
|
||||
#include "platforms/ios/iosPairingCameraAccess.h"
|
||||
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
bool amneziaIosPairingCameraAccessGranted()
|
||||
{
|
||||
const AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
|
||||
return status == AVAuthorizationStatusAuthorized;
|
||||
}
|
||||
|
||||
void amneziaIosRequestPairingCameraAccess(const std::function<void(bool)> &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<bool>(granted));
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
void amneziaIosOpenApplicationSettings()
|
||||
{
|
||||
NSURL *url = [NSURL URLWithString:UIApplicationOpenSettingsURLString];
|
||||
if (url != nil) {
|
||||
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
|
||||
}
|
||||
}
|
||||
13
client/platforms/ios/iosPairingCameraAccess_stub.cpp
Normal file
13
client/platforms/ios/iosPairingCameraAccess_stub.cpp
Normal file
@@ -0,0 +1,13 @@
|
||||
#include "platforms/ios/iosPairingCameraAccess.h"
|
||||
|
||||
bool amneziaIosPairingCameraAccessGranted()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
void amneziaIosRequestPairingCameraAccess(const std::function<void(bool)> &onDone)
|
||||
{
|
||||
onDone(true);
|
||||
}
|
||||
|
||||
void amneziaIosOpenApplicationSettings() {}
|
||||
16
client/platforms/ios/iosPairingQrOverlayWindow.h
Normal file
16
client/platforms/ios/iosPairingQrOverlayWindow.h
Normal file
@@ -0,0 +1,16 @@
|
||||
#ifndef IOS_PAIRING_QR_OVERLAY_WINDOW_H
|
||||
#define IOS_PAIRING_QR_OVERLAY_WINDOW_H
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
using AmneziaPairingQrScannedUtf8Handler = std::function<void(const char *)>;
|
||||
using AmneziaPairingQrOverlayBackHandler = std::function<void()>;
|
||||
|
||||
void amneziaIosPairingQrOverlayPresent(AmneziaPairingQrScannedUtf8Handler onScanned, AmneziaPairingQrOverlayBackHandler onBack,
|
||||
const std::string &titleUtf8, const std::string &subtitleUtf8);
|
||||
void amneziaIosPairingQrOverlayDismiss();
|
||||
void amneziaIosPairingQrOverlaySetTorchEnabled(bool on);
|
||||
void amneziaIosPairingQrOverlayRestartCapture();
|
||||
|
||||
#endif
|
||||
836
client/platforms/ios/iosPairingQrOverlayWindow.mm
Normal file
836
client/platforms/ios/iosPairingQrOverlayWindow.mm
Normal file
@@ -0,0 +1,836 @@
|
||||
#include "platforms/ios/iosPairingQrOverlayWindow.h"
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <QuartzCore/QuartzCore.h>
|
||||
#import <math.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
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 () <AVCaptureMetadataOutputObjectsDelegate>
|
||||
@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<CAShapeLayer *> *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<bool>(onScanned);
|
||||
const bool hasBack = static_cast<bool>(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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
165
client/tests/testPairingParsers.cpp
Normal file
165
client/tests/testPairingParsers.cpp
Normal file
@@ -0,0 +1,165 @@
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSignalSpy>
|
||||
#include <QTest>
|
||||
|
||||
#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"
|
||||
@@ -1822,6 +1822,16 @@ Thank you for staying with us!</source>
|
||||
<source>Cancel</source>
|
||||
<translation>Отменить</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSettingsApiDevices.qml" line="252"/>
|
||||
<source>Configuration Files: %1</source>
|
||||
<translation>Файлы конфигурации: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSettingsApiDevices.qml" line="253"/>
|
||||
<source>Generated configuration files also count towards the device limit</source>
|
||||
<translation>Сгенерированные файлы конфигурации тоже учитываются в лимите устройств</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>PageSettingsApiInstructions</name>
|
||||
|
||||
739
client/ui/controllers/api/pairingUiController.cpp
Normal file
739
client/ui/controllers/api/pairingUiController.cpp
Normal file
@@ -0,0 +1,739 @@
|
||||
#include "pairingUiController.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QIODevice>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QMetaObject>
|
||||
#include <QPointer>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
#include <QUuid>
|
||||
#include <string>
|
||||
|
||||
#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<QString> userCountryCodes;
|
||||
QSet<QString> 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<PairingUiController> 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<PairingUiController> 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<GatewayController>::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<QPair<ErrorCode, QByteArray>> future =
|
||||
gatewayController->postAsync(QString::fromLatin1(kGenerateQrPath), payload, &replyRaw, gatewayController);
|
||||
m_tvNetworkReply = replyRaw;
|
||||
|
||||
auto *watcher = new QFutureWatcher<QPair<ErrorCode, QByteArray>>(this);
|
||||
m_tvWatcher = watcher;
|
||||
QObject::connect(watcher, &QFutureWatcher<QPair<ErrorCode, QByteArray>>::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<GatewayController>::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<QPair<ErrorCode, QByteArray>> future =
|
||||
gatewayController->postAsync(QString::fromLatin1(kScanQrPath), payload, &replyRaw, gatewayController);
|
||||
m_phoneNetworkReply = replyRaw;
|
||||
|
||||
auto *watcher = new QFutureWatcher<QPair<ErrorCode, QByteArray>>(this);
|
||||
m_phoneWatcher = watcher;
|
||||
QObject::connect(watcher, &QFutureWatcher<QPair<ErrorCode, QByteArray>>::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);
|
||||
}
|
||||
131
client/ui/controllers/api/pairingUiController.h
Normal file
131
client/ui/controllers/api/pairingUiController.h
Normal file
@@ -0,0 +1,131 @@
|
||||
#ifndef PAIRINGUICONTROLLER_H
|
||||
#define PAIRINGUICONTROLLER_H
|
||||
|
||||
#include <QFutureWatcher>
|
||||
#include <QNetworkReply>
|
||||
#include <QObject>
|
||||
#include <QVariantList>
|
||||
#include <QPointer>
|
||||
#include <QStringList>
|
||||
|
||||
#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<QString> m_tvQrCodes;
|
||||
QString m_tvSessionUuid;
|
||||
bool m_tvPairingBusy = false;
|
||||
QPointer<QFutureWatcher<QPair<amnezia::ErrorCode, QByteArray>>> m_tvWatcher;
|
||||
QPointer<QNetworkReply> m_tvNetworkReply;
|
||||
quint64 m_tvSessionGeneration { 0 };
|
||||
|
||||
bool m_phonePairingBusy = false;
|
||||
QString m_pendingPhonePairingUuid;
|
||||
QString m_lastSuccessfulPhonePairingDisplayName;
|
||||
QPointer<QFutureWatcher<QPair<amnezia::ErrorCode, QByteArray>>> m_phoneWatcher;
|
||||
QPointer<QNetworkReply> m_phoneNetworkReply;
|
||||
quint64 m_phoneSessionGeneration { 0 };
|
||||
|
||||
qint64 m_androidPairingReaderCooldownUntilEpochMs = 0;
|
||||
};
|
||||
|
||||
#endif // PAIRINGUICONTROLLER_H
|
||||
@@ -16,6 +16,10 @@
|
||||
#include <QFutureWatcher>
|
||||
#include <QTimer>
|
||||
|
||||
#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);
|
||||
|
||||
@@ -82,6 +82,9 @@ namespace PageLoader
|
||||
PageSetupWizardApiPremiumInfo,
|
||||
PageSetupWizardApiTrialEmail,
|
||||
|
||||
PageSettingsApiQrPairingSend,
|
||||
PageSetupWizardApiQrPairingReceive,
|
||||
|
||||
PageDevMenu,
|
||||
|
||||
PageProtocolXraySnapshots,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "apiAccountInfoModel.h"
|
||||
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QJsonObject>
|
||||
|
||||
@@ -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<int, QByteArray> ApiAccountInfoModel::roleNames() const
|
||||
roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired";
|
||||
roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon";
|
||||
roles[IsInAppPurchaseRole] = "isInAppPurchase";
|
||||
roles[ConfigurationFilesCountRole] = "configurationFilesCount";
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ public:
|
||||
IsProtocolSelectionSupportedRole,
|
||||
IsSubscriptionExpiredRole,
|
||||
IsSubscriptionExpiringSoonRole,
|
||||
IsInAppPurchaseRole
|
||||
IsInAppPurchaseRole,
|
||||
ConfigurationFilesCountRole
|
||||
};
|
||||
|
||||
explicit ApiAccountInfoModel(QObject *parent = nullptr);
|
||||
@@ -64,6 +65,7 @@ private:
|
||||
|
||||
bool isInAppPurchase = false;
|
||||
bool isRenewalAvailable = false;
|
||||
int configurationFilesCount = 0;
|
||||
};
|
||||
|
||||
AccountInfoData m_accountInfoData;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
339
client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml
Normal file
339
client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
192
client/ui/qml/Pages2/PageSetupWizardApiQrPairingReceive.qml
Normal file
192
client/ui/qml/Pages2/PageSetupWizardApiQrPairingReceive.qml
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -97,6 +97,8 @@
|
||||
<file>Pages2/PageSettingsAbout.qml</file>
|
||||
<file>Pages2/PageSettingsApiAvailableCountries.qml</file>
|
||||
<file>Pages2/PageSettingsApiServerInfo.qml</file>
|
||||
<file>Pages2/PageSettingsApiQrPairingSend.qml</file>
|
||||
<file>Pages2/PageSetupWizardApiQrPairingReceive.qml</file>
|
||||
<file>Pages2/PageSettingsApplication.qml</file>
|
||||
<file>Pages2/PageSettingsAppSplitTunneling.qml</file>
|
||||
<file>Pages2/PageSettingsBackup.qml</file>
|
||||
|
||||
Reference in New Issue
Block a user