mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-20 02:00:55 +07:00
fixed UI scanner iOS
This commit is contained in:
@@ -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 &) {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 device’s “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 device’s “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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user