fixed scaner QR Android

This commit is contained in:
dranik
2026-05-09 17:11:29 +03:00
parent 2fa0ec81ad
commit f781bf6a23
18 changed files with 1397 additions and 111 deletions
@@ -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>
+5
View File
@@ -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>
+4
View File
@@ -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 views 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()
}
+39
View File
@@ -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);
+102 -40
View File
@@ -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 23 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()
}
}
}