fixed UI scanner iOS

This commit is contained in:
dranik
2026-05-08 21:35:08 +03:00
parent bb56008c3d
commit b7e2847393
18 changed files with 1542 additions and 306 deletions
+3
View File
@@ -109,6 +109,9 @@ void AmneziaApplication::init()
// install filter on main window
if (auto win = qobject_cast<QQuickWindow*>(obj)) {
win->installEventFilter(this);
#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS)
win->setDefaultAlphaBuffer(true);
#endif
#ifdef Q_OS_ANDROID
QObject::connect(win, &QQuickWindow::sceneGraphError,
[](QQuickWindow::SceneGraphError, const QString &msg) {
@@ -100,6 +100,8 @@ class AmneziaActivity : QtActivity() {
private var pendingOpenFileUri: String? = null
private var openFileDeliveryScheduled = false
private var pairingQrEmbeddedCamera: PairingQrEmbeddedCamera? = null
private val vpnServiceEventHandler: Handler by lazy(NONE) {
object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
@@ -981,6 +983,26 @@ class AmneziaActivity : QtActivity() {
return heightDp
}
@Suppress("unused")
fun startPairingQrEmbeddedCamera() {
Log.v(TAG, "startPairingQrEmbeddedCamera")
if (pairingQrEmbeddedCamera == null) {
pairingQrEmbeddedCamera = PairingQrEmbeddedCamera(this)
}
pairingQrEmbeddedCamera?.start()
}
@Suppress("unused")
fun stopPairingQrEmbeddedCamera() {
Log.v(TAG, "stopPairingQrEmbeddedCamera")
pairingQrEmbeddedCamera?.stop()
}
@Suppress("unused")
fun setPairingQrEmbeddedTorch(enabled: Boolean) {
pairingQrEmbeddedCamera?.setTorch(enabled)
}
@Suppress("unused")
fun startQrCodeReader() {
Log.v(TAG, "Start camera")
@@ -0,0 +1,163 @@
package org.amnezia.vpn
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScannerOptions.Builder
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.qt.QtAndroidController
import org.amnezia.vpn.util.Log
private const val TAG = "PairingQrEmbedded"
@OptIn(ExperimentalGetImage::class)
class PairingQrEmbeddedCamera(private val activity: AmneziaActivity) {
private var previewView: PreviewView? = null
private var cameraProvider: ProcessCameraProvider? = null
private var boundCamera: Camera? = null
private val checkedBarcodes = hashSetOf<String>()
private var barcodeScanner: com.google.mlkit.vision.barcode.BarcodeScanner? = null
private val windowBgOpaque = ColorDrawable(Color.parseColor("#0E0E11"))
fun start() {
activity.runOnUiThread {
if (previewView != null) {
return@runOnUiThread
}
checkedBarcodes.clear()
activity.window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
val content = activity.findViewById<ViewGroup>(android.R.id.content)
val pv = PreviewView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
scaleType = PreviewView.ScaleType.FILL_CENTER
}
content.addView(pv, 0)
for (i in 1 until content.childCount) {
content.getChildAt(i).bringToFront()
}
previewView = pv
val cameraProviderFuture = ProcessCameraProvider.getInstance(activity)
cameraProviderFuture.addListener({
try {
cameraProvider = cameraProviderFuture.get()
bindUseCases(pv)
} catch (e: Exception) {
Log.e(TAG, "Camera bind failed: $e")
stop()
}
}, ContextCompat.getMainExecutor(activity))
}
}
private fun bindUseCases(viewFinder: PreviewView) {
val provider = cameraProvider ?: return
provider.unbindAll()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(viewFinder.surfaceProvider)
}
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
val cam = provider.bindToLifecycle(
activity,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalysis
)
boundCamera = cam
barcodeScanner = BarcodeScanning.getClient(
Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.setZoomSuggestionOptions(
ZoomSuggestionOptions.Builder { zoomLevel ->
cam.cameraControl.setZoomRatio(zoomLevel)
true
}.apply {
cam.cameraInfo.zoomState.value?.maxZoomRatio?.let { maxZoom ->
setMaxSupportedZoomRatio(maxZoom)
}
}.build()
).build()
)
val scanner = barcodeScanner!!
imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(activity)) { 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()
}
}
}
}.addOnFailureListener {
Log.e(TAG, "QR process failed: ${it.message}")
}.addOnCompleteListener {
imageProxy.close()
}
}
}
fun setTorch(on: Boolean) {
activity.runOnUiThread {
try {
boundCamera?.cameraControl?.enableTorch(on)
} catch (e: Exception) {
Log.e(TAG, "Torch: $e")
}
}
}
fun stop() {
activity.runOnUiThread {
try {
boundCamera?.cameraControl?.enableTorch(false)
} catch (_: Exception) {
}
boundCamera = null
barcodeScanner?.close()
barcodeScanner = null
cameraProvider?.unbindAll()
cameraProvider = null
previewView?.let { pv ->
(pv.parent as? ViewGroup)?.removeView(pv)
}
previewView = null
activity.window.setBackgroundDrawable(windowBgOpaque)
}
}
}
@@ -243,6 +243,21 @@ void AndroidController::startQrReaderActivity()
callActivityMethod("startQrCodeReader", "()V");
}
void AndroidController::startPairingQrEmbeddedCamera()
{
callActivityMethod("startPairingQrEmbeddedCamera", "()V");
}
void AndroidController::stopPairingQrEmbeddedCamera()
{
callActivityMethod("stopPairingQrEmbeddedCamera", "()V");
}
void AndroidController::setPairingQrEmbeddedTorch(bool enabled)
{
callActivityMethod("setPairingQrEmbeddedTorch", "(Z)V", enabled);
}
void AndroidController::setSaveLogs(bool enabled)
{
callActivityMethod("setSaveLogs", "(Z)V", enabled);
@@ -46,6 +46,9 @@ public:
int getStatusBarHeight();
int getNavigationBarHeight();
void startQrReaderActivity();
void startPairingQrEmbeddedCamera();
void stopPairingQrEmbeddedCamera();
void setPairingQrEmbeddedTorch(bool enabled);
void setSaveLogs(bool enabled);
void exportLogsFile(const QString &fileName);
void clearLogs();
@@ -12,4 +12,5 @@ QRect QRCodeReader::cameraSize() {
void QRCodeReader::startReading() {}
void QRCodeReader::stopReading() {}
void QRCodeReader::setCameraSize(QRect) {}
void QRCodeReader::setTorchEnabled(bool) {}
void QRCodeReader::notifyCodeRead(const QString &) {}
+1
View File
@@ -16,6 +16,7 @@ public slots:
void startReading();
void stopReading();
void setCameraSize(QRect value);
void setTorchEnabled(bool on);
/// Called from AVFoundation delegate on the main queue; emits codeReaded.
void notifyCodeRead(const QString &code);
+276 -42
View File
@@ -1,19 +1,109 @@
#if !MACOS_NE
#include "QRCodeReaderBase.h"
#include "platforms/ios/iosPairingCameraAccess.h"
#include <QByteArray>
#include <QDebug>
#include <QThread>
#import <UIKit/UIKit.h>
#import <AVFoundation/AVFoundation.h>
static NSString *amneziaQrThreadTag(void)
{
if ([NSThread isMainThread]) {
return @"main";
}
return [NSString stringWithFormat:@"bg:%p", (void *)[NSThread currentThread]];
}
static void amneziaQrLogDeviceAuth(void)
{
AVAuthorizationStatus st = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
NSString *stName = @"unknown";
switch (st) {
case AVAuthorizationStatusNotDetermined:
stName = @"notDetermined";
break;
case AVAuthorizationStatusRestricted:
stName = @"restricted";
break;
case AVAuthorizationStatusDenied:
stName = @"denied";
break;
case AVAuthorizationStatusAuthorized:
stName = @"authorized";
break;
default:
break;
}
NSLog(@"[QRCodeReader] camera auth status=%@ (%ld)", stName, (long)st);
}
static UIWindow *amneziaKeyWindowForQrCamera(void)
{
UIApplication *app = [UIApplication sharedApplication];
NSMutableArray<NSString *> *trace = [NSMutableArray array];
if (@available(iOS 13.0, *)) {
NSInteger sceneCount = app.connectedScenes.count;
[trace addObject:[NSString stringWithFormat:@"connectedScenes=%ld", (long)sceneCount]];
for (UIScene *scene in app.connectedScenes) {
if (scene.activationState != UISceneActivationStateForegroundActive) {
continue;
}
if (![scene isKindOfClass:[UIWindowScene class]]) {
continue;
}
UIWindowScene *windowScene = (UIWindowScene *)scene;
NSInteger winN = windowScene.windows.count;
[trace addObject:[NSString stringWithFormat:@"foreground UIWindowScene windows=%ld", (long)winN]];
for (UIWindow *window in windowScene.windows) {
if (window.isKeyWindow) {
NSLog(@"[QRCodeReader] keyWindow pick: scene keyWindow=%@ bounds=%@", window, NSStringFromCGRect(window.bounds));
return window;
}
}
for (UIWindow *window in windowScene.windows) {
if (!window.isHidden) {
NSLog(@"[QRCodeReader] keyWindow pick: scene nonHidden=%@ bounds=%@", window, NSStringFromCGRect(window.bounds));
return window;
}
}
}
}
if (app.keyWindow) {
[trace addObject:@"app.keyWindow"];
NSLog(@"[QRCodeReader] keyWindow pick: application.keyWindow=%@ bounds=%@",
app.keyWindow, NSStringFromCGRect(app.keyWindow.bounds));
return app.keyWindow;
}
for (UIWindow *window in app.windows) {
if (window.isKeyWindow) {
NSLog(@"[QRCodeReader] keyWindow pick: windows scan key=%@ bounds=%@", window, NSStringFromCGRect(window.bounds));
return window;
}
}
UIWindow *first = app.windows.firstObject;
if (first) {
NSLog(@"[QRCodeReader] keyWindow pick: firstObject=%@ bounds=%@ trace=[%@]",
first, NSStringFromCGRect(first.bounds), [trace componentsJoinedByString:@", "]);
return first;
}
NSLog(@"[QRCodeReader] keyWindow pick: NONE trace=[%@]", [trace componentsJoinedByString:@", "]);
return nil;
}
@interface QRCodeReaderImpl : UIViewController
@end
@interface QRCodeReaderImpl () <AVCaptureMetadataOutputObjectsDelegate>
@property (nonatomic) QRCodeReader* qrCodeReader;
@property (nonatomic, strong) AVCaptureSession *captureSession;
@property (nonatomic, strong) AVCaptureVideoPreviewLayer *videoPreviewPlayer;
@property (nonatomic, assign) QRCodeReader *qrCodeReader;
@property (nonatomic, retain) AVCaptureSession *captureSession;
@property (nonatomic, retain) AVCaptureVideoPreviewLayer *videoPreviewPlayer;
@property (nonatomic, retain) AVCaptureDevice *activeCaptureDevice;
@property (nonatomic) dispatch_queue_t sessionQueue;
@end
@@ -23,88 +113,223 @@
- (void)viewDidLoad {
[super viewDidLoad];
_captureSession = nil;
self.captureSession = nil;
if (!_sessionQueue) {
_sessionQueue = dispatch_queue_create("org.amnezia.qr.session", DISPATCH_QUEUE_SERIAL);
}
}
- (void)setQrCodeReader: (QRCodeReader*)value {
- (void)setQrCodeReader:(QRCodeReader *)value {
_qrCodeReader = value;
}
- (BOOL)startReading {
[self stopReading];
- (AVCaptureDevice *)resolvedCaptureDevice {
if (self.activeCaptureDevice) {
return self.activeCaptureDevice;
}
AVCaptureSession *session = self.captureSession;
if (!session) {
return nil;
}
for (AVCaptureInput *input in session.inputs) {
if ([input isKindOfClass:[AVCaptureDeviceInput class]]) {
AVCaptureDevice *d = ((AVCaptureDeviceInput *)input).device;
if (d) {
NSLog(@"[QRCodeReader] resolvedCaptureDevice from session input device=%p", d);
return d;
}
}
}
return nil;
}
- (void)applyTorchOnMainThread:(BOOL)on {
AVCaptureDevice *device = [self resolvedCaptureDevice];
if (!device) {
if (on) {
NSLog(@"[QRCodeReader] torch ON failed: no device (active=%p session=%p inputs=%lu)",
self.activeCaptureDevice,
self.captureSession,
(unsigned long)(self.captureSession ? self.captureSession.inputs.count : 0));
}
return;
}
if (![device hasTorch]) {
NSLog(@"[QRCodeReader] torch: device %p has no torch", device);
return;
}
AVCaptureSession *session = self.captureSession;
if (on && session && ![session isRunning]) {
NSLog(@"[QRCodeReader] torch: session not running yet; retry in 0.25s (session=%p)", session);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.25 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
if (on) {
[self applyTorchOnMainThread:YES];
}
});
return;
}
NSError *err = nil;
if (![device lockForConfiguration:&err]) {
NSLog(@"[QRCodeReader] torch lock failed: %@", err.localizedDescription);
return;
}
if (on) {
err = nil;
if (![device setTorchModeOnWithLevel:AVCaptureMaxAvailableTorchLevel error:&err]) {
NSLog(@"[QRCodeReader] setTorchModeOnWithLevel failed: %@ — trying torchMode", err.localizedDescription);
if ([device isTorchModeSupported:AVCaptureTorchModeOn]) {
device.torchMode = AVCaptureTorchModeOn;
}
} else {
NSLog(@"[QRCodeReader] torch ON ok level=maxAvailable");
}
} else {
device.torchMode = AVCaptureTorchModeOff;
}
[device unlockForConfiguration];
}
- (void)applyTorch:(BOOL)on {
NSLog(@"[QRCodeReader] applyTorch requested on=%d thread=%@", (int)on, amneziaQrThreadTag());
if ([NSThread isMainThread]) {
[self applyTorchOnMainThread:on];
} else {
dispatch_async(dispatch_get_main_queue(), ^{
[self applyTorchOnMainThread:on];
});
}
}
- (BOOL)startReadingOnMainThread {
NSLog(@"[QRCodeReader] startReadingOnMainThread begin thread=%@", amneziaQrThreadTag());
amneziaQrLogDeviceAuth();
[self stopReadingOnMainThread];
NSError *error = nil;
AVCaptureDevice *captureDevice = [AVCaptureDevice defaultDeviceWithMediaType: AVMediaTypeVideo];
AVCaptureDeviceInput *deviceInput = [AVCaptureDeviceInput deviceInputWithDevice: captureDevice error: &error];
AVCaptureDevice *captureDevice = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
if (!captureDevice) {
NSLog(@"[QRCodeReader] defaultDeviceWithMediaType:Video is nil");
return NO;
}
NSLog(@"[QRCodeReader] capture device=%p localizedName=%@", captureDevice, captureDevice.localizedName);
AVCaptureDeviceInput *deviceInput = [AVCaptureDeviceInput deviceInputWithDevice:captureDevice error:&error];
if (!deviceInput) {
NSLog(@"[QRCodeReader] deviceInput failed: %@", error.localizedDescription);
return NO;
}
_captureSession = [[AVCaptureSession alloc]init];
[_captureSession addInput:deviceInput];
self.activeCaptureDevice = captureDevice;
NSLog(@"[QRCodeReader] activeCaptureDevice set to %p", self.activeCaptureDevice);
AVCaptureSession *session = [[AVCaptureSession alloc] init];
[session addInput:deviceInput];
AVCaptureMetadataOutput *capturedMetadataOutput = [[AVCaptureMetadataOutput alloc] init];
[_captureSession addOutput:capturedMetadataOutput];
[session addOutput:capturedMetadataOutput];
if (!_sessionQueue) {
_sessionQueue = dispatch_queue_create("org.amnezia.qr.session", DISPATCH_QUEUE_SERIAL);
}
[capturedMetadataOutput setMetadataObjectsDelegate: self queue: _sessionQueue];
[capturedMetadataOutput setMetadataObjectTypes: [NSArray arrayWithObject:AVMetadataObjectTypeQRCode]];
[capturedMetadataOutput setMetadataObjectsDelegate:self queue:_sessionQueue];
[capturedMetadataOutput setMetadataObjectTypes:[NSArray arrayWithObject:AVMetadataObjectTypeQRCode]];
_videoPreviewPlayer = [[AVCaptureVideoPreviewLayer alloc] initWithSession: _captureSession];
CGFloat statusBarHeight = [UIApplication sharedApplication].statusBarFrame.size.height;
self.captureSession = session;
[session release];
QRect cameraRect = _qrCodeReader->cameraSize();
CGRect cameraCGRect = CGRectMake(cameraRect.x(),
cameraRect.y() + statusBarHeight,
cameraRect.width(),
cameraRect.height());
AVCaptureVideoPreviewLayer *preview = [[AVCaptureVideoPreviewLayer alloc] initWithSession:self.captureSession];
[preview setVideoGravity:AVLayerVideoGravityResizeAspectFill];
self.videoPreviewPlayer = preview;
[preview release];
[_videoPreviewPlayer setVideoGravity: AVLayerVideoGravityResizeAspectFill];
[_videoPreviewPlayer setFrame: cameraCGRect];
UIWindow *keyWindow = amneziaKeyWindowForQrCamera();
if (!keyWindow) {
NSLog(@"[QRCodeReader] startReading: no keyWindow (UIKit must run on main)");
[self stopReadingOnMainThread];
return NO;
}
CALayer* layer = [UIApplication sharedApplication].keyWindow.layer;
[layer addSublayer: _videoPreviewPlayer];
CGRect bounds = keyWindow.bounds;
[self.videoPreviewPlayer setFrame:bounds];
self.videoPreviewPlayer.zPosition = -1000.f;
[keyWindow.layer insertSublayer:self.videoPreviewPlayer atIndex:0];
amneziaIosPairingRelayoutChromeIfNeeded();
NSLog(@"[QRCodeReader] previewLayer inserted window=%@ layer.sublayers.count=%lu bounds=%@",
keyWindow, (unsigned long)keyWindow.layer.sublayers.count, NSStringFromCGRect(bounds));
AVCaptureSession *session = _captureSession;
AVCaptureSession *runningSession = self.captureSession;
dispatch_async(_sessionQueue, ^{
[session startRunning];
NSLog(@"[QRCodeReader] session startRunning on session queue session=%p", runningSession);
[runningSession startRunning];
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"[QRCodeReader] after startRunning isRunning=%d", (int)runningSession.isRunning);
});
});
NSLog(@"[QRCodeReader] startReading OK frame=(%.1f,%.1f,%.1f,%.1f) statusBar=%.1f",
cameraCGRect.origin.x, cameraCGRect.origin.y, cameraCGRect.size.width, cameraCGRect.size.height, statusBarHeight);
NSLog(@"[QRCodeReader] startReading OK activeDevice=%p window bounds=%@",
self.activeCaptureDevice, NSStringFromCGRect(bounds));
return YES;
}
- (void)stopReading {
if (_captureSession) {
AVCaptureSession *session = _captureSession;
- (BOOL)startReading {
NSLog(@"[QRCodeReader] startReading entry thread=%@ qt=%p", amneziaQrThreadTag(), (void *)QThread::currentThread());
if ([NSThread isMainThread]) {
return [self startReadingOnMainThread];
}
__block BOOL ok = NO;
dispatch_sync(dispatch_get_main_queue(), ^{
ok = [self startReadingOnMainThread];
});
NSLog(@"[QRCodeReader] startReading exit ok=%d (dispatched to main)", (int)ok);
return ok;
}
- (void)stopReadingOnMainThread {
NSLog(@"[QRCodeReader] stopReadingOnMainThread thread=%@", amneziaQrThreadTag());
[self applyTorchOnMainThread:NO];
self.activeCaptureDevice = nil;
if (self.captureSession) {
AVCaptureSession *session = self.captureSession;
dispatch_async(_sessionQueue, ^{
NSLog(@"[QRCodeReader] session stopRunning session=%p", session);
[session stopRunning];
});
_captureSession = nil;
self.captureSession = nil;
}
if (_videoPreviewPlayer) {
[_videoPreviewPlayer removeFromSuperlayer];
_videoPreviewPlayer = nil;
if (self.videoPreviewPlayer) {
NSLog(@"[QRCodeReader] remove preview from superlayer");
[self.videoPreviewPlayer removeFromSuperlayer];
self.videoPreviewPlayer = nil;
}
}
- (void)captureOutput:(AVCaptureOutput *)output didOutputMetadataObjects:(NSArray<__kindof AVMetadataObject *> *)metadataObjects fromConnection:(AVCaptureConnection *)connection {
- (void)stopReading {
NSLog(@"[QRCodeReader] stopReading entry thread=%@ qt=%p", amneziaQrThreadTag(), (void *)QThread::currentThread());
if ([NSThread isMainThread]) {
[self stopReadingOnMainThread];
} else {
dispatch_sync(dispatch_get_main_queue(), ^{
[self stopReadingOnMainThread];
});
}
NSLog(@"[QRCodeReader] stopReading exit");
}
- (void)captureOutput:(AVCaptureOutput *)output
didOutputMetadataObjects:(NSArray<__kindof AVMetadataObject *> *)metadataObjects
fromConnection:(AVCaptureConnection *)connection {
if (metadataObjects != nil && metadataObjects.count > 0) {
AVMetadataMachineReadableCodeObject *metadataObject = [metadataObjects objectAtIndex:0];
if ([[metadataObject type] isEqualToString: AVMetadataObjectTypeQRCode]) {
if ([[metadataObject type] isEqualToString:AVMetadataObjectTypeQRCode]) {
NSString *value = [metadataObject stringValue];
if (value.length == 0) {
return;
@@ -123,7 +348,7 @@
QRCodeReader::QRCodeReader() {
m_qrCodeReader = [[QRCodeReaderImpl alloc] init];
[m_qrCodeReader setQrCodeReader: this];
[m_qrCodeReader setQrCodeReader:this];
}
QRect QRCodeReader::cameraSize() {
@@ -136,20 +361,28 @@ void QRCodeReader::setCameraSize(QRect value) {
}
void QRCodeReader::startReading() {
qInfo() << "[QRCodeReader] C++ startReading thread" << QThread::currentThread();
const BOOL ok = [m_qrCodeReader startReading];
if (!ok) {
qWarning() << "[QRCodeReader] startReading failed (see NSLogs)";
qWarning() << "[QRCodeReader] C++ startReading failed (see NSLogs)";
} else {
qInfo() << "[QRCodeReader] C++ startReading ok";
}
}
void QRCodeReader::stopReading() {
qInfo() << "[QRCodeReader] C++ stopReading thread" << QThread::currentThread();
[m_qrCodeReader stopReading];
qInfo() << "[QRCodeReader] stopReading";
}
void QRCodeReader::notifyCodeRead(const QString &code) {
emit codeReaded(code);
}
void QRCodeReader::setTorchEnabled(bool on) {
qInfo() << "[QRCodeReader] C++ setTorchEnabled" << on << "thread" << QThread::currentThread();
[(QRCodeReaderImpl *)m_qrCodeReader applyTorch:on ? YES : NO];
}
#else
#include "QRCodeReaderBase.h"
@@ -165,5 +398,6 @@ QRect QRCodeReader::cameraSize() {
void QRCodeReader::startReading() {}
void QRCodeReader::stopReading() {}
void QRCodeReader::setCameraSize(QRect) {}
void QRCodeReader::setTorchEnabled(bool) {}
void QRCodeReader::notifyCodeRead(const QString &) {}
#endif
@@ -7,4 +7,16 @@ bool amneziaIosPairingCameraAccessGranted();
void amneziaIosRequestPairingCameraAccess(const std::function<void(bool)> &onDone);
void amneziaIosOpenApplicationSettings();
/** When true, makes Qt's root UIView non-opaque so window-layer camera preview shows through transparent QML. */
void amneziaIosApplyEmbeddedCameraUnderlayToQtView(bool enable);
/**
* Extra height (points) added to the bottom native dim strip above UIKit safe-area bottom, when the Qt root view
* fills the host (tab bar lives in QML). Typically set from QML scanDimBleedBottom. Pass 0 to clear.
*/
void amneziaIosSetPairingEmbeddedCameraNativeBottomExtraPt(int extraPt);
/** Call after AVCaptureVideoPreviewLayer is inserted on UIWindow so the window-layer mask stacks above it. */
void amneziaIosPairingRelayoutChromeIfNeeded(void);
#endif
@@ -3,6 +3,266 @@
#import <AVFoundation/AVFoundation.h>
#import <UIKit/UIKit.h>
static UIViewController *amneziaKeyWindowViewController(void)
{
UIApplication *app = [UIApplication sharedApplication];
if (@available(iOS 13.0, *)) {
for (UIScene *scene in app.connectedScenes) {
if (scene.activationState != UISceneActivationStateForegroundActive) {
continue;
}
if (![scene isKindOfClass:[UIWindowScene class]]) {
continue;
}
UIWindowScene *windowScene = (UIWindowScene *)scene;
for (UIWindow *window in windowScene.windows) {
if (window.isKeyWindow && window.rootViewController) {
return window.rootViewController;
}
}
for (UIWindow *window in windowScene.windows) {
if (!window.isHidden && window.rootViewController) {
return window.rootViewController;
}
}
}
}
for (UIWindow *window in app.windows) {
if (window.isKeyWindow && window.rootViewController) {
return window.rootViewController;
}
}
for (UIWindow *window in app.windows) {
if (window.rootViewController) {
return window.rootViewController;
}
}
return nil;
}
/** QML window is shorter than UIKit UIWindow (e.g. 759 vs 852); camera preview covers full window — dim strips in safe areas. */
static UIView *s_pairingSafeTopDim = nil;
static UIView *s_pairingSafeBottomDim = nil;
static id s_pairingSafeDimOrientationToken = nil;
/** Qt root for layout: bottom strip fills from maxY to host bottom when Qt view is shorter than host. */
static __unsafe_unretained UIView *s_pairingDimQtRoot = nil;
/** QML-driven tab band (e.g. scanDimBleedBottom) added to safe bottom when Qt root is full height. */
static int s_pairingNativeBottomExtraPt = 0;
/** Opaque strip on UIWindow.layer above AVCaptureVideoPreviewLayer — preview can composite above UIDropShadowView subviews. */
static CALayer *s_pairingWindowBottomMaskLayer = nil;
static UIColor *amneziaPairingBottomChromeOpaqueColor(void)
{
return [UIColor colorWithRed:(CGFloat)(28.0 / 255.0) green:(CGFloat)(29.0 / 255.0) blue:(CGFloat)(33.0 / 255.0) alpha:1.0f];
}
static CALayer *amneziaFindVideoPreviewLayerInWindow(UIWindow *window)
{
if (!window) {
return nil;
}
for (CALayer *ly in window.layer.sublayers) {
if ([ly isKindOfClass:[AVCaptureVideoPreviewLayer class]]) {
return ly;
}
}
return nil;
}
static void amneziaRemovePairingWindowBottomMaskLayer(void)
{
[s_pairingWindowBottomMaskLayer removeFromSuperlayer];
s_pairingWindowBottomMaskLayer = nil;
}
/** Maps bottom strip from host (UIDropShadowView) into window coords and stacks above camera preview on window.layer. */
static void amneziaSyncPairingWindowBottomMaskLayer(UIWindow *window, UIView *host, CGFloat bottomY, CGFloat bottomH, CGFloat width)
{
if (!window || !host || bottomH < 0.5f) {
amneziaRemovePairingWindowBottomMaskLayer();
return;
}
CGRect hostRect = CGRectMake(0, bottomY, width, bottomH);
CGRect winRect = [window convertRect:hostRect fromView:host];
if (!s_pairingWindowBottomMaskLayer) {
s_pairingWindowBottomMaskLayer = [[CALayer alloc] init];
s_pairingWindowBottomMaskLayer.backgroundColor = amneziaPairingBottomChromeOpaqueColor().CGColor;
}
s_pairingWindowBottomMaskLayer.frame = winRect;
s_pairingWindowBottomMaskLayer.zPosition = -500.f;
CALayer *preview = amneziaFindVideoPreviewLayerInWindow(window);
if (preview) {
[window.layer insertSublayer:s_pairingWindowBottomMaskLayer above:preview];
} else if (s_pairingWindowBottomMaskLayer.superlayer) {
[s_pairingWindowBottomMaskLayer removeFromSuperlayer];
}
}
static void amneziaRemovePairingSafeAreaDimStrips(void)
{
if (s_pairingSafeDimOrientationToken) {
[[NSNotificationCenter defaultCenter] removeObserver:s_pairingSafeDimOrientationToken];
s_pairingSafeDimOrientationToken = nil;
}
[s_pairingSafeTopDim removeFromSuperview];
[s_pairingSafeBottomDim removeFromSuperview];
s_pairingSafeTopDim = nil;
s_pairingSafeBottomDim = nil;
s_pairingDimQtRoot = nil;
amneziaRemovePairingWindowBottomMaskLayer();
}
static void amneziaLayoutPairingSafeAreaDimStrips(void)
{
if (!s_pairingSafeTopDim || !s_pairingSafeTopDim.superview) {
return;
}
UIWindow *w = s_pairingSafeTopDim.window;
UIView *host = s_pairingSafeTopDim.superview;
if (!w || !host) {
return;
}
UIEdgeInsets insets = w.safeAreaInsets;
CGRect hb = host.bounds;
s_pairingSafeTopDim.frame = CGRectMake(0, 0, hb.size.width, insets.top);
CGFloat bottomY = hb.size.height - insets.bottom;
CGFloat bottomH = insets.bottom;
UIView *qt = s_pairingDimQtRoot;
CGFloat qMaxYForLog = hb.size.height;
if (qt && qt.superview) {
CGRect qInHost = [host convertRect:qt.bounds fromView:qt];
const CGFloat qMaxY = CGRectGetMaxY(qInHost);
qMaxYForLog = qMaxY;
if (qMaxY < hb.size.height - 0.5f) {
bottomY = qMaxY;
bottomH = hb.size.height - qMaxY;
} else if (s_pairingNativeBottomExtraPt > 0) {
bottomH = insets.bottom + (CGFloat)s_pairingNativeBottomExtraPt;
bottomY = hb.size.height - bottomH;
}
}
if (bottomH < 0.5f) {
bottomH = 0.f;
bottomY = hb.size.height;
} else {
/** Pull strip slightly past layout bounds — QML vs UIKit gap can leave a hairline of preview at the physical bottom. */
const CGFloat kBottomOverscanPt = 12.f;
bottomH += kBottomOverscanPt;
bottomY = hb.size.height - bottomH;
if (bottomY < insets.top + 2.f) {
bottomY = insets.top + 2.f;
bottomH = hb.size.height - bottomY;
}
/** Stack↔tab hairline: preview on UIWindow.layer can leak slightly above computed strip top vs Qt chrome. */
const CGFloat kHairlineCoverUpPt = 4.f;
bottomY -= kHairlineCoverUpPt;
bottomH += kHairlineCoverUpPt;
if (bottomY < insets.top + 2.f) {
bottomH -= (insets.top + 2.f) - bottomY;
bottomY = insets.top + 2.f;
}
}
s_pairingSafeBottomDim.frame = CGRectMake(0, bottomY, hb.size.width, bottomH);
amneziaSyncPairingWindowBottomMaskLayer(w, host, bottomY, bottomH, hb.size.width);
CGRect winStrip = [w convertRect:s_pairingSafeBottomDim.frame fromView:host];
CALayer *previewLy = amneziaFindVideoPreviewLayerInWindow(w);
CGRect previewWin = CGRectZero;
if (previewLy) {
previewWin = [w.layer convertRect:previewLy.bounds fromLayer:previewLy];
}
CGRect maskWin = s_pairingWindowBottomMaskLayer ? s_pairingWindowBottomMaskLayer.frame : CGRectZero;
CGRect qtWin = CGRectZero;
if (qt) {
qtWin = [w convertRect:qt.bounds fromView:qt];
}
NSLog(@"[PairingCamera] safeAreaDim bottom strip y=%.1f h=%.1f qtMaxY=%.1f hostH=%.1f safeBottom=%.1f extraPt=%d winMask=%d",
bottomY, bottomH, qMaxYForLog, hb.size.height, insets.bottom, s_pairingNativeBottomExtraPt,
(int)(s_pairingWindowBottomMaskLayer.superlayer != nil));
NSLog(@"[PairingCamera] geom winStrip={{%.1f,%.1f},{%.1f,%.1f}} qtWin={{%.1f,%.1f},{%.1f,%.1f}} previewWin={{%.1f,%.1f},{%.1f,%.1f}} "
@"maskWin={{%.1f,%.1f},{%.1f,%.1f}} dyStripTopMinusPreviewTop=%.1f dyStripTopMinusQtMaxY=%.1f",
winStrip.origin.x, winStrip.origin.y, winStrip.size.width, winStrip.size.height, qtWin.origin.x, qtWin.origin.y,
qtWin.size.width, qtWin.size.height, previewWin.origin.x, previewWin.origin.y, previewWin.size.width,
previewWin.size.height, maskWin.origin.x, maskWin.origin.y, maskWin.size.width, maskWin.size.height,
CGRectGetMinY(winStrip) - CGRectGetMinY(previewWin), CGRectGetMinY(winStrip) - CGRectGetMaxY(qtWin));
}
static void amneziaInstallPairingSafeAreaDimStrips(UIWindow *window, UIView *qtRootView)
{
amneziaRemovePairingSafeAreaDimStrips();
if (!window || !qtRootView) {
return;
}
UIView *host = qtRootView.superview ?: window;
if (!host) {
return;
}
UIEdgeInsets insets = window.safeAreaInsets;
CGRect hb = host.bounds;
UIColor *dim = [UIColor colorWithWhite:0 alpha:0.55f];
UIColor *bottomChromeOpaque = amneziaPairingBottomChromeOpaqueColor();
s_pairingSafeTopDim = [[UIView alloc] initWithFrame:CGRectMake(0, 0, hb.size.width, insets.top)];
s_pairingSafeTopDim.backgroundColor = dim;
s_pairingSafeTopDim.opaque = NO;
s_pairingSafeTopDim.layer.opaque = NO;
s_pairingSafeTopDim.userInteractionEnabled = NO;
s_pairingSafeTopDim.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleBottomMargin;
s_pairingSafeBottomDim = [[UIView alloc] initWithFrame:CGRectMake(0, hb.size.height - insets.bottom, hb.size.width, insets.bottom)];
s_pairingSafeBottomDim.backgroundColor = bottomChromeOpaque;
s_pairingSafeBottomDim.opaque = YES;
s_pairingSafeBottomDim.layer.opaque = YES;
s_pairingSafeBottomDim.userInteractionEnabled = NO;
s_pairingSafeBottomDim.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin;
s_pairingDimQtRoot = qtRootView;
[host insertSubview:s_pairingSafeTopDim belowSubview:qtRootView];
[host insertSubview:s_pairingSafeBottomDim belowSubview:qtRootView];
NSLog(@"[PairingCamera] safeAreaDim host=%@ top=%.1f bottomInset=%.1f qtFrame=%@ hostBounds=%@ window=%@",
NSStringFromClass(host.class), insets.top, insets.bottom, NSStringFromCGRect(qtRootView.frame), NSStringFromCGRect(hb),
NSStringFromCGRect(window.bounds));
s_pairingSafeDimOrientationToken = [[NSNotificationCenter defaultCenter]
addObserverForName:UIDeviceOrientationDidChangeNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(__unused NSNotification *note) {
dispatch_async(dispatch_get_main_queue(), ^{
amneziaLayoutPairingSafeAreaDimStrips();
});
}];
}
static void amneziaApplyUnderlayTransparencyToView(UIView *view, BOOL transparent, NSUInteger depth, NSUInteger maxDepth)
{
if (!view || depth > maxDepth) {
return;
}
if (transparent) {
view.opaque = NO;
view.backgroundColor = [UIColor clearColor];
view.layer.opaque = NO;
} else {
view.opaque = YES;
view.backgroundColor = [UIColor blackColor];
view.layer.opaque = YES;
}
if (depth < maxDepth) {
for (UIView *child in view.subviews) {
amneziaApplyUnderlayTransparencyToView(child, transparent, depth + 1, maxDepth);
}
}
}
bool amneziaIosPairingCameraAccessGranted()
{
const AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
@@ -35,3 +295,64 @@ void amneziaIosOpenApplicationSettings()
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}
}
void amneziaIosPairingRelayoutChromeIfNeeded(void)
{
if (!s_pairingSafeBottomDim || !s_pairingSafeBottomDim.superview) {
return;
}
amneziaLayoutPairingSafeAreaDimStrips();
}
void amneziaIosSetPairingEmbeddedCameraNativeBottomExtraPt(int extraPt)
{
const int v = extraPt < 0 ? 0 : extraPt;
if (s_pairingNativeBottomExtraPt == v) {
return;
}
s_pairingNativeBottomExtraPt = v;
void (^relayout)(void) = ^{
amneziaLayoutPairingSafeAreaDimStrips();
};
if ([NSThread isMainThread]) {
relayout();
} else {
dispatch_async(dispatch_get_main_queue(), relayout);
}
}
void amneziaIosApplyEmbeddedCameraUnderlayToQtView(bool enable)
{
void (^work)(void) = ^{
UIViewController *vc = amneziaKeyWindowViewController();
if (!vc || !vc.view) {
NSLog(@"[PairingCamera] amneziaIosApplyEmbeddedCameraUnderlayToQtView: no root VC (enable=%d)", (int)enable);
return;
}
UIView *root = vc.view;
UIWindow *win = root.window;
if (!enable) {
s_pairingNativeBottomExtraPt = 0;
amneziaRemovePairingSafeAreaDimStrips();
}
if (enable) {
vc.edgesForExtendedLayout = UIRectEdgeAll;
if (@available(iOS 11.0, *)) {
vc.extendedLayoutIncludesOpaqueBars = YES;
}
}
const NSUInteger kMaxDepth = 6;
amneziaApplyUnderlayTransparencyToView(root, enable ? YES : NO, 0, kMaxDepth);
if (enable && win) {
amneziaInstallPairingSafeAreaDimStrips(win, root);
amneziaLayoutPairingSafeAreaDimStrips();
}
NSLog(@"[PairingCamera] Qt view underlay transparency %@ subviews=%lu",
enable ? @"ON" : @"OFF", (unsigned long)root.subviews.count);
};
if ([NSThread isMainThread]) {
work();
} else {
dispatch_sync(dispatch_get_main_queue(), work);
}
}
@@ -11,3 +11,9 @@ void amneziaIosRequestPairingCameraAccess(const std::function<void(bool)> &onDon
}
void amneziaIosOpenApplicationSettings() {}
void amneziaIosApplyEmbeddedCameraUnderlayToQtView(bool) {}
void amneziaIosSetPairingEmbeddedCameraNativeBottomExtraPt(int) {}
void amneziaIosPairingRelayoutChromeIfNeeded(void) {}
@@ -166,7 +166,9 @@ bool PairingUiController::isPairingCameraAccessGranted() const
#if defined(Q_OS_ANDROID)
return AndroidController::instance()->isCameraPermissionGranted();
#elif defined(Q_OS_IOS)
return amneziaIosPairingCameraAccessGranted();
const bool ok = amneziaIosPairingCameraAccessGranted();
qInfo() << "[PairingUi] iOS isPairingCameraAccessGranted =" << ok;
return ok;
#else
return true;
#endif
@@ -178,6 +180,7 @@ void PairingUiController::requestPairingCameraAccess()
AndroidController::instance()->requestCameraPermissionForQrPairing();
#elif defined(Q_OS_IOS)
amneziaIosRequestPairingCameraAccess([this](bool granted) {
qInfo() << "[PairingUi] iOS requestPairingCameraAccess callback granted =" << granted;
QMetaObject::invokeMethod(
this, [this, granted]() { emit pairingCameraAccessFinished(granted); }, Qt::QueuedConnection);
});
@@ -195,6 +198,44 @@ void PairingUiController::openPairingCameraAppSettings()
#endif
}
void PairingUiController::setEmbeddedPairingQrCameraActive(bool active)
{
if (m_embeddedPairingQrCameraActive == active) {
return;
}
m_embeddedPairingQrCameraActive = active;
#if defined(Q_OS_IOS)
qInfo() << "[PairingUi] iOS embeddedPairingQrCameraActive ->" << active;
amneziaIosApplyEmbeddedCameraUnderlayToQtView(active);
#endif
#if defined(Q_OS_ANDROID)
if (active) {
AndroidController::instance()->startPairingQrEmbeddedCamera();
} else {
AndroidController::instance()->stopPairingQrEmbeddedCamera();
}
#endif
emit embeddedPairingQrCameraActiveChanged();
}
void PairingUiController::syncIosEmbeddedPairingQrNativeBottomExtra(int extraPt)
{
#if defined(Q_OS_IOS)
amneziaIosSetPairingEmbeddedCameraNativeBottomExtraPt(extraPt);
#else
Q_UNUSED(extraPt);
#endif
}
void PairingUiController::setPairingQrTorchEnabled(bool enabled)
{
#if defined(Q_OS_ANDROID)
AndroidController::instance()->setPairingQrEmbeddedTorch(enabled);
#else
Q_UNUSED(enabled);
#endif
}
bool PairingUiController::applyScannedTextAsPairingUuid(const QString &raw)
{
const QString t = raw.trimmed();
@@ -522,6 +563,8 @@ void PairingUiController::cancelAllPairingActivity()
}
cancelTvQrSession();
setEmbeddedPairingQrCameraActive(false);
}
void PairingUiController::submitPhonePairing(const QString &qrUuid, int serverIndex)
@@ -35,6 +35,9 @@ class PairingUiController : public QObject
lastSuccessfulPhonePairingDisplayNameChanged)
/** TV flow for QA: 0=idle, 1=waitingForPeer, 2=error, 3=sessionExpired */
Q_PROPERTY(int tvPairingUiPhase READ tvPairingUiPhase NOTIFY tvPairingUiPhaseChanged)
/** Full-screen pairing QR camera under QML (mobile); drives translucent main window. */
Q_PROPERTY(bool embeddedPairingQrCameraActive READ embeddedPairingQrCameraActive WRITE setEmbeddedPairingQrCameraActive NOTIFY
embeddedPairingQrCameraActiveChanged)
public:
PairingUiController(PairingController *pairingController, ServersController *serversController,
@@ -55,6 +58,10 @@ public:
void setPendingPhonePairingUuid(const QString &uuid);
QString lastSuccessfulPhonePairingDisplayName() const { return m_lastSuccessfulPhonePairingDisplayName; }
int tvPairingUiPhase() const { return m_tvPairingUiPhase; }
bool embeddedPairingQrCameraActive() const { return m_embeddedPairingQrCameraActive; }
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);
#if defined(Q_OS_ANDROID)
static bool tryConsumeAndroidQrScan(const QString &code);
@@ -80,6 +87,8 @@ public slots:
Q_INVOKABLE void requestPairingCameraAccess();
/** Open system settings for this app (camera can be enabled there). No-op on desktop. */
Q_INVOKABLE void openPairingCameraAppSettings();
/** Android: torch for embedded pairing camera. No-op elsewhere. */
Q_INVOKABLE void setPairingQrTorchEnabled(bool enabled);
/** If \a raw contains a session UUID (not vpn://), emits pairingUuidFromScan and returns true. */
bool applyScannedTextAsPairingUuid(const QString &raw);
@@ -106,6 +115,7 @@ signals:
void tvPairingUiPhaseChanged();
/** After requestPairingCameraAccess(): true if OS granted camera access. */
void pairingCameraAccessFinished(bool granted);
void embeddedPairingQrCameraActiveChanged();
private:
void setTvBusy(bool busy);
@@ -138,6 +148,8 @@ private:
QPointer<QFutureWatcher<QPair<amnezia::ErrorCode, QByteArray>>> m_phoneWatcher;
QPointer<QNetworkReply> m_phoneNetworkReply;
quint64 m_phoneSessionGeneration { 0 };
bool m_embeddedPairingQrCameraActive = false;
};
#endif // PAIRINGUICONTROLLER_H
@@ -4,6 +4,9 @@ import QtQuick.Controls
StackView {
id: root
/** Allow pages (e.g. pairing QR dim) to paint outside stack bounds into tab bar / safe areas. */
clip: false
pushEnter: Transition {
PropertyAnimation {
property: "opacity"
@@ -53,7 +53,10 @@ TabButton {
background: Rectangle {
id: background
anchors.fill: parent
color: AmneziaStyle.color.transparent
/** iOS embedded QR camera: clear window + preview under Qt; transparent tab cells show the camera. */
color: (PairingUiController.embeddedPairingQrCameraActive && Qt.platform.os !== "android")
? AmneziaStyle.color.onyxBlack
: AmneziaStyle.color.transparent
radius: 10
border.color: root.activeFocus ? root.borderFocusedColor : AmneziaStyle.color.transparent
@@ -1,4 +1,5 @@
import QtQuick
import QtQuick.Window
import QtQuick.Controls
import QtQuick.Layouts
@@ -15,33 +16,166 @@ import "../Components"
PageType {
id: root
/** Loud dim colors when true (red/blue/cyan/orange regions). Sync with PageStart.pairingQrChromeDebug. */
property bool pairingQrChromeDebug: false
/** iOS (and any non-Android mobile): native QRCodeReader; Qt may not always report os === "ios". */
readonly property bool useIosStyleNativeQrReader: 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
clip: !extendScanDimToScreenEdges
/** QQuickWindow (not Item); do not type as Item — breaks binding on Qt 6. */
readonly property var appWindow: Window.window
/** Pixels of window above this page (status bar / safe area gap). */
readonly property real scanDimBleedTop: {
if (!extendScanDimToScreenEdges || !appWindow || !appWindow.contentItem)
return 0
let bleed = Math.max(0, root.mapToItem(appWindow.contentItem, 0, 0).y)
if (bleed < 2 && root.useIosStyleNativeQrReader)
bleed = Math.max(bleed, PageController.safeAreaTopMargin)
return bleed
}
/** Pixels of window below this page (tab bar + home indicator). */
readonly property real scanDimBleedBottom: {
if (!extendScanDimToScreenEdges || !appWindow || !appWindow.contentItem)
return 0
const o = root.mapToItem(appWindow.contentItem, 0, root.height)
let bleed = Math.max(0, appWindow.height - o.y)
const slack = Math.max(0, appWindow.height - root.height - scanDimBleedTop)
if (bleed < slack - 1)
bleed = Math.max(bleed, slack)
if (bleed < 2 && root.useIosStyleNativeQrReader)
bleed = Math.max(bleed, PageController.safeAreaBottomMargin + 72)
return bleed
}
/**
* Bottom bleed for dimLayer only. On iOS embedded native QR, keep dim inside the page — semi-opaque dim
* extended into the tab stack seam composites badly with opaque tab chrome (persistent hairline).
* Native bottom mask still uses scanDimBleedBottom via pushIosNativeBottomBleedSync().
*/
readonly property real scanDimBleedBottomForDimLayer: (root.useIosStyleNativeQrReader
&& PairingUiController.embeddedPairingQrCameraActive) ? 0 : scanDimBleedBottom
/** iOS: extend UIKit bottom dim under QML tab bar (see iosPairingCameraAccess + PairingUiController). */
function pushIosNativeBottomBleedSync() {
if (!root.useIosStyleNativeQrReader || !PairingUiController.embeddedPairingQrCameraActive) {
return
}
PairingUiController.syncIosEmbeddedPairingQrNativeBottomExtra(Math.max(0, Math.round(root.scanDimBleedBottom)))
}
onScanDimBleedBottomChanged: {
if (PairingUiController.embeddedPairingQrCameraActive && root.useIosStyleNativeQrReader) {
Qt.callLater(root.pushIosNativeBottomBleedSync)
}
}
Timer {
id: pairingScanLayoutLogTimer
interval: 50
repeat: false
onTriggered: root.logPairingScanLayout("timer")
}
Connections {
target: PairingUiController
function onEmbeddedPairingQrCameraActiveChanged() {
if (PairingUiController.embeddedPairingQrCameraActive) {
pairingScanLayoutLogTimer.restart()
Qt.callLater(root.pushIosNativeBottomBleedSync)
}
}
}
function logPairingScanLayout(tag) {
const w = Window.window
const ci = w && w.contentItem ? w.contentItem : null
let m00 = null
let m0h = null
let scanBot = null
let dimTL = null
let dimBR = null
let dimHoleB = null
if (ci) {
m00 = root.mapToItem(ci, 0, 0)
m0h = root.mapToItem(ci, 0, root.height)
scanBot = scanStep.mapToItem(ci, 0, scanStep.height)
dimTL = dimLayer.mapToItem(ci, 0, 0)
dimBR = dimLayer.mapToItem(ci, dimLayer.width, dimLayer.height)
dimHoleB = dimLayer.holeBottom
}
console.warn("[PairingQrLayout]", tag,
"extend=", extendScanDimToScreenEdges,
"clip=", clip,
"root=", root.width, "x", root.height,
"win=", w ? w.width : -1, "x", w ? w.height : -1,
"contentItem=", ci ? ci.width : -1, "x", ci ? ci.height : -1,
"bleedT/B=", scanDimBleedTop, scanDimBleedBottom,
"dimLayerBleedB=", root.scanDimBleedBottomForDimLayer,
"safeT/B=", PageController.safeAreaTopMargin, PageController.safeAreaBottomMargin,
"map00=", m00 ? m00.x + "," + m00.y : "n/a",
"map0h=", m0h ? m0h.x + "," + m0h.y : "n/a",
"ci.scanStepBot=", scanBot ? scanBot.x.toFixed(1) + "," + scanBot.y.toFixed(1) : "n/a",
"ci.dimTL/BR=", dimTL ? dimTL.x.toFixed(1) + "," + dimTL.y.toFixed(1) : "n/a",
dimBR ? dimBR.x.toFixed(1) + "," + dimBR.y.toFixed(1) : "n/a",
"dimHoleB=", dimHoleB !== null ? dimHoleB.toFixed(1) : "n/a",
"win.screen=", w && w.screen ? w.screen.width + "x" + w.screen.height : "n/a",
"dimLayer wh=", dimLayer.width, "x", dimLayer.height)
}
/** 0 = scan QR, 1 = confirm before sending subscription */
property int pairingWizardStep: 0
/** True after optimistic close: keep request running in background while page is closing. */
property bool keepPhonePairingInBackgroundOnClose: false
property bool pairingCameraOpen: false
property int lastInvalidPairingQrToastClockMs: 0
/** iOS may deliver many QR frames; guard duplicate step transitions. */
property bool addDeviceConfirmNavigationScheduled: false
/** Mobile: waiting for camera permission before starting scan UI / Android scanner. */
property bool awaitingCameraPermissionForScan: false
/** After denial on scan screen: user may enable camera in settings. */
property bool waitingSettingsReturnForScan: false
property bool torchOn: false
Timer {
id: pairingCameraKickTimer
interval: 180
interval: 220
repeat: false
onTriggered: root.restartPairingIosCamera()
}
function startPairingScanAfterPermission() {
function stopMobileScanner() {
torchOn = false
if (Qt.platform.os === "android") {
PairingUiController.openPairingQrScanner()
} else if (Qt.platform.os === "ios") {
root.pairingCameraOpen = true
PairingUiController.setPairingQrTorchEnabled(false)
} else if (root.useIosStyleNativeQrReader) {
pairingQrReader.setTorchEnabled(false)
}
pairingQrReader.stopReading()
PairingUiController.embeddedPairingQrCameraActive = false
}
function startMobileScanner() {
if (!GC.isMobile()) {
return
}
if (!PairingUiController.isPairingCameraAccessGranted()) {
awaitingCameraPermissionForScan = true
PairingUiController.requestPairingCameraAccess()
return
}
PairingUiController.embeddedPairingQrCameraActive = true
if (root.useIosStyleNativeQrReader) {
// Session must start here, not only after pairingCameraKickTimer (220ms), otherwise
// torch/scan run before startReading and native layer never attaches.
restartPairingIosCamera()
pairingCameraKickTimer.restart()
}
}
function startPairingScanAfterPermission() {
startMobileScanner()
}
function showScanCameraDeniedDrawer() {
@@ -59,52 +193,53 @@ PageType {
}
function tryResumeScanAfterCameraSettings() {
if (!root.waitingSettingsReturnForScan || !root.visible || root.pairingWizardStep !== 0) {
if (!waitingSettingsReturnForScan || !visible || pairingWizardStep !== 0) {
return
}
if (PairingUiController.isPairingCameraAccessGranted()) {
root.waitingSettingsReturnForScan = false
root.startPairingScanAfterPermission()
waitingSettingsReturnForScan = false
startMobileScanner()
}
}
function restartPairingIosCamera() {
if (Qt.platform.os !== "ios" || !root.pairingCameraOpen) {
if (!root.useIosStyleNativeQrReader || pairingWizardStep !== 0) {
return
}
if (cameraSlot.width < 32 || cameraSlot.height < 32) {
console.info("[PairingQr] cameraSlot too small wxh=", cameraSlot.width, cameraSlot.height, "retry")
pairingCameraKickTimer.restart()
return
}
var p = cameraSlot.mapToItem(root, 0, 0)
console.info("[PairingQr] start preview frame", p.x, p.y, cameraSlot.width, cameraSlot.height)
// Never gate on root.visible here: under StackView the active page often has
// visible === false while it is on screen, so startReading never ran (no session, no torch).
pairingQrReader.stopReading()
pairingQrReader.setCameraSize(Qt.rect(Math.round(p.x), Math.round(p.y), Math.round(cameraSlot.width), Math.round(cameraSlot.height)))
pairingQrReader.startReading()
}
Component.onDestruction: {
if (!root.keepPhonePairingInBackgroundOnClose && !PairingUiController.phonePairingBusy) {
if (!keepPhonePairingInBackgroundOnClose && !PairingUiController.phonePairingBusy) {
PairingUiController.cancelAllPairingActivity()
}
}
Connections {
target: root
function onVisibleChanged() {
if (root.visible) {
root.addDeviceConfirmNavigationScheduled = false
} else {
pairingCameraKickTimer.stop()
pairingQrReader.stopReading()
root.pairingCameraOpen = false
root.pairingWizardStep = 0
root.waitingSettingsReturnForScan = false
if (!root.keepPhonePairingInBackgroundOnClose && !PairingUiController.phonePairingBusy) {
PairingUiController.cancelAllPairingActivity()
}
onVisibleChanged: {
if (visible) {
addDeviceConfirmNavigationScheduled = false
if (pairingWizardStep === 0) {
Qt.callLater(startMobileScanner)
}
} else {
pairingCameraKickTimer.stop()
stopMobileScanner()
pairingWizardStep = 0
waitingSettingsReturnForScan = false
if (!keepPhonePairingInBackgroundOnClose && !PairingUiController.phonePairingBusy) {
PairingUiController.cancelAllPairingActivity()
}
}
}
onPairingWizardStepChanged: {
if (pairingWizardStep !== 0) {
stopMobileScanner()
} else {
Qt.callLater(startMobileScanner)
}
}
@@ -129,223 +264,378 @@ PageType {
}
}
Connections {
target: root
function onPairingCameraOpenChanged() {
if (!root.pairingCameraOpen) {
pairingCameraKickTimer.stop()
pairingQrReader.stopReading()
return
}
if (Qt.platform.os === "ios") {
pairingCameraKickTimer.restart()
}
}
}
Connections {
target: cameraSlot
enabled: Qt.platform.os === "ios" && root.pairingCameraOpen
function onWidthChanged() {
pairingCameraKickTimer.restart()
}
function onHeightChanged() {
pairingCameraKickTimer.restart()
}
}
FlickableType {
Item {
anchors.fill: parent
contentHeight: layout.implicitHeight
interactive: contentHeight > height
ColumnLayout {
id: layout
width: root.width
spacing: 0
Item {
id: scanStep
anchors.fill: parent
visible: pairingWizardStep === 0
BackButtonType {
Layout.topMargin: 20 + PageController.safeAreaTopMargin
backButtonFunction: function() {
if (root.pairingWizardStep === 1) {
PairingUiController.cancelAllPairingActivity()
root.pairingWizardStep = 0
root.addDeviceConfirmNavigationScheduled = false
} else {
readonly property real sqSz: Math.floor(Math.min(width, height) * 0.72)
readonly property real sqX: (width - sqSz) / 2
readonly property real sqY: (height - sqSz) / 2 - height * 0.06
readonly property real dimAlpha: 0.55
readonly property color dimTopDebug: "#aa3333"
readonly property color dimBottomDebug: "#33aaff"
readonly property color dimLeftDebug: "#3333ff"
readonly property color dimRightDebug: "#ffaa33"
readonly property int bracketThick: 5
readonly property int bracketLen: Math.max(28, Math.floor(sqSz * 0.13))
readonly property real bracketRadius: bracketThick * 0.5
Rectangle {
anchors.fill: parent
color: AmneziaStyle.color.midnightBlack
visible: !GC.isMobile()
}
Label {
anchors.centerIn: parent
width: parent.width - 48
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
visible: !GC.isMobile()
color: AmneziaStyle.color.mutedGray
font.pixelSize: 15
text: qsTr("QR pairing is available in the mobile app.")
}
Item {
id: dimLayer
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: -root.scanDimBleedTop
anchors.bottom: parent.bottom
anchors.bottomMargin: -root.scanDimBleedBottomForDimLayer
visible: GC.isMobile()
z: 0
readonly property real holeTop: root.scanDimBleedTop + scanStep.sqY
readonly property real holeBottom: holeTop + scanStep.sqSz
Rectangle {
x: 0
y: 0
width: parent.width
height: Math.max(0, dimLayer.holeTop)
color: root.pairingQrChromeDebug ? scanStep.dimTopDebug : Qt.rgba(0, 0, 0, scanStep.dimAlpha)
}
Rectangle {
x: 0
y: dimLayer.holeBottom
width: parent.width
height: Math.max(0, dimLayer.height - dimLayer.holeBottom)
color: root.pairingQrChromeDebug ? scanStep.dimBottomDebug : Qt.rgba(0, 0, 0, scanStep.dimAlpha)
}
Rectangle {
x: 0
y: dimLayer.holeTop
width: Math.max(0, scanStep.sqX)
height: scanStep.sqSz
color: root.pairingQrChromeDebug ? scanStep.dimLeftDebug : Qt.rgba(0, 0, 0, scanStep.dimAlpha)
}
Rectangle {
x: scanStep.sqX + scanStep.sqSz
y: dimLayer.holeTop
width: Math.max(0, parent.width - (scanStep.sqX + scanStep.sqSz))
height: scanStep.sqSz
color: root.pairingQrChromeDebug ? scanStep.dimRightDebug : Qt.rgba(0, 0, 0, scanStep.dimAlpha)
}
}
/** Same onyx as tab bar: bridges dim/camera to TabBar sibling so the seam is not only TabBar.background overlap. */
Rectangle {
id: pairingIosStackBottomChromeBridge
objectName: "pairingIosStackBottomChromeBridge"
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 22
visible: GC.isMobile() && root.useIosStyleNativeQrReader && PairingUiController.embeddedPairingQrCameraActive
color: root.pairingQrChromeDebug ? "#8844ff" : AmneziaStyle.color.onyxBlack
z: 1
}
Item {
x: scanStep.sqX
y: scanStep.sqY
width: scanStep.sqSz
height: scanStep.sqSz
visible: GC.isMobile()
Rectangle {
x: 0
y: 0
width: scanStep.bracketLen
height: scanStep.bracketThick
radius: scanStep.bracketRadius
color: AmneziaStyle.color.paleGray
}
Rectangle {
x: 0
y: 0
width: scanStep.bracketThick
height: scanStep.bracketLen
radius: scanStep.bracketRadius
color: AmneziaStyle.color.paleGray
}
Rectangle {
x: scanStep.sqSz - scanStep.bracketLen
y: 0
width: scanStep.bracketLen
height: scanStep.bracketThick
radius: scanStep.bracketRadius
color: AmneziaStyle.color.paleGray
}
Rectangle {
x: scanStep.sqSz - scanStep.bracketThick
y: 0
width: scanStep.bracketThick
height: scanStep.bracketLen
radius: scanStep.bracketRadius
color: AmneziaStyle.color.paleGray
}
Rectangle {
x: 0
y: scanStep.sqSz - scanStep.bracketThick
width: scanStep.bracketLen
height: scanStep.bracketThick
radius: scanStep.bracketRadius
color: AmneziaStyle.color.paleGray
}
Rectangle {
x: 0
y: scanStep.sqSz - scanStep.bracketLen
width: scanStep.bracketThick
height: scanStep.bracketLen
radius: scanStep.bracketRadius
color: AmneziaStyle.color.paleGray
}
Rectangle {
x: scanStep.sqSz - scanStep.bracketLen
y: scanStep.sqSz - scanStep.bracketThick
width: scanStep.bracketLen
height: scanStep.bracketThick
radius: scanStep.bracketRadius
color: AmneziaStyle.color.paleGray
}
Rectangle {
x: scanStep.sqSz - scanStep.bracketThick
y: scanStep.sqSz - scanStep.bracketLen
width: scanStep.bracketThick
height: scanStep.bracketLen
radius: scanStep.bracketRadius
color: AmneziaStyle.color.paleGray
}
}
Column {
id: headerBlock
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: 8 + PageController.safeAreaTopMargin
spacing: 10
z: 2
BackButtonType {
width: parent.width
backButtonFunction: function() {
PageController.closePage()
}
}
Label {
width: parent.width - 32
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Add device via QR")
font.pixelSize: 28
font.bold: true
color: AmneziaStyle.color.paleGray
wrapMode: Text.Wrap
}
ParagraphTextType {
width: parent.width - 32
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Scan the session QR shown on the device you want to add. You will confirm before the subscription is sent.")
wrapMode: Text.Wrap
}
}
StackLayout {
id: stepStack
Item {
z: 2
width: 56
height: 56
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottomMargin: 28 + PageController.safeAreaBottomMargin
visible: GC.isMobile()
Rectangle {
anchors.fill: parent
radius: 28
color: Qt.rgba(1, 1, 1, root.torchOn ? 0.42 : 0.22)
border.width: root.torchOn ? 2 : 0
border.color: AmneziaStyle.color.goldenApricot
}
Text {
anchors.centerIn: parent
text: "🔦"
font.pixelSize: 26
}
MouseArea {
anchors.fill: parent
onClicked: {
root.torchOn = !root.torchOn
if (Qt.platform.os === "android") {
PairingUiController.setPairingQrTorchEnabled(root.torchOn)
} else if (root.useIosStyleNativeQrReader) {
pairingQrReader.setTorchEnabled(root.torchOn)
}
}
}
}
ParagraphTextType {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottomMargin: 100 + PageController.safeAreaBottomMargin
anchors.leftMargin: 16
anchors.rightMargin: 16
visible: PairingUiController.phoneStatusMessage.length > 0
text: PairingUiController.phoneStatusMessage
wrapMode: Text.Wrap
z: 2
}
Item {
width: 0
height: 0
visible: false
QRCodeReader {
id: pairingQrReader
// Same idea as PageSetupWizardQrReader: ensure startReading runs even if
// StackView/onVisible timing skips startMobileScanner once.
Component.onCompleted: {
if (!root.useIosStyleNativeQrReader || root.pairingWizardStep !== 0) {
return
}
Qt.callLater(function () {
if (root.pairingWizardStep !== 0 || !PairingUiController.isPairingCameraAccessGranted()) {
return
}
PairingUiController.embeddedPairingQrCameraActive = true
pairingQrReader.stopReading()
pairingQrReader.startReading()
})
}
onCodeReaded: function(code) {
if (addDeviceConfirmNavigationScheduled) {
return
}
if (PairingUiController.applyScannedTextAsPairingUuid(code)) {
addDeviceConfirmNavigationScheduled = true
stopMobileScanner()
} else {
const now = new Date().getTime()
if (now - lastInvalidPairingQrToastClockMs >= 2200) {
lastInvalidPairingQrToastClockMs = now
PageController.showNotificationMessage(
qsTr("This QR code is not a pairing session. Show the code from the other devices “receive config” screen."))
}
}
}
}
}
}
ColumnLayout {
id: confirmStep
anchors.fill: parent
anchors.leftMargin: 0
anchors.rightMargin: 0
visible: pairingWizardStep === 1
spacing: 16
BackButtonType {
Layout.topMargin: 20 + PageController.safeAreaTopMargin
Layout.leftMargin: 0
backButtonFunction: function() {
PairingUiController.cancelAllPairingActivity()
pairingWizardStep = 0
addDeviceConfirmNavigationScheduled = false
Qt.callLater(startMobileScanner)
}
}
Label {
Layout.fillWidth: true
currentIndex: root.pairingWizardStep
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
text: qsTr("Add a new device to the subscription?")
font.pixelSize: 28
font.bold: true
color: AmneziaStyle.color.paleGray
wrapMode: Text.Wrap
}
ColumnLayout {
Layout.fillWidth: true
spacing: 8
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 16
Label {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
text: qsTr("Add device via QR")
font.pixelSize: 28
font.bold: true
color: AmneziaStyle.color.paleGray
wrapMode: Text.Wrap
}
text: qsTr("Add Device")
defaultColor: AmneziaStyle.color.paleGray
hoveredColor: AmneziaStyle.color.lightGray
pressedColor: AmneziaStyle.color.mutedGray
textColor: AmneziaStyle.color.midnightBlack
ParagraphTextType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
text: qsTr("Scan the session QR shown on the device you want to add. You will confirm before the subscription is sent.")
wrapMode: Text.Wrap
}
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 16
visible: Qt.platform.os === "android" || Qt.platform.os === "ios"
text: {
if (Qt.platform.os === "ios" && root.pairingCameraOpen) {
return qsTr("Hide camera")
}
return qsTr("Scan QR code")
}
enabled: !PairingUiController.phonePairingBusy
clickedFunc: function() {
if (!PairingUiController.isPairingCameraAccessGranted()) {
root.awaitingCameraPermissionForScan = true
PairingUiController.requestPairingCameraAccess()
return
}
if (Qt.platform.os === "android") {
PairingUiController.openPairingQrScanner()
} else {
root.pairingCameraOpen = !root.pairingCameraOpen
}
}
}
Item {
id: cameraSlot
Layout.fillWidth: true
Layout.preferredHeight: (root.pairingCameraOpen && Qt.platform.os === "ios") ? 220 : 0
Layout.leftMargin: 16
Layout.rightMargin: 16
visible: Layout.preferredHeight > 0
clip: true
QRCodeReader {
id: pairingQrReader
onCodeReaded: function(code) {
if (root.addDeviceConfirmNavigationScheduled) {
return
}
if (PairingUiController.applyScannedTextAsPairingUuid(code)) {
root.addDeviceConfirmNavigationScheduled = true
pairingQrReader.stopReading()
root.pairingCameraOpen = false
} else {
const now = new Date().getTime()
if (now - root.lastInvalidPairingQrToastClockMs >= 2200) {
root.lastInvalidPairingQrToastClockMs = now
PageController.showNotificationMessage(
qsTr("This QR code is not a pairing session. Show the code from the other devices “receive config” screen."))
}
}
}
}
onVisibleChanged: {
if (!visible) {
pairingQrReader.stopReading()
return
}
if (Qt.platform.os === "ios") {
pairingCameraKickTimer.restart()
}
}
}
ParagraphTextType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.bottomMargin: 24 + PageController.safeAreaBottomMargin
visible: root.pairingWizardStep === 0 && PairingUiController.phoneStatusMessage.length > 0
text: PairingUiController.phoneStatusMessage
wrapMode: Text.Wrap
}
clickedFunc: function() {
keepPhonePairingInBackgroundOnClose = true
PairingUiController.submitPhonePairing(PairingUiController.pendingPhonePairingUuid,
ServersUiController.getProcessedServerIndex())
Qt.callLater(function() {
PageController.closePage()
})
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 16
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Label {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
text: qsTr("Add a new device to the subscription?")
font.pixelSize: 28
font.bold: true
color: AmneziaStyle.color.paleGray
wrapMode: Text.Wrap
}
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 16
text: qsTr("Add Device")
defaultColor: AmneziaStyle.color.paleGray
hoveredColor: AmneziaStyle.color.lightGray
pressedColor: AmneziaStyle.color.mutedGray
textColor: AmneziaStyle.color.midnightBlack
clickedFunc: function() {
root.keepPhonePairingInBackgroundOnClose = true
PairingUiController.submitPhonePairing(PairingUiController.pendingPhonePairingUuid,
ServersUiController.getProcessedServerIndex())
Qt.callLater(function() {
PageController.closePage()
})
}
}
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
defaultColor: AmneziaStyle.color.transparent
hoveredColor: AmneziaStyle.color.translucentWhite
pressedColor: AmneziaStyle.color.sheerWhite
textColor: AmneziaStyle.color.paleGray
borderColor: AmneziaStyle.color.paleGray
borderWidth: 1
text: qsTr("Cancel")
clickedFunc: function() {
PairingUiController.cancelAllPairingActivity()
root.pairingWizardStep = 0
root.addDeviceConfirmNavigationScheduled = false
}
}
defaultColor: AmneziaStyle.color.transparent
hoveredColor: AmneziaStyle.color.translucentWhite
pressedColor: AmneziaStyle.color.sheerWhite
textColor: AmneziaStyle.color.paleGray
borderColor: AmneziaStyle.color.paleGray
borderWidth: 1
text: qsTr("Cancel")
clickedFunc: function() {
PairingUiController.cancelAllPairingActivity()
pairingWizardStep = 0
addDeviceConfirmNavigationScheduled = false
Qt.callLater(startMobileScanner)
}
}
Item {
Layout.fillHeight: true
}
}
}
@@ -353,28 +643,27 @@ PageType {
target: PairingUiController
function onPairingCameraAccessFinished(granted) {
if (!root.awaitingCameraPermissionForScan) {
if (!awaitingCameraPermissionForScan) {
return
}
root.awaitingCameraPermissionForScan = false
awaitingCameraPermissionForScan = false
if (granted) {
root.startPairingScanAfterPermission()
startMobileScanner()
} else {
root.waitingSettingsReturnForScan = true
root.showScanCameraDeniedDrawer()
waitingSettingsReturnForScan = true
showScanCameraDeniedDrawer()
}
}
function onPairingUuidFromScan(uuid) {
if (root.addDeviceConfirmNavigationScheduled) {
if (addDeviceConfirmNavigationScheduled) {
return
}
root.addDeviceConfirmNavigationScheduled = true
pairingQrReader.stopReading()
root.pairingCameraOpen = false
addDeviceConfirmNavigationScheduled = true
stopMobileScanner()
PairingUiController.pendingPhonePairingUuid = uuid
Qt.callLater(function() {
root.pairingWizardStep = 1
pairingWizardStep = 1
})
}
}
+118 -14
View File
@@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Shapes
import QtQuick.Window
import PageEnum 1.0
import Style 1.0
@@ -18,6 +19,87 @@ PageType {
property bool isControlsDisabled: false
property bool isTabBarDisabled: false
/** Loud colors (tab bar base green, extra overlap) when true — pair with PageSettingsApiQrPairingSend.pairingQrChromeDebug. */
property bool pairingQrChromeDebug: false
/** Opaque extension of tab bar background upward (iOS embedded QR); see PairingTabChrome deltaY vs stack bottom. */
readonly property int tabBarChromeOverlapUp: (PairingUiController.embeddedPairingQrCameraActive && GC.isMobile()
&& Qt.platform.os !== "android")
? (root.pairingQrChromeDebug ? 24 : 18) : 0
/** Pull stack under tab chrome so TabBar.background overlap fully covers stack bottom pixels. */
readonly property int tabStackPairingUnderlapDown: (PairingUiController.embeddedPairingQrCameraActive && GC.isMobile()
&& Qt.platform.os !== "android") ? 8 : 0
readonly property bool pairingTabChromeLogActive: PairingUiController.embeddedPairingQrCameraActive && GC.isMobile()
&& Qt.platform.os !== "android"
function logPairingTabChromeLayout(tag) {
if (!root.pairingTabChromeLogActive) {
return
}
const w = Window.window
const ci = w && w.contentItem ? w.contentItem : null
let msg = "[PairingTabChrome] " + tag
msg += " PageStart=" + Math.round(root.width) + "x" + Math.round(root.height)
msg += " tabBar=" + Math.round(tabBar.width) + "x" + Math.round(tabBar.height) + " y=" + tabBar.y.toFixed(2)
msg += " imeBM=" + PageController.imeHeight
msg += " stack=" + Math.round(tabBarStackView.width) + "x" + Math.round(tabBarStackView.height)
msg += " overlapUp=" + tabBarChromeOverlapUp + " stackUnderlap=" + tabStackPairingUnderlapDown
msg += " tabBgRootH=" + (tabBarBackgroundRoot ? tabBarBackgroundRoot.height.toFixed(2) : "n/a")
if (ci) {
const tabOrigin = tabBar.mapToItem(ci, 0, 0)
const tabBandTop = tabBar.mapToItem(ci, 0, -tabBarChromeOverlapUp)
const stackOrigin = tabBarStackView.mapToItem(ci, 0, 0)
const stackBottomMid = tabBarStackView.mapToItem(ci, tabBarStackView.width * 0.5, tabBarStackView.height)
const bgTopLeft = tabBarBackgroundRoot.mapToItem(ci, 0, 0)
msg += " ci.tab(0,0)=" + tabOrigin.x.toFixed(1) + "," + tabOrigin.y.toFixed(1)
msg += " ci.tab(0,-ov)=" + tabBandTop.x.toFixed(1) + "," + tabBandTop.y.toFixed(1)
msg += " ci.stack(0,0)=" + stackOrigin.x.toFixed(1) + "," + stackOrigin.y.toFixed(1)
msg += " ci.stackMidBot=" + stackBottomMid.x.toFixed(1) + "," + stackBottomMid.y.toFixed(1)
msg += " ci.tabBgRoot(0,0)=" + bgTopLeft.x.toFixed(1) + "," + bgTopLeft.y.toFixed(1)
msg += " deltaY_tabBandTop_minus_stackMidBot=" + (tabBandTop.y - stackBottomMid.y).toFixed(2)
} else {
msg += " ci=missing"
}
console.warn(msg)
}
Timer {
id: pairingTabChromeLogTimer50
interval: 50
repeat: false
onTriggered: root.logPairingTabChromeLayout("t50")
}
Timer {
id: pairingTabChromeLogTimer350
interval: 350
repeat: false
onTriggered: root.logPairingTabChromeLayout("t350")
}
Connections {
target: PairingUiController
function onEmbeddedPairingQrCameraActiveChanged() {
if (PairingUiController.embeddedPairingQrCameraActive && GC.isMobile() && Qt.platform.os !== "android") {
pairingTabChromeLogTimer50.restart()
pairingTabChromeLogTimer350.restart()
}
}
}
onWidthChanged: {
if (root.pairingTabChromeLogActive) {
pairingTabChromeLogTimer50.restart()
}
}
onHeightChanged: {
if (root.pairingTabChromeLogActive) {
pairingTabChromeLogTimer50.restart()
}
}
Connections {
objectName: "pageControllerConnection"
@@ -257,6 +339,7 @@ PageType {
anchors.right: parent.right
anchors.left: parent.left
anchors.bottom: tabBar.top
anchors.bottomMargin: -root.tabStackPairingUnderlapDown
enabled: !root.isControlsDisabled
@@ -303,6 +386,8 @@ PageType {
id: tabBar
objectName: "tabBar"
clip: false
anchors.right: parent.right
anchors.left: parent.left
anchors.bottom: parent.bottom
@@ -319,24 +404,43 @@ PageType {
enabled: !root.isControlsDisabled && !root.isTabBarDisabled
background: Shape {
objectName: "backgroundShape"
background: Item {
id: tabBarBackgroundRoot
objectName: "tabBarBackgroundRoot"
width: parent.width
height: parent.height
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: parent.height + root.tabBarChromeOverlapUp
ShapePath {
startX: 0
startY: 0
/** Opaque base: Shape alone can show the window-layer camera through anti-aliased edges when the window is clear (iOS QR pairing). */
Rectangle {
anchors.fill: parent
color: root.pairingQrChromeDebug ? "#00ff66" : AmneziaStyle.color.onyxBlack
}
/** Stroke around tab row; hidden during iOS embedded QR overlap — top horizontal slateGray reads as a hairline “strip” above tabs. */
Shape {
id: tabBarChromeShape
objectName: "backgroundShape"
visible: root.tabBarChromeOverlapUp === 0
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: tabBar.height
PathLine { x: width; y: 0 }
PathLine { x: width; y: tabBar.height - 1 }
PathLine { x: 0; y: tabBar.height - 1 }
PathLine { x: 0; y: 0 }
ShapePath {
startX: 0
startY: 0
strokeWidth: 1
strokeColor: AmneziaStyle.color.slateGray
fillColor: AmneziaStyle.color.onyxBlack
PathLine { x: tabBarChromeShape.width; y: 0 }
PathLine { x: tabBarChromeShape.width; y: tabBarChromeShape.height - 1 }
PathLine { x: 0; y: tabBarChromeShape.height - 1 }
PathLine { x: 0; y: 0 }
strokeWidth: 1
strokeColor: AmneziaStyle.color.slateGray
fillColor: "transparent"
}
}
}
+3 -2
View File
@@ -16,6 +16,9 @@ Window {
id: root
objectName: "mainWindow"
readonly property bool pairingQrCameraUnderlay: PairingUiController.embeddedPairingQrCameraActive && GC.isMobile()
color: pairingQrCameraUnderlay ? "#00000000" : AmneziaStyle.color.midnightBlack
Connections {
target: Qt.application
function onStateChanged() {
@@ -61,8 +64,6 @@ Window {
maximumWidth: 600
maximumHeight: 800
color: AmneziaStyle.color.midnightBlack
onClosing: function(close) {
close.accepted = false
PageController.closeWindow()