mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-20 02:00:55 +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_height="match_parent" />
|
||||
|
||||
<org.amnezia.vpn.PairingQrScanOverlayView
|
||||
android:id="@+id/pairingScanOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/pairingChrome"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top"
|
||||
android:background="@android:color/transparent"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="28dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/pairingBack"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="top"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/pairing_qr_camera_back"
|
||||
android:padding="12dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_pairing_back" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pairingTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/pairing_qr_camera_title"
|
||||
android:textColor="#FFE8E8EC"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pairingSubtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/pairing_qr_camera_subtitle"
|
||||
android:textColor="#FFB8B8C0"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/torchButton"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:background="@drawable/torch_fab_bg"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:text="🔦"
|
||||
android:textSize="26sp"
|
||||
android:contentDescription="@string/camera_torch" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -24,5 +24,10 @@
|
||||
<string name="notificationSettingsDialogMessage">Для показа уведомлений необходимо включить уведомления в системных настройках</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>
|
||||
</resources>
|
||||
@@ -27,6 +27,10 @@
|
||||
<string name="cameraPermissionDialogTitle">Camera access</string>
|
||||
<string name="cameraPermissionDialogMessage">To scan a QR code for device pairing, Amnezia VPN needs access to the camera.</string>
|
||||
<string name="cameraPermissionContinue">Continue</string>
|
||||
<string name="camera_torch">Flashlight</string>
|
||||
<string name="pairing_qr_camera_title">Add device via QR</string>
|
||||
<string name="pairing_qr_camera_subtitle">Scan the session QR shown on the device you want to add. You will confirm before the subscription is sent.</string>
|
||||
<string name="pairing_qr_camera_back">Back</string>
|
||||
|
||||
<string name="tvNoFileBrowser">Please install a file management utility to browse files</string>
|
||||
</resources>
|
||||
@@ -42,6 +42,9 @@ import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import java.io.IOException
|
||||
import kotlin.LazyThreadSafetyMode.NONE
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
@@ -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 KEY_PENDING_OPEN_FILE_URI = "pending_open_file_uri"
|
||||
|
||||
class AmneziaActivity : QtActivity() {
|
||||
class AmneziaActivity : QtActivity(), LifecycleOwner {
|
||||
|
||||
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||
|
||||
override val lifecycle: Lifecycle
|
||||
get() = lifecycleRegistry
|
||||
|
||||
private lateinit var mainScope: CoroutineScope
|
||||
private val qtInitialized = CompletableDeferred<Unit>()
|
||||
@@ -101,6 +109,7 @@ class AmneziaActivity : QtActivity() {
|
||||
private var openFileDeliveryScheduled = false
|
||||
|
||||
private var pairingQrEmbeddedCamera: PairingQrEmbeddedCamera? = null
|
||||
private var lastPairingQrReaderStartUptimeMs: Long = 0L
|
||||
|
||||
private val vpnServiceEventHandler: Handler by lazy(NONE) {
|
||||
object : Handler(Looper.getMainLooper()) {
|
||||
@@ -208,6 +217,7 @@ class AmneziaActivity : QtActivity() {
|
||||
registerBroadcastReceivers()
|
||||
intent?.let(::processIntent)
|
||||
runBlocking { vpnProto = proto.await() }
|
||||
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
@@ -265,6 +275,7 @@ class AmneziaActivity : QtActivity() {
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
|
||||
Log.d(TAG, "Start Amnezia activity")
|
||||
mainScope.launch {
|
||||
qtInitialized.await()
|
||||
@@ -288,6 +299,7 @@ class AmneziaActivity : QtActivity() {
|
||||
qtInitialized.await()
|
||||
QtAndroidController.onServiceDisconnected()
|
||||
}
|
||||
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
@@ -360,6 +372,7 @@ class AmneziaActivity : QtActivity() {
|
||||
if (qtInitialized.isCompleted) {
|
||||
QtAndroidController.onActivityPaused()
|
||||
}
|
||||
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
|
||||
super.onPause()
|
||||
isActivityResumed = false
|
||||
// Cancel all pending operations when activity pauses
|
||||
@@ -370,6 +383,7 @@ class AmneziaActivity : QtActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
isActivityResumed = true
|
||||
Log.d(TAG, "Resume Amnezia activity")
|
||||
if (qtInitialized.isCompleted) {
|
||||
@@ -486,6 +500,7 @@ class AmneziaActivity : QtActivity() {
|
||||
unregisterBroadcastReceiver(notificationStateReceiver)
|
||||
notificationStateReceiver = null
|
||||
mainScope.cancel()
|
||||
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
|
||||
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")
|
||||
fun setSaveLogs(enabled: Boolean) {
|
||||
Log.v(TAG, "Set save logs: $enabled")
|
||||
|
||||
@@ -2,47 +2,401 @@ package org.amnezia.vpn
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
import android.view.MotionEvent.ACTION_DOWN
|
||||
import android.view.MotionEvent.ACTION_UP
|
||||
import android.graphics.RectF
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ExperimentalGetImage
|
||||
import androidx.camera.core.FocusMeteringAction
|
||||
import androidx.camera.core.FocusMeteringAction.FLAG_AE
|
||||
import androidx.camera.core.FocusMeteringAction.FLAG_AF
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.camera.view.TransformExperimental
|
||||
import androidx.camera.view.transform.CoordinateTransform
|
||||
import androidx.camera.view.transform.ImageProxyTransformFactory
|
||||
import androidx.camera.view.transform.OutputTransform
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.lifecycle.Observer
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions.Builder
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.ZoomSuggestionOptions
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import org.amnezia.vpn.databinding.CameraPreviewBinding
|
||||
import org.amnezia.vpn.qt.QtAndroidController
|
||||
import org.amnezia.vpn.util.Log
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val TAG = "CameraActivity"
|
||||
|
||||
@OptIn(TransformExperimental::class)
|
||||
class CameraActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_PAIRING_QR_CAMERA = "org.amnezia.vpn.extra.PAIRING_QR_CAMERA"
|
||||
}
|
||||
|
||||
private lateinit var viewBinding: CameraPreviewBinding
|
||||
private lateinit var cameraProvider: ProcessCameraProvider
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var boundCamera: Camera? = null
|
||||
private var boundImageAnalysis: ImageAnalysis? = null
|
||||
private var torchOn: Boolean = false
|
||||
|
||||
/** 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
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
viewBinding = CameraPreviewBinding.inflate(layoutInflater)
|
||||
setContentView(viewBinding.root)
|
||||
viewBinding.viewFinder.scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
|
||||
if (intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val density = resources.displayMetrics.density
|
||||
val padH = (8 * density).toInt()
|
||||
val padTopBase = (28 * density).toInt()
|
||||
val padBottom = (12 * density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(viewBinding.pairingChrome) { v, windowInsets ->
|
||||
val bars = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars())
|
||||
v.setPadding(padH, padTopBase + bars.top, (16 * density).toInt(), padBottom)
|
||||
v.post { onPairingLayoutGeometryChanged() }
|
||||
windowInsets
|
||||
}
|
||||
viewBinding.pairingScanOverlay.visibility = View.VISIBLE
|
||||
viewBinding.pairingChrome.visibility = View.VISIBLE
|
||||
viewBinding.root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
onPairingLayoutGeometryChanged()
|
||||
}
|
||||
viewBinding.root.post {
|
||||
onPairingLayoutGeometryChanged()
|
||||
applyPairingTorchButtonChrome()
|
||||
}
|
||||
}
|
||||
|
||||
viewBinding.pairingBack.setOnClickListener { releaseCameraAndFinish() }
|
||||
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
releaseCameraAndFinish()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
viewBinding.torchButton.setOnClickListener {
|
||||
torchOn = !torchOn
|
||||
try {
|
||||
boundCamera?.cameraControl?.enableTorch(torchOn)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Torch: $e")
|
||||
}
|
||||
if (intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) {
|
||||
applyPairingTorchButtonChrome()
|
||||
}
|
||||
}
|
||||
|
||||
checkPermissions(onSuccess = ::startCamera, onFail = ::finish)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
if (!intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) {
|
||||
return
|
||||
}
|
||||
if (!::viewBinding.isInitialized) {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
onSuccess()
|
||||
@@ -67,26 +421,41 @@ class CameraActivity : ComponentActivity() {
|
||||
|
||||
cameraProviderFuture.addListener({
|
||||
cameraProvider = cameraProviderFuture.get()
|
||||
bindPreview()
|
||||
bindImageAnalysis()
|
||||
bindCameraUseCases()
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun bindPreview() {
|
||||
@ExperimentalGetImage
|
||||
private fun bindCameraUseCases() {
|
||||
val provider = cameraProvider ?: return
|
||||
imageAnalysisExecutor?.shutdown()
|
||||
imageAnalysisExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
val viewFinder = viewBinding.viewFinder
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(viewFinder.surfaceProvider)
|
||||
}
|
||||
|
||||
val camera = cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview)
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
|
||||
val camera = provider.bindToLifecycle(
|
||||
this,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
imageAnalysis
|
||||
)
|
||||
boundCamera = camera
|
||||
boundImageAnalysis = imageAnalysis
|
||||
|
||||
viewFinder.setOnTouchListener { _, motionEvent ->
|
||||
when (motionEvent.action) {
|
||||
ACTION_DOWN -> true
|
||||
ACTION_UP -> {
|
||||
val point = viewFinder
|
||||
.meteringPointFactory.createPoint(motionEvent.x, motionEvent.x)
|
||||
.meteringPointFactory.createPoint(motionEvent.x, motionEvent.y)
|
||||
|
||||
val action = FocusMeteringAction
|
||||
.Builder(point, FLAG_AF or FLAG_AE).build()
|
||||
@@ -98,58 +467,117 @@ class CameraActivity : ComponentActivity() {
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalGetImage
|
||||
private fun bindImageAnalysis() {
|
||||
val imageAnalysis = ImageAnalysis.Builder().build()
|
||||
if (intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)) {
|
||||
previewTransformLayoutListener?.let { viewFinder.removeOnLayoutChangeListener(it) }
|
||||
val layoutListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
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)
|
||||
|
||||
val barcodeScanner = BarcodeScanning.getClient(
|
||||
try {
|
||||
barcodeScanner?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
// No ZoomSuggestionOptions: ML Kit "scanner-auto-zoom" is off-spec for pairing and skews crop vs our ROI.
|
||||
barcodeScanner = BarcodeScanning.getClient(
|
||||
Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.setZoomSuggestionOptions(
|
||||
ZoomSuggestionOptions.Builder { zoomLevel ->
|
||||
camera.cameraControl.setZoomRatio(zoomLevel)
|
||||
true
|
||||
}.apply {
|
||||
camera.cameraInfo.zoomState.value?.maxZoomRatio?.let { maxZoomRation ->
|
||||
setMaxSupportedZoomRatio(maxZoomRation)
|
||||
}
|
||||
}.build()
|
||||
).build()
|
||||
.build()
|
||||
)
|
||||
|
||||
// optimization
|
||||
val checkedBarcodes = hashSetOf<String>()
|
||||
val analysisExecutor = imageAnalysisExecutor!!
|
||||
val mainExecutor = ContextCompat.getMainExecutor(this)
|
||||
val pairingQrMode = intent.getBooleanExtra(EXTRA_PAIRING_QR_CAMERA, false)
|
||||
|
||||
imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(this)) { imageProxy ->
|
||||
imageProxy.image?.let { InputImage.fromMediaImage(it, imageProxy.imageInfo.rotationDegrees) }
|
||||
?.let { image ->
|
||||
barcodeScanner.process(image).addOnSuccessListener { barcodes ->
|
||||
barcodes.firstOrNull()?.let { barcode ->
|
||||
barcode.displayValue?.let { code ->
|
||||
if (code.isNotEmpty() && code !in checkedBarcodes) {
|
||||
if (QtAndroidController.decodeQrCode(code)) {
|
||||
barcodeScanner.close()
|
||||
imageAnalysis.setAnalyzer(analysisExecutor) { imageProxy ->
|
||||
if (qrHandledOrClosing.get()) {
|
||||
imageProxy.close()
|
||||
return@setAnalyzer
|
||||
}
|
||||
val mediaImage = imageProxy.image
|
||||
if (mediaImage == null) {
|
||||
imageProxy.close()
|
||||
return@setAnalyzer
|
||||
}
|
||||
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||
val viewW = viewFinder.width
|
||||
val viewH = viewFinder.height
|
||||
val pairingRoi = if (pairingQrMode) {
|
||||
pairingHoleRectInImageSpace(viewFinder, imageProxy, image.width, image.height)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val scanner = barcodeScanner ?: run {
|
||||
imageProxy.close()
|
||||
return@setAnalyzer
|
||||
}
|
||||
scanner.process(image)
|
||||
.addOnSuccessListener(mainExecutor) { barcodes ->
|
||||
if (qrHandledOrClosing.get()) {
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
val barcode = if (pairingQrMode) {
|
||||
val roi = pairingRoi
|
||||
?: PairingQrScanGeometry.pairingIosStyleHoleInImageCoords(
|
||||
viewW,
|
||||
viewH,
|
||||
pairingGeomHeaderBottomPx,
|
||||
pairingGeomStatusBarTopPx,
|
||||
pairingGeomDensity,
|
||||
image.width,
|
||||
image.height
|
||||
)
|
||||
barcodes.firstOrNull {
|
||||
PairingQrScanGeometry.barcodeMatchesPairingHole(
|
||||
roi,
|
||||
image.width,
|
||||
image.height,
|
||||
it
|
||||
)
|
||||
}
|
||||
} else {
|
||||
barcodes.firstOrNull()
|
||||
}
|
||||
barcode?.displayValue?.let { code ->
|
||||
if (code.isNotEmpty() && code !in checkedBarcodes) {
|
||||
checkedBarcodes.add(code)
|
||||
if (QtAndroidController.decodeQrCode(code)) {
|
||||
if (qrHandledOrClosing.compareAndSet(false, true)) {
|
||||
if (pairingQrMode) {
|
||||
pairingQrDeliveredToQt = true
|
||||
}
|
||||
stopCamera()
|
||||
}
|
||||
checkedBarcodes.add(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.addOnFailureListener {
|
||||
Log.e(TAG, "Processing QR code image failed: ${it.message}")
|
||||
}.addOnCompleteListener {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
.addOnFailureListener(mainExecutor) {
|
||||
Log.e(TAG, "Processing QR code image failed: ${it.message}")
|
||||
}
|
||||
.addOnCompleteListener(mainExecutor) {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopCamera() {
|
||||
cameraProvider.unbindAll()
|
||||
cleanupCameraResources()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.amnezia.vpn
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
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 org.amnezia.vpn.qt.QtAndroidController
|
||||
import org.amnezia.vpn.util.Log
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
private const val TAG = "PairingQrEmbedded"
|
||||
|
||||
@@ -33,6 +37,10 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
||||
|
||||
private val windowBgOpaque = ColorDrawable(Color.parseColor("#0E0E11"))
|
||||
|
||||
private var savedWindowFormat: Int? = null
|
||||
|
||||
private var imageAnalysisExecutor: ExecutorService? = null
|
||||
|
||||
fun start() {
|
||||
activity.runOnUiThread {
|
||||
if (previewView != null) {
|
||||
@@ -40,15 +48,26 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
||||
}
|
||||
checkedBarcodes.clear()
|
||||
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)
|
||||
content.clipChildren = false
|
||||
content.clipToPadding = false
|
||||
val pv = PreviewView(activity).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
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
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
setBackgroundColor(Color.TRANSPARENT)
|
||||
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||
}
|
||||
content.addView(pv, 0)
|
||||
for (i in 1 until content.childCount) {
|
||||
@@ -61,6 +80,10 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
||||
try {
|
||||
cameraProvider = cameraProviderFuture.get()
|
||||
bindUseCases(pv)
|
||||
pv.post {
|
||||
pv.requestLayout()
|
||||
pv.invalidate()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Camera bind failed: $e")
|
||||
stop()
|
||||
@@ -72,6 +95,8 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
||||
private fun bindUseCases(viewFinder: PreviewView) {
|
||||
val provider = cameraProvider ?: return
|
||||
provider.unbindAll()
|
||||
imageAnalysisExecutor?.shutdown()
|
||||
imageAnalysisExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(viewFinder.surfaceProvider)
|
||||
@@ -94,7 +119,13 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.setZoomSuggestionOptions(
|
||||
ZoomSuggestionOptions.Builder { zoomLevel ->
|
||||
cam.cameraControl.setZoomRatio(zoomLevel)
|
||||
activity.runOnUiThread {
|
||||
try {
|
||||
cam.cameraControl.setZoomRatio(zoomLevel)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Zoom: $e")
|
||||
}
|
||||
}
|
||||
true
|
||||
}.apply {
|
||||
cam.cameraInfo.zoomState.value?.maxZoomRatio?.let { maxZoom ->
|
||||
@@ -105,28 +136,32 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
||||
)
|
||||
|
||||
val scanner = barcodeScanner!!
|
||||
imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(activity)) { imageProxy ->
|
||||
val analysisExecutor = imageAnalysisExecutor!!
|
||||
imageAnalysis.setAnalyzer(analysisExecutor) { imageProxy ->
|
||||
val mediaImage = imageProxy.image ?: run {
|
||||
imageProxy.close()
|
||||
return@setAnalyzer
|
||||
}
|
||||
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||
scanner.process(image).addOnSuccessListener { barcodes ->
|
||||
barcodes.firstOrNull()?.displayValue?.let { code ->
|
||||
if (code.isNotEmpty() && code !in checkedBarcodes) {
|
||||
checkedBarcodes.add(code)
|
||||
if (QtAndroidController.decodeQrCode(code)) {
|
||||
scanner.close()
|
||||
barcodeScanner = null
|
||||
stop()
|
||||
scanner.process(image)
|
||||
.addOnSuccessListener(analysisExecutor) { barcodes ->
|
||||
barcodes.firstOrNull()?.displayValue?.let { code ->
|
||||
if (code.isNotEmpty() && code !in checkedBarcodes) {
|
||||
checkedBarcodes.add(code)
|
||||
if (QtAndroidController.decodeQrCode(code)) {
|
||||
scanner.close()
|
||||
barcodeScanner = null
|
||||
activity.runOnUiThread { stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.addOnFailureListener {
|
||||
Log.e(TAG, "QR process failed: ${it.message}")
|
||||
}.addOnCompleteListener {
|
||||
imageProxy.close()
|
||||
}
|
||||
.addOnFailureListener(analysisExecutor) {
|
||||
Log.e(TAG, "QR process failed: ${it.message}")
|
||||
}
|
||||
.addOnCompleteListener(analysisExecutor) {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +177,8 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
||||
|
||||
fun stop() {
|
||||
activity.runOnUiThread {
|
||||
imageAnalysisExecutor?.shutdown()
|
||||
imageAnalysisExecutor = null
|
||||
try {
|
||||
boundCamera?.cameraControl?.enableTorch(false)
|
||||
} catch (_: Exception) {
|
||||
@@ -158,6 +195,12 @@ class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
|
||||
previewView = null
|
||||
|
||||
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 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
|
||||
)
|
||||
|
||||
# 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")
|
||||
|
||||
# We need to include qtprivate api's
|
||||
|
||||
@@ -105,7 +105,9 @@ bool AndroidController::initialize()
|
||||
{"onSystemBarsInsetsChanged", "(II)V", reinterpret_cast<void *>(onSystemBarsInsetsChanged)},
|
||||
{"onActivityPaused", "()V", reinterpret_cast<void *>(onActivityPaused)},
|
||||
{"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;
|
||||
@@ -243,6 +245,11 @@ void AndroidController::startQrReaderActivity()
|
||||
callActivityMethod("startQrCodeReader", "()V");
|
||||
}
|
||||
|
||||
void AndroidController::startPairingQrReaderActivity()
|
||||
{
|
||||
callActivityMethod("startPairingQrCodeReader", "()V");
|
||||
}
|
||||
|
||||
void AndroidController::startPairingQrEmbeddedCamera()
|
||||
{
|
||||
callActivityMethod("startPairingQrEmbeddedCamera", "()V");
|
||||
@@ -623,4 +630,22 @@ void AndroidController::onCameraPermissionResult(JNIEnv *env, jobject thiz, jboo
|
||||
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 getNavigationBarHeight();
|
||||
void startQrReaderActivity();
|
||||
void startPairingQrReaderActivity();
|
||||
void startPairingQrEmbeddedCamera();
|
||||
void stopPairingQrEmbeddedCamera();
|
||||
void setPairingQrEmbeddedTorch(bool enabled);
|
||||
@@ -117,6 +118,8 @@ private:
|
||||
static void onActivityPaused(JNIEnv *env, jobject thiz);
|
||||
static void onActivityResumed(JNIEnv *env, jobject thiz);
|
||||
static void onCameraPermissionResult(JNIEnv *env, jobject thiz, jboolean granted);
|
||||
static void onPairingQrCameraClosed(JNIEnv *env, jobject thiz);
|
||||
static void onPairingQrCameraUserDismissed(JNIEnv *env, jobject thiz);
|
||||
|
||||
template <typename Ret, typename ...Args>
|
||||
static auto callActivityMethod(const char *methodName, const char *signature, Args &&...args);
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDataStream>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QIODevice>
|
||||
#include <QMetaObject>
|
||||
#include <QPointer>
|
||||
#include <QRegularExpression>
|
||||
#include <QTimer>
|
||||
#include <QUuid>
|
||||
@@ -96,6 +98,33 @@ bool tryDecodeLegacyChunkedPairingQrPayload(const QString &t, QString *outUuid)
|
||||
*outUuid = u.toString(QUuid::WithoutBraces);
|
||||
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
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
@@ -113,6 +142,15 @@ bool PairingUiController::iosNativePairingQrOverlayBuild() const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool PairingUiController::androidNativePairingQrOverlayBuild() const
|
||||
{
|
||||
#if defined(Q_OS_ANDROID)
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
PairingUiController::PairingUiController(PairingController *pairingController, ServersController *serversController,
|
||||
SubscriptionController *subscriptionController,
|
||||
SecureAppSettingsRepository *appSettingsRepository, QObject *parent)
|
||||
@@ -173,7 +211,7 @@ void PairingUiController::openPairingQrScanner()
|
||||
{
|
||||
#if defined(Q_OS_ANDROID)
|
||||
qInfo() << "[PairingUi] openPairingQrScanner (Android native activity)";
|
||||
AndroidController::instance()->startQrReaderActivity();
|
||||
AndroidController::instance()->startPairingQrReaderActivity();
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -319,34 +357,18 @@ bool PairingUiController::applyScannedTextAsPairingUuid(const QString &raw)
|
||||
{
|
||||
const QString t = raw.trimmed();
|
||||
qInfo() << "[PairingUi] scan raw len=" << t.size();
|
||||
if (t.startsWith(QStringLiteral("vpn://"), Qt::CaseInsensitive)) {
|
||||
qInfo() << "[PairingUi] scan rejected: looks like vpn:// bundle, not session UUID";
|
||||
const QString uuid = extractPairingSessionUuidFromScanText(raw);
|
||||
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;
|
||||
}
|
||||
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()) {
|
||||
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;
|
||||
qInfo() << "[PairingUi] scan accepted uuid=" << uuid.left(13) << "...";
|
||||
emit pairingUuidFromScan(uuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
@@ -356,25 +378,65 @@ bool PairingUiController::tryConsumeAndroidQrScan(const QString &code)
|
||||
qWarning() << "[PairingUi] tryConsumeAndroidQrScan: no controller (g_pairingUiForAndroidQr null)";
|
||||
return false;
|
||||
}
|
||||
PairingUiController *const ctl = g_pairingUiForAndroidQr;
|
||||
bool consumed = false;
|
||||
const QString codeCopy = code;
|
||||
QObject *const app = QCoreApplication::instance();
|
||||
if (!app) {
|
||||
// Parse on this thread: while CameraActivity is foreground, AmneziaActivity is stopped and the Qt
|
||||
// event loop may not process BlockingQueuedConnection until the user returns — UI would lag behind.
|
||||
if (extractPairingSessionUuidFromScanText(codeCopy).isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// CameraActivity / ML Kit may invoke JNI from a non-Qt thread. Signals and QML must run on the Qt GUI thread.
|
||||
QMetaObject::invokeMethod(
|
||||
app,
|
||||
[ctl, codeCopy, &consumed]() {
|
||||
consumed = ctl->applyScannedTextAsPairingUuid(codeCopy);
|
||||
},
|
||||
Qt::BlockingQueuedConnection);
|
||||
qInfo() << "[PairingUi] tryConsumeAndroidQrScan consumed=" << consumed << "rawLen=" << codeCopy.size();
|
||||
return consumed;
|
||||
PairingUiController *const ctl = g_pairingUiForAndroidQr;
|
||||
QPointer<PairingUiController> ctlPtr(ctl);
|
||||
QTimer::singleShot(0, ctl, [ctlPtr, codeCopy]() {
|
||||
if (!ctlPtr) {
|
||||
return;
|
||||
}
|
||||
ctlPtr->applyScannedTextAsPairingUuid(codeCopy);
|
||||
});
|
||||
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
|
||||
|
||||
void PairingUiController::suppressAndroidNativePairingReaderStarts(int ms)
|
||||
{
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
}
|
||||
#if defined(Q_OS_ANDROID)
|
||||
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||
const qint64 until = now + ms;
|
||||
if (until <= m_androidPairingReaderCooldownUntilEpochMs) {
|
||||
return;
|
||||
}
|
||||
m_androidPairingReaderCooldownUntilEpochMs = until;
|
||||
emit androidPairingReaderCooldownUntilEpochMsChanged();
|
||||
#else
|
||||
Q_UNUSED(ms);
|
||||
#endif
|
||||
}
|
||||
|
||||
QVariantList PairingUiController::tvQrCodes() const
|
||||
{
|
||||
QVariantList list;
|
||||
|
||||
@@ -40,6 +40,14 @@ class PairingUiController : public QObject
|
||||
embeddedPairingQrCameraActiveChanged)
|
||||
/** True only on iOS builds: use native UIWindow QR overlay (not Qt.platform.os, which can differ). */
|
||||
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:
|
||||
PairingUiController(PairingController *pairingController, ServersController *serversController,
|
||||
@@ -62,6 +70,7 @@ public:
|
||||
int tvPairingUiPhase() const { return m_tvPairingUiPhase; }
|
||||
bool embeddedPairingQrCameraActive() const { return m_embeddedPairingQrCameraActive; }
|
||||
bool iosNativePairingQrOverlayBuild() const;
|
||||
bool androidNativePairingQrOverlayBuild() const;
|
||||
Q_INVOKABLE void setEmbeddedPairingQrCameraActive(bool active);
|
||||
/** iOS: native dim strip height uses safe bottom + extraPt (see PageSettingsApiQrPairingSend scanDimBleedBottom). No-op elsewhere. */
|
||||
Q_INVOKABLE void syncIosEmbeddedPairingQrNativeBottomExtra(int extraPt);
|
||||
@@ -72,6 +81,10 @@ public:
|
||||
*/
|
||||
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.
|
||||
* No-op on other platforms.
|
||||
@@ -83,6 +96,10 @@ public:
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
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
|
||||
|
||||
public slots:
|
||||
@@ -132,10 +149,13 @@ signals:
|
||||
/** After requestPairingCameraAccess(): true if OS granted camera access. */
|
||||
void pairingCameraAccessFinished(bool granted);
|
||||
void embeddedPairingQrCameraActiveChanged();
|
||||
void androidPairingReaderCooldownUntilEpochMsChanged();
|
||||
/** iOS native overlay scanner: payload was not a pairing session UUID (toast in QML). */
|
||||
void pairingSendQrScanRejectedInvalidPayload();
|
||||
/** Native overlay back chevron tapped — dismiss scanner and close page from QML. */
|
||||
void pairingIosNativeQrOverlayBackRequested();
|
||||
/** Android CameraActivity: user pressed back — QML should exit pairing send (e.g. closePage), not reopen camera. */
|
||||
void pairingAndroidNativeQrScannerUserDismissed();
|
||||
|
||||
private:
|
||||
void setTvBusy(bool busy);
|
||||
@@ -170,6 +190,7 @@ private:
|
||||
quint64 m_phoneSessionGeneration { 0 };
|
||||
|
||||
bool m_embeddedPairingQrCameraActive = false;
|
||||
qint64 m_androidPairingReaderCooldownUntilEpochMs = 0;
|
||||
};
|
||||
|
||||
#endif // PAIRINGUICONTROLLER_H
|
||||
|
||||
@@ -25,9 +25,17 @@ PageType {
|
||||
/** iOS-only: full-screen UIKit UIWindow scanner (PairingUiController.iosNativePairingQrOverlayBuild — not Qt.platform.os). */
|
||||
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. */
|
||||
readonly property bool extendScanDimToScreenEdges: GC.isMobile() && pairingWizardStep === 0
|
||||
&& PairingUiController.embeddedPairingQrCameraActive
|
||||
&& !root.useAndroidNativePairingQrOverlay
|
||||
clip: !extendScanDimToScreenEdges
|
||||
|
||||
/** QQuickWindow (not Item); do not type as Item — breaks binding on Qt 6. */
|
||||
@@ -140,6 +148,8 @@ PageType {
|
||||
property bool awaitingCameraPermissionForScan: false
|
||||
property bool waitingSettingsReturnForScan: false
|
||||
property bool torchOn: false
|
||||
/** Suppress double startActivity when StackView fires both Component.onCompleted and onVisibleChanged. */
|
||||
property int _androidPairingReaderLastStartMs: 0
|
||||
|
||||
Timer {
|
||||
id: pairingCameraKickTimer
|
||||
@@ -171,9 +181,18 @@ PageType {
|
||||
if (!root.visible) {
|
||||
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,
|
||||
"iosNativePairingQrOverlayBuild=", PairingUiController.iosNativePairingQrOverlayBuild,
|
||||
"useIosNativePairingQrOverlay=", root.useIosNativePairingQrOverlay)
|
||||
"useIosNativePairingQrOverlay=", root.useIosNativePairingQrOverlay,
|
||||
"androidNativePairingQrOverlayBuild=", PairingUiController.androidNativePairingQrOverlayBuild,
|
||||
"useAndroidNativePairingQrOverlay=", root.useAndroidNativePairingQrOverlay)
|
||||
if (!PairingUiController.isPairingCameraAccessGranted()) {
|
||||
awaitingCameraPermissionForScan = true
|
||||
PairingUiController.requestPairingCameraAccess()
|
||||
@@ -186,6 +205,23 @@ PageType {
|
||||
/** Do not run pairingCameraKickTimer here: restartCapture during first startRunning races the session (torch needs 2–3 taps). */
|
||||
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
|
||||
if (root.useIosStyleNativeQrReader) {
|
||||
// Session must start here, not only after pairingCameraKickTimer (220ms), otherwise
|
||||
@@ -249,13 +285,15 @@ PageType {
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
addDeviceConfirmNavigationScheduled = false
|
||||
/** Only reset confirm flag on scan step; clearing it on confirm breaks guards if visible flickers. */
|
||||
if (pairingWizardStep === 0) {
|
||||
addDeviceConfirmNavigationScheduled = false
|
||||
Qt.callLater(startMobileScanner)
|
||||
}
|
||||
} else {
|
||||
pairingCameraKickTimer.stop()
|
||||
stopMobileScanner()
|
||||
_androidPairingReaderLastStartMs = 0
|
||||
pairingWizardStep = 0
|
||||
waitingSettingsReturnForScan = false
|
||||
if (!keepPhonePairingInBackgroundOnClose && !PairingUiController.phonePairingBusy) {
|
||||
@@ -268,6 +306,10 @@ PageType {
|
||||
if (pairingWizardStep !== 0) {
|
||||
stopMobileScanner()
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
@@ -348,10 +390,31 @@ PageType {
|
||||
Item {
|
||||
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 {
|
||||
id: scanStep
|
||||
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 sqX: (width - sqSz) / 2
|
||||
@@ -653,16 +716,16 @@ PageType {
|
||||
anchors.leftMargin: 0
|
||||
anchors.rightMargin: 0
|
||||
visible: pairingWizardStep === 1
|
||||
z: 10
|
||||
spacing: 16
|
||||
|
||||
BackButtonType {
|
||||
Layout.topMargin: 20 + PageController.safeAreaTopMargin
|
||||
Layout.leftMargin: 0
|
||||
backButtonFunction: function() {
|
||||
PairingUiController.cancelAllPairingActivity()
|
||||
pairingWizardStep = 0
|
||||
addDeviceConfirmNavigationScheduled = false
|
||||
Qt.callLater(startMobileScanner)
|
||||
pairingWizardStep = 0
|
||||
PairingUiController.cancelAllPairingActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,10 +777,9 @@ PageType {
|
||||
text: qsTr("Cancel")
|
||||
|
||||
clickedFunc: function() {
|
||||
PairingUiController.cancelAllPairingActivity()
|
||||
pairingWizardStep = 0
|
||||
addDeviceConfirmNavigationScheduled = false
|
||||
Qt.callLater(startMobileScanner)
|
||||
pairingWizardStep = 0
|
||||
PairingUiController.cancelAllPairingActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,7 +798,9 @@ PageType {
|
||||
}
|
||||
awaitingCameraPermissionForScan = false
|
||||
if (granted) {
|
||||
startMobileScanner()
|
||||
if (root.pairingWizardStep === 0) {
|
||||
startMobileScanner()
|
||||
}
|
||||
} else {
|
||||
waitingSettingsReturnForScan = true
|
||||
showScanCameraDeniedDrawer()
|
||||
@@ -750,9 +814,8 @@ PageType {
|
||||
addDeviceConfirmNavigationScheduled = true
|
||||
stopMobileScanner()
|
||||
PairingUiController.pendingPhonePairingUuid = uuid
|
||||
Qt.callLater(function() {
|
||||
pairingWizardStep = 1
|
||||
})
|
||||
/** Immediate step switch so scan chrome is not hit-testable for another frame (avoids reopening CameraActivity). */
|
||||
pairingWizardStep = 1
|
||||
}
|
||||
|
||||
function onPairingSendQrScanRejectedInvalidPayload() {
|
||||
@@ -771,5 +834,16 @@ PageType {
|
||||
stopMobileScanner()
|
||||
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