mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-22 02:01:08 +07:00
fixed scaner QR Android
This commit is contained in:
@@ -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>
|
||||||
@@ -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_width="match_parent"
|
||||||
android:layout_height="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>
|
</FrameLayout>
|
||||||
@@ -24,5 +24,10 @@
|
|||||||
<string name="notificationSettingsDialogMessage">Для показа уведомлений необходимо включить уведомления в системных настройках</string>
|
<string name="notificationSettingsDialogMessage">Для показа уведомлений необходимо включить уведомления в системных настройках</string>
|
||||||
<string name="openNotificationSettings">Открыть настройки уведомлений</string>
|
<string name="openNotificationSettings">Открыть настройки уведомлений</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>
|
<string name="tvNoFileBrowser">Пожалуйста, установите приложение для просмотра файлов</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -27,6 +27,10 @@
|
|||||||
<string name="cameraPermissionDialogTitle">Camera access</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="cameraPermissionDialogMessage">To scan a QR code for device pairing, Amnezia VPN needs access to the camera.</string>
|
||||||
<string name="cameraPermissionContinue">Continue</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>
|
<string name="tvNoFileBrowser">Please install a file management utility to browse files</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -42,6 +42,9 @@ import androidx.core.view.OnApplyWindowInsetsListener
|
|||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.LifecycleRegistry
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.LazyThreadSafetyMode.NONE
|
import kotlin.LazyThreadSafetyMode.NONE
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
@@ -79,7 +82,12 @@ private const val PREFS_NOTIFICATION_PERMISSION_ASKED = "NOTIFICATION_PERMISSION
|
|||||||
private const val OPEN_FILE_AFTER_RESUME_DELAY_MS = 400L
|
private const val OPEN_FILE_AFTER_RESUME_DELAY_MS = 400L
|
||||||
private const val KEY_PENDING_OPEN_FILE_URI = "pending_open_file_uri"
|
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 lateinit var mainScope: CoroutineScope
|
||||||
private val qtInitialized = CompletableDeferred<Unit>()
|
private val qtInitialized = CompletableDeferred<Unit>()
|
||||||
@@ -101,6 +109,7 @@ class AmneziaActivity : QtActivity() {
|
|||||||
private var openFileDeliveryScheduled = false
|
private var openFileDeliveryScheduled = false
|
||||||
|
|
||||||
private var pairingQrEmbeddedCamera: PairingQrEmbeddedCamera? = null
|
private var pairingQrEmbeddedCamera: PairingQrEmbeddedCamera? = null
|
||||||
|
private var lastPairingQrReaderStartUptimeMs: Long = 0L
|
||||||
|
|
||||||
private val vpnServiceEventHandler: Handler by lazy(NONE) {
|
private val vpnServiceEventHandler: Handler by lazy(NONE) {
|
||||||
object : Handler(Looper.getMainLooper()) {
|
object : Handler(Looper.getMainLooper()) {
|
||||||
@@ -208,6 +217,7 @@ class AmneziaActivity : QtActivity() {
|
|||||||
registerBroadcastReceivers()
|
registerBroadcastReceivers()
|
||||||
intent?.let(::processIntent)
|
intent?.let(::processIntent)
|
||||||
runBlocking { vpnProto = proto.await() }
|
runBlocking { vpnProto = proto.await() }
|
||||||
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
@@ -265,6 +275,7 @@ class AmneziaActivity : QtActivity() {
|
|||||||
|
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
|
||||||
Log.d(TAG, "Start Amnezia activity")
|
Log.d(TAG, "Start Amnezia activity")
|
||||||
mainScope.launch {
|
mainScope.launch {
|
||||||
qtInitialized.await()
|
qtInitialized.await()
|
||||||
@@ -288,6 +299,7 @@ class AmneziaActivity : QtActivity() {
|
|||||||
qtInitialized.await()
|
qtInitialized.await()
|
||||||
QtAndroidController.onServiceDisconnected()
|
QtAndroidController.onServiceDisconnected()
|
||||||
}
|
}
|
||||||
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +372,7 @@ class AmneziaActivity : QtActivity() {
|
|||||||
if (qtInitialized.isCompleted) {
|
if (qtInitialized.isCompleted) {
|
||||||
QtAndroidController.onActivityPaused()
|
QtAndroidController.onActivityPaused()
|
||||||
}
|
}
|
||||||
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
|
||||||
super.onPause()
|
super.onPause()
|
||||||
isActivityResumed = false
|
isActivityResumed = false
|
||||||
// Cancel all pending operations when activity pauses
|
// Cancel all pending operations when activity pauses
|
||||||
@@ -370,6 +383,7 @@ class AmneziaActivity : QtActivity() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||||
isActivityResumed = true
|
isActivityResumed = true
|
||||||
Log.d(TAG, "Resume Amnezia activity")
|
Log.d(TAG, "Resume Amnezia activity")
|
||||||
if (qtInitialized.isCompleted) {
|
if (qtInitialized.isCompleted) {
|
||||||
@@ -486,6 +500,7 @@ class AmneziaActivity : QtActivity() {
|
|||||||
unregisterBroadcastReceiver(notificationStateReceiver)
|
unregisterBroadcastReceiver(notificationStateReceiver)
|
||||||
notificationStateReceiver = null
|
notificationStateReceiver = null
|
||||||
mainScope.cancel()
|
mainScope.cancel()
|
||||||
|
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1011,6 +1026,21 @@ class AmneziaActivity : QtActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
fun startPairingQrCodeReader() {
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
if (now - lastPairingQrReaderStartUptimeMs < 1200L) {
|
||||||
|
Log.w(TAG, "startPairingQrCodeReader: suppressed duplicate (${now - lastPairingQrReaderStartUptimeMs}ms)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastPairingQrReaderStartUptimeMs = now
|
||||||
|
Log.v(TAG, "Start pairing QR camera")
|
||||||
|
Intent(this, CameraActivity::class.java).also {
|
||||||
|
it.putExtra(CameraActivity.EXTRA_PAIRING_QR_CAMERA, true)
|
||||||
|
startActivity(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
fun setSaveLogs(enabled: Boolean) {
|
fun setSaveLogs(enabled: Boolean) {
|
||||||
Log.v(TAG, "Set save logs: $enabled")
|
Log.v(TAG, "Set save logs: $enabled")
|
||||||
|
|||||||
@@ -2,47 +2,401 @@ package org.amnezia.vpn
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.MotionEvent.ACTION_DOWN
|
import android.view.MotionEvent.ACTION_DOWN
|
||||||
import android.view.MotionEvent.ACTION_UP
|
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 android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
|
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
|
||||||
|
import androidx.camera.core.Camera
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
import androidx.camera.core.ExperimentalGetImage
|
import androidx.camera.core.ExperimentalGetImage
|
||||||
import androidx.camera.core.FocusMeteringAction
|
import androidx.camera.core.FocusMeteringAction
|
||||||
import androidx.camera.core.FocusMeteringAction.FLAG_AE
|
import androidx.camera.core.FocusMeteringAction.FLAG_AE
|
||||||
import androidx.camera.core.FocusMeteringAction.FLAG_AF
|
import androidx.camera.core.FocusMeteringAction.FLAG_AF
|
||||||
import androidx.camera.core.ImageAnalysis
|
import androidx.camera.core.ImageAnalysis
|
||||||
|
import androidx.camera.core.ImageProxy
|
||||||
import androidx.camera.core.Preview
|
import androidx.camera.core.Preview
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
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.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.BarcodeScannerOptions.Builder
|
||||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
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.barcode.common.Barcode
|
||||||
import com.google.mlkit.vision.common.InputImage
|
import com.google.mlkit.vision.common.InputImage
|
||||||
import org.amnezia.vpn.databinding.CameraPreviewBinding
|
import org.amnezia.vpn.databinding.CameraPreviewBinding
|
||||||
import org.amnezia.vpn.qt.QtAndroidController
|
import org.amnezia.vpn.qt.QtAndroidController
|
||||||
import org.amnezia.vpn.util.Log
|
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"
|
private const val TAG = "CameraActivity"
|
||||||
|
|
||||||
|
@OptIn(TransformExperimental::class)
|
||||||
class CameraActivity : ComponentActivity() {
|
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 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
|
||||||
|
|
||||||
|
/** CameraX analyzer thread only; ML Kit Task callbacks use [ContextCompat.getMainExecutor] so they are not rejected after [ExecutorService.shutdown]. */
|
||||||
|
private var imageAnalysisExecutor: ExecutorService? = null
|
||||||
|
|
||||||
|
/** After a successful decode, ignore further frames (ML Kit otherwise hits "detector is already closed"). */
|
||||||
|
private val qrHandledOrClosing = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pairing mode: QR was accepted by Qt ([decodeQrCode] true) and we are finishing normally.
|
||||||
|
* Do not apply JNI reopen cooldown on destroy — user may cancel confirm and return to scan immediately.
|
||||||
|
*/
|
||||||
|
private var pairingQrDeliveredToQt = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pairing mode: user closed camera via back / toolbar (not a successful scan).
|
||||||
|
* Skip JNI reopen cooldown — otherwise Qt stays on the black overlay shell until cooldown ends and a second back pops the page.
|
||||||
|
*/
|
||||||
|
private var pairingQrUserDismissedCamera = false
|
||||||
|
|
||||||
|
private var barcodeScanner: BarcodeScanner? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [PreviewView.getOutputTransform] is main-thread-only; the image analyzer runs on a background
|
||||||
|
* executor. Refresh this cache whenever layout / preview geometry may change.
|
||||||
|
*/
|
||||||
|
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
|
@ExperimentalGetImage
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
viewBinding = CameraPreviewBinding.inflate(layoutInflater)
|
viewBinding = CameraPreviewBinding.inflate(layoutInflater)
|
||||||
setContentView(viewBinding.root)
|
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 { _, _, _, _, _, _, _, _, _ ->
|
||||||
|
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)
|
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
|
||||||
|
}
|
||||||
|
Log.v(TAG, "onNewIntent: rebind pairing camera")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Must run on the main thread (uses [PreviewView.getOutputTransform]). */
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules [refreshCachedPreviewOutputTransform] on the main thread (safe from any thread).
|
||||||
|
*/
|
||||||
|
private fun scheduleCachedPreviewOutputTransformRefresh() {
|
||||||
|
if (!::viewBinding.isInitialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewBinding.viewFinder.post { refreshCachedPreviewOutputTransform() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS `layoutScanOverlayGeometry`: hole + torch vertical position; keeps ML Kit ROI aligned with overlay.
|
||||||
|
*/
|
||||||
|
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 lp = viewBinding.torchButton.layoutParams as FrameLayout.LayoutParams
|
||||||
|
lp.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
|
||||||
|
lp.topMargin = (torchCy - torchSizePx / 2f).roundToInt().coerceAtLeast(0)
|
||||||
|
lp.bottomMargin = 0
|
||||||
|
viewBinding.torchButton.layoutParams = lp
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches iOS pairing overlay torch on/off chrome (background + golden border). */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the pairing scan square from [PreviewView] pixel space into the same coordinate system
|
||||||
|
* as ML Kit barcodes for this [imageProxy] (rotation-aware). Falls back to FILL_CENTER math
|
||||||
|
* if the transform is not ready — never returns view-space rects as image-space.
|
||||||
|
*/
|
||||||
|
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) {
|
private fun checkPermissions(onSuccess: () -> Unit, onFail: () -> Unit) {
|
||||||
if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||||
onSuccess()
|
onSuccess()
|
||||||
@@ -67,26 +421,41 @@ class CameraActivity : ComponentActivity() {
|
|||||||
|
|
||||||
cameraProviderFuture.addListener({
|
cameraProviderFuture.addListener({
|
||||||
cameraProvider = cameraProviderFuture.get()
|
cameraProvider = cameraProviderFuture.get()
|
||||||
bindPreview()
|
bindCameraUseCases()
|
||||||
bindImageAnalysis()
|
|
||||||
}, ContextCompat.getMainExecutor(this))
|
}, ContextCompat.getMainExecutor(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
private fun bindPreview() {
|
@ExperimentalGetImage
|
||||||
|
private fun bindCameraUseCases() {
|
||||||
|
val provider = cameraProvider ?: return
|
||||||
|
imageAnalysisExecutor?.shutdown()
|
||||||
|
imageAnalysisExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
val viewFinder = viewBinding.viewFinder
|
val viewFinder = viewBinding.viewFinder
|
||||||
val preview = Preview.Builder().build().also {
|
val preview = Preview.Builder().build().also {
|
||||||
it.setSurfaceProvider(viewFinder.surfaceProvider)
|
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 ->
|
viewFinder.setOnTouchListener { _, motionEvent ->
|
||||||
when (motionEvent.action) {
|
when (motionEvent.action) {
|
||||||
ACTION_DOWN -> true
|
ACTION_DOWN -> true
|
||||||
ACTION_UP -> {
|
ACTION_UP -> {
|
||||||
val point = viewFinder
|
val point = viewFinder
|
||||||
.meteringPointFactory.createPoint(motionEvent.x, motionEvent.x)
|
.meteringPointFactory.createPoint(motionEvent.x, motionEvent.y)
|
||||||
|
|
||||||
val action = FocusMeteringAction
|
val action = FocusMeteringAction
|
||||||
.Builder(point, FLAG_AF or FLAG_AE).build()
|
.Builder(point, FLAG_AF or FLAG_AE).build()
|
||||||
@@ -98,58 +467,117 @@ class CameraActivity : ComponentActivity() {
|
|||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@ExperimentalGetImage
|
if (intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) {
|
||||||
private fun bindImageAnalysis() {
|
previewTransformLayoutListener?.let { viewFinder.removeOnLayoutChangeListener(it) }
|
||||||
val imageAnalysis = ImageAnalysis.Builder().build()
|
val layoutListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||||
|
scheduleCachedPreviewOutputTransformRefresh()
|
||||||
|
onPairingLayoutGeometryChanged()
|
||||||
|
}
|
||||||
|
previewTransformLayoutListener = layoutListener
|
||||||
|
viewFinder.addOnLayoutChangeListener(layoutListener)
|
||||||
|
previewStreamStateObserver?.let { viewFinder.previewStreamState.removeObserver(it) }
|
||||||
|
val streamObserver = Observer<PreviewView.StreamState> { state ->
|
||||||
|
if (state == PreviewView.StreamState.STREAMING) {
|
||||||
|
scheduleCachedPreviewOutputTransformRefresh()
|
||||||
|
viewFinder.post { onPairingLayoutGeometryChanged() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewStreamStateObserver = streamObserver
|
||||||
|
viewFinder.previewStreamState.observe(this, streamObserver)
|
||||||
|
scheduleCachedPreviewOutputTransformRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
val camera = cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, imageAnalysis)
|
try {
|
||||||
|
barcodeScanner?.close()
|
||||||
val barcodeScanner = BarcodeScanning.getClient(
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
// No ZoomSuggestionOptions: ML Kit "scanner-auto-zoom" is off-spec for pairing and skews crop vs our ROI.
|
||||||
|
barcodeScanner = BarcodeScanning.getClient(
|
||||||
Builder()
|
Builder()
|
||||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||||
.setZoomSuggestionOptions(
|
.build()
|
||||||
ZoomSuggestionOptions.Builder { zoomLevel ->
|
|
||||||
camera.cameraControl.setZoomRatio(zoomLevel)
|
|
||||||
true
|
|
||||||
}.apply {
|
|
||||||
camera.cameraInfo.zoomState.value?.maxZoomRatio?.let { maxZoomRation ->
|
|
||||||
setMaxSupportedZoomRatio(maxZoomRation)
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
).build()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// optimization
|
|
||||||
val checkedBarcodes = hashSetOf<String>()
|
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 ->
|
imageAnalysis.setAnalyzer(analysisExecutor) { imageProxy ->
|
||||||
imageProxy.image?.let { InputImage.fromMediaImage(it, imageProxy.imageInfo.rotationDegrees) }
|
if (qrHandledOrClosing.get()) {
|
||||||
?.let { image ->
|
imageProxy.close()
|
||||||
barcodeScanner.process(image).addOnSuccessListener { barcodes ->
|
return@setAnalyzer
|
||||||
barcodes.firstOrNull()?.let { barcode ->
|
}
|
||||||
barcode.displayValue?.let { code ->
|
val mediaImage = imageProxy.image
|
||||||
if (code.isNotEmpty() && code !in checkedBarcodes) {
|
if (mediaImage == null) {
|
||||||
if (QtAndroidController.decodeQrCode(code)) {
|
imageProxy.close()
|
||||||
barcodeScanner.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()
|
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() {
|
private fun stopCamera() {
|
||||||
cameraProvider.unbindAll()
|
cleanupCameraResources()
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package org.amnezia.vpn
|
package org.amnezia.vpn
|
||||||
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.graphics.PixelFormat
|
||||||
import android.graphics.drawable.ColorDrawable
|
import android.graphics.drawable.ColorDrawable
|
||||||
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.camera.core.Camera
|
import androidx.camera.core.Camera
|
||||||
@@ -19,6 +21,8 @@ import com.google.mlkit.vision.barcode.common.Barcode
|
|||||||
import com.google.mlkit.vision.common.InputImage
|
import com.google.mlkit.vision.common.InputImage
|
||||||
import org.amnezia.vpn.qt.QtAndroidController
|
import org.amnezia.vpn.qt.QtAndroidController
|
||||||
import org.amnezia.vpn.util.Log
|
import org.amnezia.vpn.util.Log
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
private const val TAG = "PairingQrEmbedded"
|
private const val TAG = "PairingQrEmbedded"
|
||||||
|
|
||||||
@@ -33,6 +37,10 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
|||||||
|
|
||||||
private val windowBgOpaque = ColorDrawable(Color.parseColor("#0E0E11"))
|
private val windowBgOpaque = ColorDrawable(Color.parseColor("#0E0E11"))
|
||||||
|
|
||||||
|
private var savedWindowFormat: Int? = null
|
||||||
|
|
||||||
|
private var imageAnalysisExecutor: ExecutorService? = null
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
activity.runOnUiThread {
|
activity.runOnUiThread {
|
||||||
if (previewView != null) {
|
if (previewView != null) {
|
||||||
@@ -40,15 +48,26 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
|||||||
}
|
}
|
||||||
checkedBarcodes.clear()
|
checkedBarcodes.clear()
|
||||||
activity.window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
|
activity.window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
|
||||||
|
val lp = activity.window.attributes
|
||||||
|
if (savedWindowFormat == null) {
|
||||||
|
savedWindowFormat = lp.format
|
||||||
|
}
|
||||||
|
lp.format = PixelFormat.TRANSLUCENT
|
||||||
|
activity.window.attributes = lp
|
||||||
|
|
||||||
val content = activity.findViewById<ViewGroup>(android.R.id.content)
|
val content = activity.findViewById<ViewGroup>(android.R.id.content)
|
||||||
|
content.clipChildren = false
|
||||||
|
content.clipToPadding = false
|
||||||
val pv = PreviewView(activity).apply {
|
val pv = PreviewView(activity).apply {
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
|
// TextureView-style path so preview can show through “holes” in Qt Quick on some OEMs (e.g. Samsung).
|
||||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||||
|
setBackgroundColor(Color.TRANSPARENT)
|
||||||
|
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||||
}
|
}
|
||||||
content.addView(pv, 0)
|
content.addView(pv, 0)
|
||||||
for (i in 1 until content.childCount) {
|
for (i in 1 until content.childCount) {
|
||||||
@@ -61,6 +80,10 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
|||||||
try {
|
try {
|
||||||
cameraProvider = cameraProviderFuture.get()
|
cameraProvider = cameraProviderFuture.get()
|
||||||
bindUseCases(pv)
|
bindUseCases(pv)
|
||||||
|
pv.post {
|
||||||
|
pv.requestLayout()
|
||||||
|
pv.invalidate()
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Camera bind failed: $e")
|
Log.e(TAG, "Camera bind failed: $e")
|
||||||
stop()
|
stop()
|
||||||
@@ -72,6 +95,8 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
|||||||
private fun bindUseCases(viewFinder: PreviewView) {
|
private fun bindUseCases(viewFinder: PreviewView) {
|
||||||
val provider = cameraProvider ?: return
|
val provider = cameraProvider ?: return
|
||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
|
imageAnalysisExecutor?.shutdown()
|
||||||
|
imageAnalysisExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
val preview = Preview.Builder().build().also {
|
val preview = Preview.Builder().build().also {
|
||||||
it.setSurfaceProvider(viewFinder.surfaceProvider)
|
it.setSurfaceProvider(viewFinder.surfaceProvider)
|
||||||
@@ -94,7 +119,13 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
|||||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||||
.setZoomSuggestionOptions(
|
.setZoomSuggestionOptions(
|
||||||
ZoomSuggestionOptions.Builder { zoomLevel ->
|
ZoomSuggestionOptions.Builder { zoomLevel ->
|
||||||
cam.cameraControl.setZoomRatio(zoomLevel)
|
activity.runOnUiThread {
|
||||||
|
try {
|
||||||
|
cam.cameraControl.setZoomRatio(zoomLevel)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Zoom: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}.apply {
|
}.apply {
|
||||||
cam.cameraInfo.zoomState.value?.maxZoomRatio?.let { maxZoom ->
|
cam.cameraInfo.zoomState.value?.maxZoomRatio?.let { maxZoom ->
|
||||||
@@ -105,28 +136,32 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val scanner = barcodeScanner!!
|
val scanner = barcodeScanner!!
|
||||||
imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(activity)) { imageProxy ->
|
val analysisExecutor = imageAnalysisExecutor!!
|
||||||
|
imageAnalysis.setAnalyzer(analysisExecutor) { imageProxy ->
|
||||||
val mediaImage = imageProxy.image ?: run {
|
val mediaImage = imageProxy.image ?: run {
|
||||||
imageProxy.close()
|
imageProxy.close()
|
||||||
return@setAnalyzer
|
return@setAnalyzer
|
||||||
}
|
}
|
||||||
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||||
scanner.process(image).addOnSuccessListener { barcodes ->
|
scanner.process(image)
|
||||||
barcodes.firstOrNull()?.displayValue?.let { code ->
|
.addOnSuccessListener(analysisExecutor) { barcodes ->
|
||||||
if (code.isNotEmpty() && code !in checkedBarcodes) {
|
barcodes.firstOrNull()?.displayValue?.let { code ->
|
||||||
checkedBarcodes.add(code)
|
if (code.isNotEmpty() && code !in checkedBarcodes) {
|
||||||
if (QtAndroidController.decodeQrCode(code)) {
|
checkedBarcodes.add(code)
|
||||||
scanner.close()
|
if (QtAndroidController.decodeQrCode(code)) {
|
||||||
barcodeScanner = null
|
scanner.close()
|
||||||
stop()
|
barcodeScanner = null
|
||||||
|
activity.runOnUiThread { stop() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.addOnFailureListener {
|
.addOnFailureListener(analysisExecutor) {
|
||||||
Log.e(TAG, "QR process failed: ${it.message}")
|
Log.e(TAG, "QR process failed: ${it.message}")
|
||||||
}.addOnCompleteListener {
|
}
|
||||||
imageProxy.close()
|
.addOnCompleteListener(analysisExecutor) {
|
||||||
}
|
imageProxy.close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +177,8 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
|||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
activity.runOnUiThread {
|
activity.runOnUiThread {
|
||||||
|
imageAnalysisExecutor?.shutdown()
|
||||||
|
imageAnalysisExecutor = null
|
||||||
try {
|
try {
|
||||||
boundCamera?.cameraControl?.enableTorch(false)
|
boundCamera?.cameraControl?.enableTorch(false)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -158,6 +195,12 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
|||||||
previewView = null
|
previewView = null
|
||||||
|
|
||||||
activity.window.setBackgroundDrawable(windowBgOpaque)
|
activity.window.setBackgroundDrawable(windowBgOpaque)
|
||||||
|
savedWindowFormat?.let { fmt ->
|
||||||
|
val lp = activity.window.attributes
|
||||||
|
lp.format = fmt
|
||||||
|
activity.window.attributes = lp
|
||||||
|
savedWindowFormat = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stroked L-brackets for the pairing scan frame — port of iOS `amneziaScanBracketStrokePath`
|
||||||
|
* (`iosPairingQrOverlayWindow.mm`).
|
||||||
|
* corner: 0=TL, 1=TR, 2=BL, 3=BR.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same proportions as [PageSettingsApiQrPairingSend.qml] (iOS embedded scan): sq = 0.72 * min(w,h),
|
||||||
|
* vertical bias -0.06 * height (square shifted up slightly).
|
||||||
|
*/
|
||||||
|
object PairingQrScanGeometry {
|
||||||
|
const val SQ_FRACTION = 0.72f
|
||||||
|
const val VERTICAL_BIAS = 0.06f
|
||||||
|
|
||||||
|
fun holeRectF(viewW: Int, viewH: Int): RectF {
|
||||||
|
val w = viewW.toFloat()
|
||||||
|
val h = viewH.toFloat()
|
||||||
|
val side = min(w, h) * SQ_FRACTION
|
||||||
|
val left = (w - side) / 2f
|
||||||
|
var top = (h - side) / 2f - h * VERTICAL_BIAS
|
||||||
|
top = max(0f, top)
|
||||||
|
var bottom = top + side
|
||||||
|
if (bottom > h) {
|
||||||
|
bottom = h
|
||||||
|
}
|
||||||
|
val adjSide = bottom - top
|
||||||
|
return RectF(left, top, left + adjSide, bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ML Kit [Barcode] box is in [InputImage] pixel space (same as analysis frame WxH). */
|
||||||
|
fun barcodeCenterInPairingHole(imageW: Int, imageH: Int, barcode: Barcode): Boolean {
|
||||||
|
val box = barcode.boundingBox ?: return true
|
||||||
|
val r = holeRectF(imageW, imageH)
|
||||||
|
return r.contains(box.centerX().toFloat(), box.centerY().toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a rectangle in [PreviewView] / overlay pixel space to [InputImage] pixel space,
|
||||||
|
* assuming the preview uses default FILL_CENTER scaling (same as typical CameraX [PreviewView]).
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pairing hole (same geometry as overlay) expressed in ML Kit / [InputImage] coordinates. */
|
||||||
|
fun pairingHoleInImageCoords(viewW: Int, viewH: Int, imageW: Int, imageH: Int): RectF {
|
||||||
|
val holeView = holeRectF(viewW, viewH)
|
||||||
|
return viewRectToInputImageRectFillCenter(viewW, viewH, imageW, imageH, holeView)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounded scan window corner radius — same formula as iOS `layoutScanOverlayGeometry` (`holeR`).
|
||||||
|
* [sidePx] is the scan square side in pixels.
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Area(roi ∩ box) / area(box); 0 if disjoint. */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept only codes whose bounding box overlaps the on-screen pairing square by at least
|
||||||
|
* [minOverlapFraction] when that square is mapped into image space (preview FILL_CENTER model).
|
||||||
|
*/
|
||||||
|
fun barcodeMostlyInsidePairingHole(
|
||||||
|
viewW: Int,
|
||||||
|
viewH: Int,
|
||||||
|
imageW: Int,
|
||||||
|
imageH: Int,
|
||||||
|
barcode: Barcode,
|
||||||
|
minOverlapFraction: Float = 0.82f
|
||||||
|
): Boolean {
|
||||||
|
val box = barcode.boundingBox ?: return true
|
||||||
|
if (viewW <= 0 || viewH <= 0 || imageW <= 0 || imageH <= 0) {
|
||||||
|
return barcodeCenterInPairingHole(imageW, imageH, barcode)
|
||||||
|
}
|
||||||
|
val roi = pairingHoleInImageCoords(viewW, viewH, imageW, imageH)
|
||||||
|
val inset = 0.02f * min(imageW, imageH)
|
||||||
|
roi.inset(inset, inset)
|
||||||
|
if (roi.width() <= 0f || roi.height() <= 0f) {
|
||||||
|
return barcodeCenterInPairingHole(imageW, imageH, barcode)
|
||||||
|
}
|
||||||
|
return barcodeBoxOverlapFraction(roi, box) >= minOverlapFraction
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pairing send: accept only if the QR lies fully inside the on-screen square.
|
||||||
|
* [roiInImageSpace] is [holeRectF] in [PreviewView] coords mapped into the same space as ML Kit
|
||||||
|
* geometry ([CoordinateTransform] in [CameraActivity]).
|
||||||
|
*
|
||||||
|
* When [Barcode.getCornerPoints] is present (typical for QR), all corners must lie inside the ROI —
|
||||||
|
* tighter than [BoundingBox], which is often padded.
|
||||||
|
* Otherwise falls back to bbox center inside ROI plus [minOverlapFraction] of bbox area inside ROI.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bbox-only fallback when corner points are missing (unusual for QR). */
|
||||||
|
private const val PAIRING_SEND_MIN_OVERLAP_BBOX_FALLBACK = 0.72f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native pairing scan hole — same rules as iOS `layoutScanOverlayGeometry` in
|
||||||
|
* `iosPairingQrOverlayWindow.mm` (0.72 × min side, header / bottom band clamps).
|
||||||
|
* [headerBottomPx] is the bottom edge of the chrome row in this view’s coordinate system.
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical center of the torch control in px (same math as iOS `torchCenterYConstraint` update).
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [pairingIosStyleHoleRectF] mapped with the legacy FILL_CENTER preview model (transform fallback). */
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS-style pairing scan chrome: dim outside a central square + white corner brackets.
|
||||||
|
* Hole layout matches iOS `layoutScanOverlayGeometry` ([pairingIosStyleHoleRectF]).
|
||||||
|
*/
|
||||||
|
class PairingQrScanOverlayView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : View(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
isClickable = false
|
||||||
|
isFocusable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Let taps reach [PreviewView] below for tap-to-focus. */
|
||||||
|
@Suppress("ClickableViewAccessibility")
|
||||||
|
override fun onTouchEvent(event: MotionEvent): Boolean = false
|
||||||
|
|
||||||
|
private val dimPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = 0x8C000000.toInt() // ~55% black, same role as QML dimAlpha 0.55
|
||||||
|
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 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 holeLeft = hole.left
|
||||||
|
val holeTop = hole.top
|
||||||
|
val holeRight = hole.right
|
||||||
|
val holeBottom = hole.bottom
|
||||||
|
|
||||||
|
// Four dim rects (same idea as QML dimLayer)
|
||||||
|
canvas.drawRect(0f, 0f, w, holeTop, dimPaint)
|
||||||
|
canvas.drawRect(0f, holeBottom, w, h, dimPaint)
|
||||||
|
canvas.drawRect(0f, holeTop, holeLeft, holeBottom, dimPaint)
|
||||||
|
canvas.drawRect(holeRight, holeTop, w, holeBottom, dimPaint)
|
||||||
|
|
||||||
|
for (i in 0..3) {
|
||||||
|
bracketPaths[i]?.let { canvas.drawPath(it, bracketPaint) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,4 +36,8 @@ object QtAndroidController {
|
|||||||
external fun onActivityResumed()
|
external fun onActivityResumed()
|
||||||
|
|
||||||
external fun onCameraPermissionResult(granted: Boolean)
|
external fun onCameraPermissionResult(granted: Boolean)
|
||||||
|
|
||||||
|
external fun onPairingQrCameraClosed()
|
||||||
|
|
||||||
|
external fun onPairingQrCameraUserDismissed()
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,45 @@ set_target_properties(${PROJECT} PROPERTIES
|
|||||||
QT_ANDROID_PACKAGE_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/android
|
QT_ANDROID_PACKAGE_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/android
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# libQt6Core is built against a recent LLVM libc++; if androiddeployqt packs an older
|
||||||
|
# libc++_shared.so (e.g. from NDK 25), dlopen fails on std::pmr::monotonic_buffer_resource.
|
||||||
|
# Pin the STL to the same NDK CMake uses (Qt 6.9.x expects NDK r27 — see Qt wiki).
|
||||||
|
if(CMAKE_ANDROID_NDK)
|
||||||
|
file(GLOB _amnz_ndk_prebuilts
|
||||||
|
LIST_DIRECTORIES true
|
||||||
|
"${CMAKE_ANDROID_NDK}/toolchains/llvm/prebuilt/*")
|
||||||
|
set(_amnz_ndk_prebuilt "")
|
||||||
|
foreach(_amnz_d IN LISTS _amnz_ndk_prebuilts)
|
||||||
|
if(IS_DIRECTORY "${_amnz_d}")
|
||||||
|
set(_amnz_ndk_prebuilt "${_amnz_d}")
|
||||||
|
break()
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
if(CMAKE_ANDROID_ARCH_ABI STREQUAL "arm64-v8a")
|
||||||
|
set(_amnz_libcxx_triple "aarch64-linux-android")
|
||||||
|
elseif(CMAKE_ANDROID_ARCH_ABI STREQUAL "armeabi-v7a")
|
||||||
|
set(_amnz_libcxx_triple "armv7a-linux-androideabi")
|
||||||
|
elseif(CMAKE_ANDROID_ARCH_ABI STREQUAL "x86")
|
||||||
|
set(_amnz_libcxx_triple "i686-linux-android")
|
||||||
|
elseif(CMAKE_ANDROID_ARCH_ABI STREQUAL "x86_64")
|
||||||
|
set(_amnz_libcxx_triple "x86_64-linux-android")
|
||||||
|
else()
|
||||||
|
set(_amnz_libcxx_triple "")
|
||||||
|
endif()
|
||||||
|
if(_amnz_ndk_prebuilt AND _amnz_libcxx_triple)
|
||||||
|
set(_amnz_libcxx_shared
|
||||||
|
"${_amnz_ndk_prebuilt}/sysroot/usr/lib/${_amnz_libcxx_triple}/libc++_shared.so")
|
||||||
|
if(EXISTS "${_amnz_libcxx_shared}")
|
||||||
|
set_property(TARGET ${PROJECT} PROPERTY QT_ANDROID_EXTRA_LIBS "${_amnz_libcxx_shared}")
|
||||||
|
message(STATUS "Android: QT_ANDROID_EXTRA_LIBS libc++_shared from NDK: ${_amnz_libcxx_shared}")
|
||||||
|
else()
|
||||||
|
message(WARNING "Android: libc++_shared not found at ${_amnz_libcxx_shared} (check CMAKE_ANDROID_NDK=${CMAKE_ANDROID_NDK})")
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
message(WARNING "Android: could not resolve NDK prebuilt under ${CMAKE_ANDROID_NDK}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
set(QT_ANDROID_MULTI_ABI_FORWARD_VARS "QT_NO_GLOBAL_APK_TARGET_PART_OF_ALL;CMAKE_BUILD_TYPE")
|
set(QT_ANDROID_MULTI_ABI_FORWARD_VARS "QT_NO_GLOBAL_APK_TARGET_PART_OF_ALL;CMAKE_BUILD_TYPE")
|
||||||
|
|
||||||
# We need to include qtprivate api's
|
# We need to include qtprivate api's
|
||||||
|
|||||||
@@ -105,7 +105,9 @@ bool AndroidController::initialize()
|
|||||||
{"onSystemBarsInsetsChanged", "(II)V", reinterpret_cast<void *>(onSystemBarsInsetsChanged)},
|
{"onSystemBarsInsetsChanged", "(II)V", reinterpret_cast<void *>(onSystemBarsInsetsChanged)},
|
||||||
{"onActivityPaused", "()V", reinterpret_cast<void *>(onActivityPaused)},
|
{"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)}
|
{"onCameraPermissionResult", "(Z)V", reinterpret_cast<void *>(onCameraPermissionResult)},
|
||||||
|
{"onPairingQrCameraClosed", "()V", reinterpret_cast<void *>(onPairingQrCameraClosed)},
|
||||||
|
{"onPairingQrCameraUserDismissed", "()V", reinterpret_cast<void *>(onPairingQrCameraUserDismissed)}
|
||||||
};
|
};
|
||||||
|
|
||||||
QJniEnvironment env;
|
QJniEnvironment env;
|
||||||
@@ -243,6 +245,11 @@ void AndroidController::startQrReaderActivity()
|
|||||||
callActivityMethod("startQrCodeReader", "()V");
|
callActivityMethod("startQrCodeReader", "()V");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AndroidController::startPairingQrReaderActivity()
|
||||||
|
{
|
||||||
|
callActivityMethod("startPairingQrCodeReader", "()V");
|
||||||
|
}
|
||||||
|
|
||||||
void AndroidController::startPairingQrEmbeddedCamera()
|
void AndroidController::startPairingQrEmbeddedCamera()
|
||||||
{
|
{
|
||||||
callActivityMethod("startPairingQrEmbeddedCamera", "()V");
|
callActivityMethod("startPairingQrEmbeddedCamera", "()V");
|
||||||
@@ -623,4 +630,22 @@ void AndroidController::onCameraPermissionResult(JNIEnv *env, jobject thiz, jboo
|
|||||||
emit AndroidController::instance()->cameraPermissionResult(static_cast<bool>(granted));
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public:
|
|||||||
int getStatusBarHeight();
|
int getStatusBarHeight();
|
||||||
int getNavigationBarHeight();
|
int getNavigationBarHeight();
|
||||||
void startQrReaderActivity();
|
void startQrReaderActivity();
|
||||||
|
void startPairingQrReaderActivity();
|
||||||
void startPairingQrEmbeddedCamera();
|
void startPairingQrEmbeddedCamera();
|
||||||
void stopPairingQrEmbeddedCamera();
|
void stopPairingQrEmbeddedCamera();
|
||||||
void setPairingQrEmbeddedTorch(bool enabled);
|
void setPairingQrEmbeddedTorch(bool enabled);
|
||||||
@@ -117,6 +118,8 @@ private:
|
|||||||
static void onActivityPaused(JNIEnv *env, jobject thiz);
|
static void onActivityPaused(JNIEnv *env, jobject thiz);
|
||||||
static void onActivityResumed(JNIEnv *env, jobject thiz);
|
static void onActivityResumed(JNIEnv *env, jobject thiz);
|
||||||
static void onCameraPermissionResult(JNIEnv *env, jobject thiz, jboolean granted);
|
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>
|
template <typename Ret, typename ...Args>
|
||||||
static auto callActivityMethod(const char *methodName, const char *signature, Args &&...args);
|
static auto callActivityMethod(const char *methodName, const char *signature, Args &&...args);
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
#include <QCoreApplication>
|
#include <QCoreApplication>
|
||||||
#include <QDataStream>
|
#include <QDataStream>
|
||||||
|
#include <QDateTime>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QIODevice>
|
#include <QIODevice>
|
||||||
#include <QMetaObject>
|
#include <QMetaObject>
|
||||||
|
#include <QPointer>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
@@ -96,6 +98,33 @@ bool tryDecodeLegacyChunkedPairingQrPayload(const QString &t, QString *outUuid)
|
|||||||
*outUuid = u.toString(QUuid::WithoutBraces);
|
*outUuid = u.toString(QUuid::WithoutBraces);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a pairing session UUID from raw QR text without touching QObject / signals.
|
||||||
|
* Safe from CameraX / JNI threads while AmneziaActivity is stopped (Qt event loop may not run).
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
QString fromLegacy;
|
||||||
|
if (tryDecodeLegacyChunkedPairingQrPayload(t, &fromLegacy)) {
|
||||||
|
return fromLegacy;
|
||||||
|
}
|
||||||
|
const QUuid parsed = QUuid::fromString(t);
|
||||||
|
if (!parsed.isNull()) {
|
||||||
|
return parsed.toString(QUuid::WithoutBraces);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
#if defined(Q_OS_ANDROID)
|
#if defined(Q_OS_ANDROID)
|
||||||
@@ -113,6 +142,15 @@ bool PairingUiController::iosNativePairingQrOverlayBuild() const
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool PairingUiController::androidNativePairingQrOverlayBuild() const
|
||||||
|
{
|
||||||
|
#if defined(Q_OS_ANDROID)
|
||||||
|
return true;
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
PairingUiController::PairingUiController(PairingController *pairingController, ServersController *serversController,
|
PairingUiController::PairingUiController(PairingController *pairingController, ServersController *serversController,
|
||||||
SubscriptionController *subscriptionController,
|
SubscriptionController *subscriptionController,
|
||||||
SecureAppSettingsRepository *appSettingsRepository, QObject *parent)
|
SecureAppSettingsRepository *appSettingsRepository, QObject *parent)
|
||||||
@@ -173,7 +211,7 @@ void PairingUiController::openPairingQrScanner()
|
|||||||
{
|
{
|
||||||
#if defined(Q_OS_ANDROID)
|
#if defined(Q_OS_ANDROID)
|
||||||
qInfo() << "[PairingUi] openPairingQrScanner (Android native activity)";
|
qInfo() << "[PairingUi] openPairingQrScanner (Android native activity)";
|
||||||
AndroidController::instance()->startQrReaderActivity();
|
AndroidController::instance()->startPairingQrReaderActivity();
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,34 +357,18 @@ bool PairingUiController::applyScannedTextAsPairingUuid(const QString &raw)
|
|||||||
{
|
{
|
||||||
const QString t = raw.trimmed();
|
const QString t = raw.trimmed();
|
||||||
qInfo() << "[PairingUi] scan raw len=" << t.size();
|
qInfo() << "[PairingUi] scan raw len=" << t.size();
|
||||||
if (t.startsWith(QStringLiteral("vpn://"), Qt::CaseInsensitive)) {
|
const QString uuid = extractPairingSessionUuidFromScanText(raw);
|
||||||
qInfo() << "[PairingUi] scan rejected: looks like vpn:// bundle, not session UUID";
|
if (uuid.isEmpty()) {
|
||||||
|
if (t.startsWith(QStringLiteral("vpn://"), Qt::CaseInsensitive)) {
|
||||||
|
qInfo() << "[PairingUi] scan rejected: looks like vpn:// bundle, not session UUID";
|
||||||
|
} else {
|
||||||
|
qInfo() << "[PairingUi] scan rejected: no session UUID recognized in payload";
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
static const QRegularExpression reV4(QStringLiteral(
|
qInfo() << "[PairingUi] scan accepted uuid=" << uuid.left(13) << "...";
|
||||||
"[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}"));
|
emit pairingUuidFromScan(uuid);
|
||||||
const QRegularExpressionMatch m = reV4.match(t);
|
return true;
|
||||||
if (m.hasMatch()) {
|
|
||||||
const QString uuid = m.captured(0);
|
|
||||||
qInfo() << "[PairingUi] scan accepted uuid=" << uuid.left(13) << "...";
|
|
||||||
emit pairingUuidFromScan(uuid);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
QString fromLegacy;
|
|
||||||
if (tryDecodeLegacyChunkedPairingQrPayload(t, &fromLegacy)) {
|
|
||||||
qInfo() << "[PairingUi] scan accepted legacy chunked QR uuid=" << fromLegacy.left(13) << "...";
|
|
||||||
emit pairingUuidFromScan(fromLegacy);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const QUuid parsed = QUuid::fromString(t);
|
|
||||||
if (!parsed.isNull()) {
|
|
||||||
const QString canon = parsed.toString(QUuid::WithoutBraces);
|
|
||||||
qInfo() << "[PairingUi] scan accepted QUuid::fromString uuid=" << canon.left(13) << "...";
|
|
||||||
emit pairingUuidFromScan(canon);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
qInfo() << "[PairingUi] scan rejected: no session UUID recognized in payload";
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if defined(Q_OS_ANDROID)
|
#if defined(Q_OS_ANDROID)
|
||||||
@@ -356,25 +378,65 @@ bool PairingUiController::tryConsumeAndroidQrScan(const QString &code)
|
|||||||
qWarning() << "[PairingUi] tryConsumeAndroidQrScan: no controller (g_pairingUiForAndroidQr null)";
|
qWarning() << "[PairingUi] tryConsumeAndroidQrScan: no controller (g_pairingUiForAndroidQr null)";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
PairingUiController *const ctl = g_pairingUiForAndroidQr;
|
|
||||||
bool consumed = false;
|
|
||||||
const QString codeCopy = code;
|
const QString codeCopy = code;
|
||||||
QObject *const app = QCoreApplication::instance();
|
// Parse on this thread: while CameraActivity is foreground, AmneziaActivity is stopped and the Qt
|
||||||
if (!app) {
|
// event loop may not process BlockingQueuedConnection until the user returns — UI would lag behind.
|
||||||
|
if (extractPairingSessionUuidFromScanText(codeCopy).isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// CameraActivity / ML Kit may invoke JNI from a non-Qt thread. Signals and QML must run on the Qt GUI thread.
|
PairingUiController *const ctl = g_pairingUiForAndroidQr;
|
||||||
QMetaObject::invokeMethod(
|
QPointer<PairingUiController> ctlPtr(ctl);
|
||||||
app,
|
QTimer::singleShot(0, ctl, [ctlPtr, codeCopy]() {
|
||||||
[ctl, codeCopy, &consumed]() {
|
if (!ctlPtr) {
|
||||||
consumed = ctl->applyScannedTextAsPairingUuid(codeCopy);
|
return;
|
||||||
},
|
}
|
||||||
Qt::BlockingQueuedConnection);
|
ctlPtr->applyScannedTextAsPairingUuid(codeCopy);
|
||||||
qInfo() << "[PairingUi] tryConsumeAndroidQrScan consumed=" << consumed << "rawLen=" << codeCopy.size();
|
});
|
||||||
return consumed;
|
qInfo() << "[PairingUi] tryConsumeAndroidQrScan: scheduled apply on Qt thread, rawLen=" << codeCopy.size();
|
||||||
|
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
|
#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 PairingUiController::tvQrCodes() const
|
||||||
{
|
{
|
||||||
QVariantList list;
|
QVariantList list;
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ class PairingUiController : public QObject
|
|||||||
embeddedPairingQrCameraActiveChanged)
|
embeddedPairingQrCameraActiveChanged)
|
||||||
/** True only on iOS builds: use native UIWindow QR overlay (not Qt.platform.os, which can differ). */
|
/** True only on iOS builds: use native UIWindow QR overlay (not Qt.platform.os, which can differ). */
|
||||||
Q_PROPERTY(bool iosNativePairingQrOverlayBuild READ iosNativePairingQrOverlayBuild CONSTANT)
|
Q_PROPERTY(bool iosNativePairingQrOverlayBuild READ iosNativePairingQrOverlayBuild CONSTANT)
|
||||||
|
/** True only on Android builds: full-screen CameraActivity pairing scanner; QML hides duplicate scan chrome. */
|
||||||
|
Q_PROPERTY(bool androidNativePairingQrOverlayBuild READ androidNativePairingQrOverlayBuild CONSTANT)
|
||||||
|
/**
|
||||||
|
* Epoch ms until which QML should not call openPairingQrScanner again (after native CameraActivity closes).
|
||||||
|
* Android pairing flow only; always 0 on other platforms.
|
||||||
|
*/
|
||||||
|
Q_PROPERTY(qint64 androidPairingReaderCooldownUntilEpochMs READ androidPairingReaderCooldownUntilEpochMs NOTIFY
|
||||||
|
androidPairingReaderCooldownUntilEpochMsChanged)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
PairingUiController(PairingController *pairingController, ServersController *serversController,
|
PairingUiController(PairingController *pairingController, ServersController *serversController,
|
||||||
@@ -62,6 +70,7 @@ public:
|
|||||||
int tvPairingUiPhase() const { return m_tvPairingUiPhase; }
|
int tvPairingUiPhase() const { return m_tvPairingUiPhase; }
|
||||||
bool embeddedPairingQrCameraActive() const { return m_embeddedPairingQrCameraActive; }
|
bool embeddedPairingQrCameraActive() const { return m_embeddedPairingQrCameraActive; }
|
||||||
bool iosNativePairingQrOverlayBuild() const;
|
bool iosNativePairingQrOverlayBuild() const;
|
||||||
|
bool androidNativePairingQrOverlayBuild() const;
|
||||||
Q_INVOKABLE void setEmbeddedPairingQrCameraActive(bool active);
|
Q_INVOKABLE void setEmbeddedPairingQrCameraActive(bool active);
|
||||||
/** iOS: native dim strip height uses safe bottom + extraPt (see PageSettingsApiQrPairingSend scanDimBleedBottom). No-op elsewhere. */
|
/** iOS: native dim strip height uses safe bottom + extraPt (see PageSettingsApiQrPairingSend scanDimBleedBottom). No-op elsewhere. */
|
||||||
Q_INVOKABLE void syncIosEmbeddedPairingQrNativeBottomExtra(int extraPt);
|
Q_INVOKABLE void syncIosEmbeddedPairingQrNativeBottomExtra(int extraPt);
|
||||||
@@ -72,6 +81,10 @@ public:
|
|||||||
*/
|
*/
|
||||||
Q_INVOKABLE void refreshIosEmbeddedPairingQrChrome();
|
Q_INVOKABLE void refreshIosEmbeddedPairingQrChrome();
|
||||||
|
|
||||||
|
qint64 androidPairingReaderCooldownUntilEpochMs() const { return m_androidPairingReaderCooldownUntilEpochMs; }
|
||||||
|
/** Lengthens androidPairingReaderCooldownUntilEpochMs to at least now + ms (Android pairing; no-op elsewhere). */
|
||||||
|
Q_INVOKABLE void suppressAndroidNativePairingReaderStarts(int ms);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* iOS: UIKit UIWindow QR scanner (see iosPairingQrOverlayWindow). Pass translated title/subtitle for native chrome.
|
* iOS: UIKit UIWindow QR scanner (see iosPairingQrOverlayWindow). Pass translated title/subtitle for native chrome.
|
||||||
* No-op on other platforms.
|
* No-op on other platforms.
|
||||||
@@ -83,6 +96,10 @@ public:
|
|||||||
|
|
||||||
#if defined(Q_OS_ANDROID)
|
#if defined(Q_OS_ANDROID)
|
||||||
static bool tryConsumeAndroidQrScan(const QString &code);
|
static bool tryConsumeAndroidQrScan(const QString &code);
|
||||||
|
/** JNI from CameraActivity onDestroy: avoid reopening native reader while camera HAL is still releasing. */
|
||||||
|
static void notifyAndroidPairingQrCameraClosed();
|
||||||
|
/** JNI before CameraActivity finish when user pressed back — Qt should reopen native scan (QML shell has no preview). */
|
||||||
|
static void notifyAndroidPairingQrCameraUserDismissed();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
@@ -132,10 +149,13 @@ signals:
|
|||||||
/** After requestPairingCameraAccess(): true if OS granted camera access. */
|
/** After requestPairingCameraAccess(): true if OS granted camera access. */
|
||||||
void pairingCameraAccessFinished(bool granted);
|
void pairingCameraAccessFinished(bool granted);
|
||||||
void embeddedPairingQrCameraActiveChanged();
|
void embeddedPairingQrCameraActiveChanged();
|
||||||
|
void androidPairingReaderCooldownUntilEpochMsChanged();
|
||||||
/** iOS native overlay scanner: payload was not a pairing session UUID (toast in QML). */
|
/** iOS native overlay scanner: payload was not a pairing session UUID (toast in QML). */
|
||||||
void pairingSendQrScanRejectedInvalidPayload();
|
void pairingSendQrScanRejectedInvalidPayload();
|
||||||
/** Native overlay back chevron tapped — dismiss scanner and close page from QML. */
|
/** Native overlay back chevron tapped — dismiss scanner and close page from QML. */
|
||||||
void pairingIosNativeQrOverlayBackRequested();
|
void pairingIosNativeQrOverlayBackRequested();
|
||||||
|
/** Android CameraActivity: user pressed back — QML should exit pairing send (e.g. closePage), not reopen camera. */
|
||||||
|
void pairingAndroidNativeQrScannerUserDismissed();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void setTvBusy(bool busy);
|
void setTvBusy(bool busy);
|
||||||
@@ -170,6 +190,7 @@ private:
|
|||||||
quint64 m_phoneSessionGeneration { 0 };
|
quint64 m_phoneSessionGeneration { 0 };
|
||||||
|
|
||||||
bool m_embeddedPairingQrCameraActive = false;
|
bool m_embeddedPairingQrCameraActive = false;
|
||||||
|
qint64 m_androidPairingReaderCooldownUntilEpochMs = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // PAIRINGUICONTROLLER_H
|
#endif // PAIRINGUICONTROLLER_H
|
||||||
|
|||||||
@@ -25,9 +25,17 @@ PageType {
|
|||||||
/** iOS-only: full-screen UIKit UIWindow scanner (PairingUiController.iosNativePairingQrOverlayBuild — not Qt.platform.os). */
|
/** iOS-only: full-screen UIKit UIWindow scanner (PairingUiController.iosNativePairingQrOverlayBuild — not Qt.platform.os). */
|
||||||
readonly property bool useIosNativePairingQrOverlay: PairingUiController.iosNativePairingQrOverlayBuild
|
readonly property bool useIosNativePairingQrOverlay: PairingUiController.iosNativePairingQrOverlayBuild
|
||||||
|
|
||||||
|
/** Android: full-screen CameraActivity — Qt cannot reliably composite CameraX under QML on some OEMs (e.g. Samsung). */
|
||||||
|
readonly property bool useAndroidNativePairingQrScanner: GC.isMobile() && Qt.platform.os === "android"
|
||||||
|
/** Android: iOS-like flow — titles and camera preview only in CameraActivity; QML hides duplicate scan chrome. */
|
||||||
|
readonly property bool useAndroidNativePairingQrOverlay: PairingUiController.androidNativePairingQrOverlayBuild
|
||||||
|
&& GC.isMobile()
|
||||||
|
&& Qt.platform.os === "android"
|
||||||
|
|
||||||
/** Let dimming draw into window chrome (status bar + tab bar) when camera underlay is active. */
|
/** Let dimming draw into window chrome (status bar + tab bar) when camera underlay is active. */
|
||||||
readonly property bool extendScanDimToScreenEdges: GC.isMobile() && pairingWizardStep === 0
|
readonly property bool extendScanDimToScreenEdges: GC.isMobile() && pairingWizardStep === 0
|
||||||
&& PairingUiController.embeddedPairingQrCameraActive
|
&& PairingUiController.embeddedPairingQrCameraActive
|
||||||
|
&& !root.useAndroidNativePairingQrOverlay
|
||||||
clip: !extendScanDimToScreenEdges
|
clip: !extendScanDimToScreenEdges
|
||||||
|
|
||||||
/** QQuickWindow (not Item); do not type as Item — breaks binding on Qt 6. */
|
/** QQuickWindow (not Item); do not type as Item — breaks binding on Qt 6. */
|
||||||
@@ -140,6 +148,8 @@ PageType {
|
|||||||
property bool awaitingCameraPermissionForScan: false
|
property bool awaitingCameraPermissionForScan: false
|
||||||
property bool waitingSettingsReturnForScan: false
|
property bool waitingSettingsReturnForScan: false
|
||||||
property bool torchOn: false
|
property bool torchOn: false
|
||||||
|
/** Suppress double startActivity when StackView fires both Component.onCompleted and onVisibleChanged. */
|
||||||
|
property int _androidPairingReaderLastStartMs: 0
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: pairingCameraKickTimer
|
id: pairingCameraKickTimer
|
||||||
@@ -171,9 +181,18 @@ PageType {
|
|||||||
if (!root.visible) {
|
if (!root.visible) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
/** Confirm step (or transition to it): never reopen native / embedded scanner from stray taps or visibility. */
|
||||||
|
if (root.pairingWizardStep !== 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (addDeviceConfirmNavigationScheduled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
console.warn("[PairingQrSend] startMobileScanner Qt.platform.os=", Qt.platform.os,
|
console.warn("[PairingQrSend] startMobileScanner Qt.platform.os=", Qt.platform.os,
|
||||||
"iosNativePairingQrOverlayBuild=", PairingUiController.iosNativePairingQrOverlayBuild,
|
"iosNativePairingQrOverlayBuild=", PairingUiController.iosNativePairingQrOverlayBuild,
|
||||||
"useIosNativePairingQrOverlay=", root.useIosNativePairingQrOverlay)
|
"useIosNativePairingQrOverlay=", root.useIosNativePairingQrOverlay,
|
||||||
|
"androidNativePairingQrOverlayBuild=", PairingUiController.androidNativePairingQrOverlayBuild,
|
||||||
|
"useAndroidNativePairingQrOverlay=", root.useAndroidNativePairingQrOverlay)
|
||||||
if (!PairingUiController.isPairingCameraAccessGranted()) {
|
if (!PairingUiController.isPairingCameraAccessGranted()) {
|
||||||
awaitingCameraPermissionForScan = true
|
awaitingCameraPermissionForScan = true
|
||||||
PairingUiController.requestPairingCameraAccess()
|
PairingUiController.requestPairingCameraAccess()
|
||||||
@@ -186,6 +205,23 @@ PageType {
|
|||||||
/** Do not run pairingCameraKickTimer here: restartCapture during first startRunning races the session (torch needs 2–3 taps). */
|
/** Do not run pairingCameraKickTimer here: restartCapture during first startRunning races the session (torch needs 2–3 taps). */
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (root.useAndroidNativePairingQrScanner) {
|
||||||
|
const coolUntil = PairingUiController.androidPairingReaderCooldownUntilEpochMs
|
||||||
|
if (Date.now() < coolUntil) {
|
||||||
|
console.warn("[PairingQrSend] startMobileScanner: skip (native camera cooldown), ms left=",
|
||||||
|
(coolUntil - Date.now()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - _androidPairingReaderLastStartMs < 700) {
|
||||||
|
console.warn("[PairingQrSend] startMobileScanner: skip duplicate Android CameraActivity within",
|
||||||
|
(now - _androidPairingReaderLastStartMs), "ms")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_androidPairingReaderLastStartMs = now
|
||||||
|
PairingUiController.openPairingQrScanner()
|
||||||
|
return
|
||||||
|
}
|
||||||
PairingUiController.embeddedPairingQrCameraActive = true
|
PairingUiController.embeddedPairingQrCameraActive = true
|
||||||
if (root.useIosStyleNativeQrReader) {
|
if (root.useIosStyleNativeQrReader) {
|
||||||
// Session must start here, not only after pairingCameraKickTimer (220ms), otherwise
|
// Session must start here, not only after pairingCameraKickTimer (220ms), otherwise
|
||||||
@@ -249,13 +285,15 @@ PageType {
|
|||||||
|
|
||||||
onVisibleChanged: {
|
onVisibleChanged: {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
addDeviceConfirmNavigationScheduled = false
|
/** Only reset confirm flag on scan step; clearing it on confirm breaks guards if visible flickers. */
|
||||||
if (pairingWizardStep === 0) {
|
if (pairingWizardStep === 0) {
|
||||||
|
addDeviceConfirmNavigationScheduled = false
|
||||||
Qt.callLater(startMobileScanner)
|
Qt.callLater(startMobileScanner)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pairingCameraKickTimer.stop()
|
pairingCameraKickTimer.stop()
|
||||||
stopMobileScanner()
|
stopMobileScanner()
|
||||||
|
_androidPairingReaderLastStartMs = 0
|
||||||
pairingWizardStep = 0
|
pairingWizardStep = 0
|
||||||
waitingSettingsReturnForScan = false
|
waitingSettingsReturnForScan = false
|
||||||
if (!keepPhonePairingInBackgroundOnClose && !PairingUiController.phonePairingBusy) {
|
if (!keepPhonePairingInBackgroundOnClose && !PairingUiController.phonePairingBusy) {
|
||||||
@@ -268,6 +306,10 @@ PageType {
|
|||||||
if (pairingWizardStep !== 0) {
|
if (pairingWizardStep !== 0) {
|
||||||
stopMobileScanner()
|
stopMobileScanner()
|
||||||
} else if (root.visible) {
|
} else if (root.visible) {
|
||||||
|
/**
|
||||||
|
* Android native: use Qt.callLater like iOS — a multi-second Timer delay left the QML scan chrome
|
||||||
|
* visible with an empty (black) viewport until CameraActivity opened.
|
||||||
|
*/
|
||||||
Qt.callLater(startMobileScanner)
|
Qt.callLater(startMobileScanner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,10 +390,31 @@ PageType {
|
|||||||
Item {
|
Item {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
||||||
|
/** Brief Qt backdrop + back while CameraActivity is starting (native holds title/instructions like iOS overlay). */
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: pairingWizardStep === 0 && root.useAndroidNativePairingQrOverlay
|
||||||
|
color: AmneziaStyle.color.midnightBlack
|
||||||
|
z: 1
|
||||||
|
}
|
||||||
|
BackButtonType {
|
||||||
|
visible: pairingWizardStep === 0 && root.useAndroidNativePairingQrOverlay
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: PageController.safeAreaTopMargin
|
||||||
|
anchors.left: parent.left
|
||||||
|
width: parent.width
|
||||||
|
z: 2
|
||||||
|
backButtonFunction: function() {
|
||||||
|
PageController.closePage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: scanStep
|
id: scanStep
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
visible: pairingWizardStep === 0
|
visible: pairingWizardStep === 0 && !root.useAndroidNativePairingQrOverlay
|
||||||
|
/** Extra guard: invisible alone can race one frame on some stacks; deny input off scan step. */
|
||||||
|
enabled: pairingWizardStep === 0 && !root.useAndroidNativePairingQrOverlay
|
||||||
|
|
||||||
readonly property real sqSz: Math.floor(Math.min(width, height) * 0.72)
|
readonly property real sqSz: Math.floor(Math.min(width, height) * 0.72)
|
||||||
readonly property real sqX: (width - sqSz) / 2
|
readonly property real sqX: (width - sqSz) / 2
|
||||||
@@ -653,16 +716,16 @@ PageType {
|
|||||||
anchors.leftMargin: 0
|
anchors.leftMargin: 0
|
||||||
anchors.rightMargin: 0
|
anchors.rightMargin: 0
|
||||||
visible: pairingWizardStep === 1
|
visible: pairingWizardStep === 1
|
||||||
|
z: 10
|
||||||
spacing: 16
|
spacing: 16
|
||||||
|
|
||||||
BackButtonType {
|
BackButtonType {
|
||||||
Layout.topMargin: 20 + PageController.safeAreaTopMargin
|
Layout.topMargin: 20 + PageController.safeAreaTopMargin
|
||||||
Layout.leftMargin: 0
|
Layout.leftMargin: 0
|
||||||
backButtonFunction: function() {
|
backButtonFunction: function() {
|
||||||
PairingUiController.cancelAllPairingActivity()
|
|
||||||
pairingWizardStep = 0
|
|
||||||
addDeviceConfirmNavigationScheduled = false
|
addDeviceConfirmNavigationScheduled = false
|
||||||
Qt.callLater(startMobileScanner)
|
pairingWizardStep = 0
|
||||||
|
PairingUiController.cancelAllPairingActivity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,10 +777,9 @@ PageType {
|
|||||||
text: qsTr("Cancel")
|
text: qsTr("Cancel")
|
||||||
|
|
||||||
clickedFunc: function() {
|
clickedFunc: function() {
|
||||||
PairingUiController.cancelAllPairingActivity()
|
|
||||||
pairingWizardStep = 0
|
|
||||||
addDeviceConfirmNavigationScheduled = false
|
addDeviceConfirmNavigationScheduled = false
|
||||||
Qt.callLater(startMobileScanner)
|
pairingWizardStep = 0
|
||||||
|
PairingUiController.cancelAllPairingActivity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,7 +798,9 @@ PageType {
|
|||||||
}
|
}
|
||||||
awaitingCameraPermissionForScan = false
|
awaitingCameraPermissionForScan = false
|
||||||
if (granted) {
|
if (granted) {
|
||||||
startMobileScanner()
|
if (root.pairingWizardStep === 0) {
|
||||||
|
startMobileScanner()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
waitingSettingsReturnForScan = true
|
waitingSettingsReturnForScan = true
|
||||||
showScanCameraDeniedDrawer()
|
showScanCameraDeniedDrawer()
|
||||||
@@ -750,9 +814,8 @@ PageType {
|
|||||||
addDeviceConfirmNavigationScheduled = true
|
addDeviceConfirmNavigationScheduled = true
|
||||||
stopMobileScanner()
|
stopMobileScanner()
|
||||||
PairingUiController.pendingPhonePairingUuid = uuid
|
PairingUiController.pendingPhonePairingUuid = uuid
|
||||||
Qt.callLater(function() {
|
/** Immediate step switch so scan chrome is not hit-testable for another frame (avoids reopening CameraActivity). */
|
||||||
pairingWizardStep = 1
|
pairingWizardStep = 1
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPairingSendQrScanRejectedInvalidPayload() {
|
function onPairingSendQrScanRejectedInvalidPayload() {
|
||||||
@@ -771,5 +834,16 @@ PageType {
|
|||||||
stopMobileScanner()
|
stopMobileScanner()
|
||||||
PageController.closePage()
|
PageController.closePage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Native CameraActivity back: leave pairing flow (same as iOS overlay back). Do NOT reopen scanner. */
|
||||||
|
function onPairingAndroidNativeQrScannerUserDismissed() {
|
||||||
|
if (!root.useAndroidNativePairingQrOverlay) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopMobileScanner()
|
||||||
|
PairingUiController.cancelAllPairingActivity()
|
||||||
|
addDeviceConfirmNavigationScheduled = false
|
||||||
|
PageController.closePage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user