mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-23 02:00:20 +07:00
add test scaner ios
This commit is contained in:
@@ -28,6 +28,7 @@ set(LIBS ${LIBS}
|
||||
|
||||
|
||||
set(HEADERS ${HEADERS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosPairingQrOverlayWindow.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.h
|
||||
@@ -45,6 +46,7 @@ set(SOURCES ${SOURCES}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosglue.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QRCodeReaderBase.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosPairingCameraAccess.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosPairingQrOverlayWindow.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.mm
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/AmneziaSceneDelegateHooks.mm
|
||||
|
||||
@@ -66,6 +66,7 @@ set(HEADERS ${HEADERS}
|
||||
${CLIENT_ROOT_DIR}/core/utils/managementServer.h
|
||||
${CLIENT_ROOT_DIR}/core/utils/constants.h
|
||||
${CLIENT_ROOT_DIR}/platforms/ios/iosPairingCameraAccess.h
|
||||
${CLIENT_ROOT_DIR}/platforms/ios/iosPairingQrOverlayWindow.h
|
||||
)
|
||||
|
||||
# Mozilla headres
|
||||
|
||||
@@ -295,14 +295,31 @@ static UIWindow *amneziaKeyWindowForQrCamera(void)
|
||||
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];
|
||||
|
||||
AVCaptureSession *session = self.captureSession;
|
||||
self.captureSession = nil;
|
||||
|
||||
/**
|
||||
* Must run stopRunning on the same serial queue as startRunning, synchronously before tearing down.
|
||||
* Async stop + immediate start (e.g. foreground resume calling restartPairingIosCamera) left stopRunning
|
||||
* racing startRunning's internal beginConfiguration/commitConfiguration → NSGenericException crash.
|
||||
*/
|
||||
if (session) {
|
||||
if (!_sessionQueue) {
|
||||
_sessionQueue = dispatch_queue_create("org.amnezia.qr.session", DISPATCH_QUEUE_SERIAL);
|
||||
}
|
||||
dispatch_sync(_sessionQueue, ^{
|
||||
@try {
|
||||
if ([session isRunning]) {
|
||||
NSLog(@"[QRCodeReader] session stopRunning (sync) session=%p", session);
|
||||
[session stopRunning];
|
||||
}
|
||||
} @catch (NSException *ex) {
|
||||
NSLog(@"[QRCodeReader] session stopRunning exception: %@", ex);
|
||||
}
|
||||
});
|
||||
self.captureSession = nil;
|
||||
}
|
||||
|
||||
if (self.videoPreviewPlayer) {
|
||||
NSLog(@"[QRCodeReader] remove preview from superlayer");
|
||||
[self.videoPreviewPlayer removeFromSuperlayer];
|
||||
|
||||
@@ -263,6 +263,38 @@ static void amneziaApplyUnderlayTransparencyToView(UIView *view, BOOL transparen
|
||||
}
|
||||
}
|
||||
|
||||
/** Qt's QUIMetalView often sits deeper than a shallow walk; render thread can reset layer flags after resize. */
|
||||
static void amneziaForceMetalViewsTransparent(UIView *view, NSUInteger depth, NSUInteger maxDepth)
|
||||
{
|
||||
if (!view || depth > maxDepth) {
|
||||
return;
|
||||
}
|
||||
NSString *cn = NSStringFromClass([view class]);
|
||||
if ([cn rangeOfString:@"Metal"].location != NSNotFound) {
|
||||
view.opaque = NO;
|
||||
view.backgroundColor = [UIColor clearColor];
|
||||
view.layer.opaque = NO;
|
||||
if (view.layer) {
|
||||
view.layer.backgroundColor = [UIColor clearColor].CGColor;
|
||||
}
|
||||
NSLog(@"[PairingCamera] forceMetalTransparent depth=%lu class=%@", (unsigned long)depth, cn);
|
||||
}
|
||||
if (depth < maxDepth) {
|
||||
for (UIView *child in view.subviews) {
|
||||
amneziaForceMetalViewsTransparent(child, depth + 1, maxDepth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void amneziaApplyPairingUnderlayWalk(UIView *root, BOOL enable)
|
||||
{
|
||||
const NSUInteger kMaxDepth = 24;
|
||||
amneziaApplyUnderlayTransparencyToView(root, enable ? YES : NO, 0, kMaxDepth);
|
||||
if (enable) {
|
||||
amneziaForceMetalViewsTransparent(root, 0, kMaxDepth);
|
||||
}
|
||||
}
|
||||
|
||||
bool amneziaIosPairingCameraAccessGranted()
|
||||
{
|
||||
const AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
|
||||
@@ -341,14 +373,24 @@ void amneziaIosApplyEmbeddedCameraUnderlayToQtView(bool enable)
|
||||
vc.extendedLayoutIncludesOpaqueBars = YES;
|
||||
}
|
||||
}
|
||||
const NSUInteger kMaxDepth = 6;
|
||||
amneziaApplyUnderlayTransparencyToView(root, enable ? YES : NO, 0, kMaxDepth);
|
||||
amneziaApplyPairingUnderlayWalk(root, enable);
|
||||
if (enable && win) {
|
||||
amneziaInstallPairingSafeAreaDimStrips(win, root);
|
||||
amneziaLayoutPairingSafeAreaDimStrips();
|
||||
}
|
||||
NSLog(@"[PairingCamera] Qt view underlay transparency %@ subviews=%lu",
|
||||
enable ? @"ON" : @"OFF", (unsigned long)root.subviews.count);
|
||||
/** QUIMetalView is often updated after QSG resize; repeat walk next runloop so camera shows below status bar. */
|
||||
if (enable) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
UIViewController *vc2 = amneziaKeyWindowViewController();
|
||||
if (!vc2 || !vc2.view) {
|
||||
return;
|
||||
}
|
||||
amneziaApplyPairingUnderlayWalk(vc2.view, YES);
|
||||
NSLog(@"[PairingCamera] underlay repeat pass (post-runloop)");
|
||||
});
|
||||
}
|
||||
};
|
||||
if ([NSThread isMainThread]) {
|
||||
work();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#ifndef IOS_PAIRING_QR_OVERLAY_WINDOW_H
|
||||
#define IOS_PAIRING_QR_OVERLAY_WINDOW_H
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* iOS-only: UIWindow + UIKit capture for API “send pairing” QR scan.
|
||||
* UTF-8 scan payload is valid only for the duration of the callback.
|
||||
*/
|
||||
using AmneziaPairingQrScannedUtf8Handler = std::function<void(const char *)>;
|
||||
using AmneziaPairingQrOverlayBackHandler = std::function<void()>;
|
||||
|
||||
void amneziaIosPairingQrOverlayPresent(AmneziaPairingQrScannedUtf8Handler onScanned, AmneziaPairingQrOverlayBackHandler onBack,
|
||||
const std::string &titleUtf8, const std::string &subtitleUtf8);
|
||||
void amneziaIosPairingQrOverlayDismiss();
|
||||
bool amneziaIosPairingQrOverlayIsPresented();
|
||||
void amneziaIosPairingQrOverlaySetTorchEnabled(bool on);
|
||||
void amneziaIosPairingQrOverlayRestartCapture();
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,674 @@
|
||||
#include "platforms/ios/iosPairingQrOverlayWindow.h"
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
/** Qt on iOS may use a high window level; stay clearly above the main QQuick window. */
|
||||
static const CGFloat kAmneziaPairingQrOverlayWindowLevel = (CGFloat)UIWindowLevelAlert + 1000.f;
|
||||
|
||||
static AmneziaPairingQrScannedUtf8Handler gOnScanned;
|
||||
static AmneziaPairingQrOverlayBackHandler gOnBack;
|
||||
static UIWindow *gPairingQrOverlayWindow = nil;
|
||||
static bool gTorchRequested = false;
|
||||
/** Last time overlay became key; used to ignore restartCapture during warm-up (see QML kick timer removal). */
|
||||
static CFAbsoluteTime gPairingQrOverlayKeySince = -1.0;
|
||||
|
||||
static void amneziaPairingQrLogScenes(NSString *tag)
|
||||
{
|
||||
NSUInteger n = 0;
|
||||
for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
|
||||
NSString *cls = NSStringFromClass(scene.class);
|
||||
NSString *state = @"?";
|
||||
switch (scene.activationState) {
|
||||
case UISceneActivationStateUnattached:
|
||||
state = @"unattached";
|
||||
break;
|
||||
case UISceneActivationStateForegroundActive:
|
||||
state = @"foregroundActive";
|
||||
break;
|
||||
case UISceneActivationStateForegroundInactive:
|
||||
state = @"foregroundInactive";
|
||||
break;
|
||||
case UISceneActivationStateBackground:
|
||||
state = @"background";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
CGRect bounds = CGRectZero;
|
||||
if ([scene isKindOfClass:[UIWindowScene class]]) {
|
||||
bounds = ((UIWindowScene *)scene).coordinateSpace.bounds;
|
||||
}
|
||||
NSLog(@"[PairingQrOverlay] %@ scene[%lu] class=%@ state=%@ bounds=%@", tag, (unsigned long)n, cls, state,
|
||||
NSStringFromCGRect(bounds));
|
||||
n++;
|
||||
}
|
||||
if (n == 0) {
|
||||
NSLog(@"[PairingQrOverlay] %@ connectedScenes count=0", tag);
|
||||
}
|
||||
}
|
||||
|
||||
static void amneziaPairingQrLogWindows(NSString *tag)
|
||||
{
|
||||
NSUInteger i = 0;
|
||||
for (UIWindow *cw in UIApplication.sharedApplication.windows) {
|
||||
NSLog(@"[PairingQrOverlay] %@ UIWindow[%lu] ptr=%p level=%.1f hidden=%d key=%d bounds=%@ rootVC=%@", tag,
|
||||
(unsigned long)i, (void *)cw, cw.windowLevel, (int)cw.hidden, (int)cw.isKeyWindow,
|
||||
NSStringFromCGRect(cw.bounds),
|
||||
cw.rootViewController ? NSStringFromClass(cw.rootViewController.class) : @"(nil)");
|
||||
i++;
|
||||
}
|
||||
if (i == 0) {
|
||||
NSLog(@"[PairingQrOverlay] %@ UIApplication.windows count=0", tag);
|
||||
}
|
||||
}
|
||||
|
||||
static UIWindowScene *amneziaForegroundWindowScene(void)
|
||||
{
|
||||
for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
|
||||
if (scene.activationState == UISceneActivationStateForegroundActive
|
||||
&& [scene isKindOfClass:[UIWindowScene class]]) {
|
||||
return (UIWindowScene *)scene;
|
||||
}
|
||||
}
|
||||
for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
|
||||
if ([scene isKindOfClass:[UIWindowScene class]]) {
|
||||
return (UIWindowScene *)scene;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static UIWindow *amneziaPickQtAppWindowToRestore(void)
|
||||
{
|
||||
UIWindow *best = nil;
|
||||
for (UIWindow *cw in UIApplication.sharedApplication.windows) {
|
||||
if (cw == gPairingQrOverlayWindow || cw.hidden) {
|
||||
continue;
|
||||
}
|
||||
if (cw.windowScene && cw.windowLevel <= UIWindowLevelNormal + 1) {
|
||||
if (!best || cw.isKeyWindow) {
|
||||
best = cw;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Height left uncovered at the bottom so the Qt tab bar on QUIWindow stays visible. */
|
||||
static CGFloat amneziaPairingQrBottomTabStripReserve(UIWindowScene *scene)
|
||||
{
|
||||
Class qios = NSClassFromString(@"QIOSViewController");
|
||||
if (!qios) {
|
||||
return 83.f;
|
||||
}
|
||||
for (UIWindow *cw in scene.windows) {
|
||||
if (!cw.rootViewController) {
|
||||
continue;
|
||||
}
|
||||
if ([cw.rootViewController isKindOfClass:qios]) {
|
||||
const CGFloat inset = cw.safeAreaInsets.bottom;
|
||||
const CGFloat reserve = inset + 49.f;
|
||||
NSLog(@"[PairingQrOverlay] bottomReserve: QUIWindow safeBottom=%.1f -> reserve=%.1f", inset, reserve);
|
||||
return MIN(MAX(reserve, 72.f), 140.f);
|
||||
}
|
||||
}
|
||||
NSLog(@"[PairingQrOverlay] bottomReserve: QUIWindow not found, default 83");
|
||||
return 83.f;
|
||||
}
|
||||
|
||||
static void amneziaApplyReadableOverCameraShadow(UIView *v)
|
||||
{
|
||||
v.layer.shadowColor = [UIColor blackColor].CGColor;
|
||||
v.layer.shadowOffset = CGSizeMake(0, 1);
|
||||
v.layer.shadowRadius = 4;
|
||||
v.layer.shadowOpacity = 0.9;
|
||||
v.layer.masksToBounds = NO;
|
||||
}
|
||||
|
||||
@interface AmneziaPairingQrOverlayViewController : UIViewController
|
||||
@end
|
||||
|
||||
@interface AmneziaPairingQrOverlayViewController () <AVCaptureMetadataOutputObjectsDelegate>
|
||||
@property (nonatomic, strong) AVCaptureSession *captureSession;
|
||||
@property (nonatomic, strong) AVCaptureVideoPreviewLayer *previewLayer;
|
||||
@property (nonatomic, strong) AVCaptureDevice *videoDevice;
|
||||
@property (nonatomic, strong) dispatch_queue_t sessionQueue;
|
||||
@property (nonatomic, strong) UIView *cameraContainer;
|
||||
@property (nonatomic, strong) UIView *headerContainer;
|
||||
@property (nonatomic, strong) UIButton *backButton;
|
||||
@property (nonatomic, strong) UILabel *titleLabel;
|
||||
@property (nonatomic, strong) UILabel *subtitleLabel;
|
||||
@property (nonatomic, strong) UIButton *torchButton;
|
||||
@property (nonatomic, copy) NSString *chromeTitleText;
|
||||
@property (nonatomic, copy) NSString *chromeSubtitleText;
|
||||
@end
|
||||
|
||||
@implementation AmneziaPairingQrOverlayViewController
|
||||
|
||||
- (void)viewDidLoad
|
||||
{
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor clearColor];
|
||||
if (!self.sessionQueue) {
|
||||
self.sessionQueue = dispatch_queue_create("org.amnezia.pairingqr.overlay", DISPATCH_QUEUE_SERIAL);
|
||||
}
|
||||
[self buildChromeUi];
|
||||
}
|
||||
|
||||
- (void)buildChromeUi
|
||||
{
|
||||
if (self.headerContainer) {
|
||||
return;
|
||||
}
|
||||
UIView *cam = [[UIView alloc] init];
|
||||
cam.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
cam.backgroundColor = [UIColor clearColor];
|
||||
cam.clipsToBounds = YES;
|
||||
self.cameraContainer = cam;
|
||||
[self.view addSubview:cam];
|
||||
|
||||
UIView *header = [[UIView alloc] init];
|
||||
header.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
header.backgroundColor = [UIColor clearColor];
|
||||
header.opaque = NO;
|
||||
header.userInteractionEnabled = YES;
|
||||
self.headerContainer = header;
|
||||
[self.view addSubview:header];
|
||||
|
||||
UIButton *back = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
back.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
back.tintColor = [UIColor whiteColor];
|
||||
if (@available(iOS 13.0, *)) {
|
||||
UIImage *img = [UIImage systemImageNamed:@"chevron.backward"];
|
||||
[back setImage:img forState:UIControlStateNormal];
|
||||
} else {
|
||||
[back setTitle:@"<" forState:UIControlStateNormal];
|
||||
}
|
||||
[back addTarget:self action:@selector(backTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
self.backButton = back;
|
||||
[header addSubview:back];
|
||||
amneziaApplyReadableOverCameraShadow(back);
|
||||
|
||||
UILabel *title = [[UILabel alloc] init];
|
||||
title.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
title.textColor = [UIColor colorWithWhite:0.96 alpha:1];
|
||||
title.font = [UIFont systemFontOfSize:22 weight:UIFontWeightBold];
|
||||
title.numberOfLines = 0;
|
||||
title.text = self.chromeTitleText.length ? self.chromeTitleText : @"Add device via QR";
|
||||
self.titleLabel = title;
|
||||
[header addSubview:title];
|
||||
amneziaApplyReadableOverCameraShadow(title);
|
||||
|
||||
UILabel *sub = [[UILabel alloc] init];
|
||||
sub.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
sub.textColor = [UIColor colorWithWhite:0.88 alpha:0.95];
|
||||
sub.font = [UIFont systemFontOfSize:14 weight:UIFontWeightRegular];
|
||||
sub.numberOfLines = 0;
|
||||
sub.text = self.chromeSubtitleText.length
|
||||
? self.chromeSubtitleText
|
||||
: @"Scan the session QR shown on the device you want to add.";
|
||||
self.subtitleLabel = sub;
|
||||
[header addSubview:sub];
|
||||
amneziaApplyReadableOverCameraShadow(sub);
|
||||
|
||||
UIButton *torch = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
torch.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[torch setTitle:@"🔦" forState:UIControlStateNormal];
|
||||
torch.titleLabel.font = [UIFont systemFontOfSize:26];
|
||||
torch.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.22];
|
||||
torch.layer.cornerRadius = 28;
|
||||
torch.clipsToBounds = YES;
|
||||
[torch addTarget:self action:@selector(torchTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
self.torchButton = torch;
|
||||
[self.view addSubview:torch];
|
||||
|
||||
UILayoutGuide *safe = self.view.safeAreaLayoutGuide;
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[cam.topAnchor constraintEqualToAnchor:self.view.topAnchor],
|
||||
[cam.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[cam.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[cam.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
|
||||
|
||||
[header.topAnchor constraintEqualToAnchor:safe.topAnchor],
|
||||
[header.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[header.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[header.heightAnchor constraintGreaterThanOrEqualToConstant:120],
|
||||
|
||||
[back.leadingAnchor constraintEqualToAnchor:header.leadingAnchor constant:4],
|
||||
[back.topAnchor constraintEqualToAnchor:header.topAnchor constant:2],
|
||||
[back.widthAnchor constraintEqualToConstant:44],
|
||||
[back.heightAnchor constraintEqualToConstant:44],
|
||||
|
||||
[title.leadingAnchor constraintEqualToAnchor:header.leadingAnchor constant:16],
|
||||
[title.trailingAnchor constraintEqualToAnchor:header.trailingAnchor constant:-16],
|
||||
[title.topAnchor constraintEqualToAnchor:back.bottomAnchor constant:2],
|
||||
|
||||
[sub.leadingAnchor constraintEqualToAnchor:title.leadingAnchor],
|
||||
[sub.trailingAnchor constraintEqualToAnchor:title.trailingAnchor],
|
||||
[sub.topAnchor constraintEqualToAnchor:title.bottomAnchor constant:8],
|
||||
[sub.bottomAnchor constraintEqualToAnchor:header.bottomAnchor constant:-10],
|
||||
|
||||
[torch.topAnchor constraintGreaterThanOrEqualToAnchor:header.bottomAnchor constant:8],
|
||||
[torch.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
|
||||
[torch.widthAnchor constraintEqualToConstant:56],
|
||||
[torch.heightAnchor constraintEqualToConstant:56],
|
||||
[torch.bottomAnchor constraintEqualToAnchor:safe.bottomAnchor constant:-10],
|
||||
]];
|
||||
[header setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisVertical];
|
||||
[header setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisVertical];
|
||||
}
|
||||
|
||||
- (void)backTapped
|
||||
{
|
||||
NSLog(@"[PairingQrOverlay] native back tapped");
|
||||
if (gOnBack) {
|
||||
gOnBack();
|
||||
}
|
||||
}
|
||||
|
||||
- (void)torchTapped
|
||||
{
|
||||
gTorchRequested = !gTorchRequested;
|
||||
NSLog(@"[PairingQrOverlay] native torch toggle -> %d", (int)gTorchRequested);
|
||||
[self applyTorchFromGlobalFlag];
|
||||
if (gTorchRequested) {
|
||||
self.torchButton.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.42];
|
||||
self.torchButton.layer.borderWidth = 2;
|
||||
self.torchButton.layer.borderColor = [UIColor colorWithRed:1 green:0.75 blue:0.45 alpha:1].CGColor;
|
||||
} else {
|
||||
self.torchButton.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.22];
|
||||
self.torchButton.layer.borderWidth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)viewDidLayoutSubviews
|
||||
{
|
||||
[super viewDidLayoutSubviews];
|
||||
if (self.previewLayer && self.cameraContainer) {
|
||||
self.previewLayer.frame = self.cameraContainer.bounds;
|
||||
}
|
||||
if (self.headerContainer) {
|
||||
[self.view bringSubviewToFront:self.headerContainer];
|
||||
}
|
||||
if (self.torchButton) {
|
||||
[self.view bringSubviewToFront:self.torchButton];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)applyTorchOnMainThread:(BOOL)on
|
||||
{
|
||||
AVCaptureDevice *device = self.videoDevice;
|
||||
if (!device || ![device hasTorch]) {
|
||||
NSLog(@"[PairingQrOverlay] applyTorch skipped on=%d device=%p hasTorch=%d", (int)on, (void *)device,
|
||||
device ? (int)[device hasTorch] : -1);
|
||||
if (on && gTorchRequested) {
|
||||
__unsafe_unretained AmneziaPairingQrOverlayViewController *unsafeSelf = self;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.12 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
AmneziaPairingQrOverlayViewController *strongSelf = unsafeSelf;
|
||||
if (strongSelf && gTorchRequested) {
|
||||
[strongSelf applyTorchOnMainThread:YES];
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
AVCaptureSession *session = self.captureSession;
|
||||
if (on && session && ![session isRunning]) {
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.25 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
if (gTorchRequested) {
|
||||
[self applyTorchOnMainThread:YES];
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
NSError *err = nil;
|
||||
if (![device lockForConfiguration:&err]) {
|
||||
NSLog(@"[PairingQrOverlay] torch lock failed: %@", err.localizedDescription);
|
||||
return;
|
||||
}
|
||||
if (on) {
|
||||
err = nil;
|
||||
if (![device setTorchModeOnWithLevel:AVCaptureMaxAvailableTorchLevel error:&err]) {
|
||||
if ([device isTorchModeSupported:AVCaptureTorchModeOn]) {
|
||||
device.torchMode = AVCaptureTorchModeOn;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
device.torchMode = AVCaptureTorchModeOff;
|
||||
}
|
||||
[device unlockForConfiguration];
|
||||
}
|
||||
|
||||
- (void)applyTorchFromGlobalFlag
|
||||
{
|
||||
[self applyTorchOnMainThread:gTorchRequested ? YES : NO];
|
||||
}
|
||||
|
||||
- (void)stopCapturePipelineOnMainThread
|
||||
{
|
||||
[self applyTorchOnMainThread:NO];
|
||||
self.videoDevice = nil;
|
||||
|
||||
AVCaptureSession *session = self.captureSession;
|
||||
self.captureSession = nil;
|
||||
|
||||
if (self.previewLayer) {
|
||||
[self.previewLayer removeFromSuperlayer];
|
||||
self.previewLayer = nil;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
dispatch_queue_t q = self.sessionQueue;
|
||||
if (!q) {
|
||||
q = dispatch_queue_create("org.amnezia.pairingqr.overlay", DISPATCH_QUEUE_SERIAL);
|
||||
self.sessionQueue = q;
|
||||
}
|
||||
dispatch_sync(q, ^{
|
||||
@try {
|
||||
if ([session isRunning]) {
|
||||
[session stopRunning];
|
||||
}
|
||||
} @catch (NSException *ex) {
|
||||
NSLog(@"[PairingQrOverlay] stopRunning exception: %@", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)startCapturePipelineOnMainThread
|
||||
{
|
||||
NSLog(@"[PairingQrOverlay] startCapturePipelineOnMainThread entry camera.bounds=%@ thread=%@",
|
||||
self.cameraContainer ? NSStringFromCGRect(self.cameraContainer.bounds) : @"(nil)",
|
||||
[NSThread isMainThread] ? @"main" : @"bg");
|
||||
|
||||
[self stopCapturePipelineOnMainThread];
|
||||
|
||||
if (!self.cameraContainer) {
|
||||
NSLog(@"[PairingQrOverlay] no cameraContainer");
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSError *error = nil;
|
||||
AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
|
||||
if (!device) {
|
||||
NSLog(@"[PairingQrOverlay] no default video device");
|
||||
return NO;
|
||||
}
|
||||
NSLog(@"[PairingQrOverlay] capture device=%p modelID=%@ localizedName=%@", (void *)device, device.modelID,
|
||||
device.localizedName);
|
||||
AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device error:&error];
|
||||
if (!input) {
|
||||
NSLog(@"[PairingQrOverlay] deviceInput failed: %@", error.localizedDescription);
|
||||
return NO;
|
||||
}
|
||||
self.videoDevice = device;
|
||||
|
||||
AVCaptureSession *session = [[AVCaptureSession alloc] init];
|
||||
if ([session canSetSessionPreset:AVCaptureSessionPresetHigh]) {
|
||||
session.sessionPreset = AVCaptureSessionPresetHigh;
|
||||
}
|
||||
NSLog(@"[PairingQrOverlay] session preset=%@", session.sessionPreset);
|
||||
|
||||
[session addInput:input];
|
||||
|
||||
AVCaptureMetadataOutput *meta = [[AVCaptureMetadataOutput alloc] init];
|
||||
if (![session canAddOutput:meta]) {
|
||||
NSLog(@"[PairingQrOverlay] cannot add metadata output");
|
||||
return NO;
|
||||
}
|
||||
[session addOutput:meta];
|
||||
dispatch_queue_t q = self.sessionQueue;
|
||||
if (!q) {
|
||||
q = dispatch_queue_create("org.amnezia.pairingqr.overlay", DISPATCH_QUEUE_SERIAL);
|
||||
self.sessionQueue = q;
|
||||
}
|
||||
[meta setMetadataObjectsDelegate:self queue:q];
|
||||
meta.metadataObjectTypes = @[ AVMetadataObjectTypeQRCode ];
|
||||
|
||||
self.captureSession = session;
|
||||
|
||||
AVCaptureVideoPreviewLayer *preview = [[AVCaptureVideoPreviewLayer alloc] initWithSession:session];
|
||||
preview.videoGravity = AVLayerVideoGravityResizeAspectFill;
|
||||
self.previewLayer = preview;
|
||||
[self.cameraContainer.layer insertSublayer:preview atIndex:0];
|
||||
preview.frame = self.cameraContainer.bounds;
|
||||
NSLog(@"[PairingQrOverlay] previewLayer on host frame=%@", NSStringFromCGRect(preview.frame));
|
||||
|
||||
AVCaptureSession *runningSession = session;
|
||||
__unsafe_unretained AmneziaPairingQrOverlayViewController *weakSelf = self;
|
||||
dispatch_async(q, ^{
|
||||
NSLog(@"[PairingQrOverlay] session queue: startRunning begin session=%p", (void *)runningSession);
|
||||
@try {
|
||||
[runningSession startRunning];
|
||||
} @catch (NSException *ex) {
|
||||
NSLog(@"[PairingQrOverlay] startRunning exception: %@", ex);
|
||||
}
|
||||
const BOOL running = [runningSession isRunning];
|
||||
NSLog(@"[PairingQrOverlay] session queue: startRunning end isRunning=%d", (int)running);
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
AmneziaPairingQrOverlayViewController *strongSelf = weakSelf;
|
||||
if (!strongSelf) {
|
||||
return;
|
||||
}
|
||||
AVCaptureVideoPreviewLayer *pl = strongSelf.previewLayer;
|
||||
NSLog(@"[PairingQrOverlay] post-start main: sessionRunning=%d previewFrame=%@ hostBounds=%@", (int)runningSession.isRunning,
|
||||
pl ? NSStringFromCGRect(pl.frame) : @"(no preview)", NSStringFromCGRect(strongSelf.cameraContainer.bounds));
|
||||
[strongSelf applyTorchFromGlobalFlag];
|
||||
});
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
AmneziaPairingQrOverlayViewController *strongSelf = weakSelf;
|
||||
if (!strongSelf) {
|
||||
return;
|
||||
}
|
||||
AVCaptureVideoPreviewLayer *pl = strongSelf.previewLayer;
|
||||
NSLog(@"[PairingQrOverlay] probe+350ms: sessionRunning=%d previewFrame=%@", (int)runningSession.isRunning,
|
||||
pl ? NSStringFromCGRect(pl.frame) : @"(nil)");
|
||||
amneziaPairingQrLogWindows(@"probe+350ms");
|
||||
});
|
||||
});
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)captureOutput:(AVCaptureOutput *)output
|
||||
didOutputMetadataObjects:(NSArray<__kindof AVMetadataMachineReadableCodeObject *> *)metadataObjects
|
||||
fromConnection:(AVCaptureConnection *)connection
|
||||
{
|
||||
(void)output;
|
||||
(void)connection;
|
||||
for (AVMetadataMachineReadableCodeObject *obj in metadataObjects) {
|
||||
NSString *value = obj.stringValue;
|
||||
if (value.length == 0) {
|
||||
continue;
|
||||
}
|
||||
const char *utf8 = value.UTF8String;
|
||||
std::string copy(utf8 ? utf8 : "");
|
||||
if (copy.empty()) {
|
||||
continue;
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (gOnScanned) {
|
||||
NSLog(@"[PairingQrOverlay] metadata QR len=%lu", (unsigned long)copy.size());
|
||||
gOnScanned(copy.c_str());
|
||||
} else {
|
||||
NSLog(@"[PairingQrOverlay] metadata QR but gOnScanned is nil");
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
static void amneziaPairingQrOverlayTeardownOnMain(void)
|
||||
{
|
||||
NSLog(@"[PairingQrOverlay] teardownOnMain overlayPtr=%p", (void *)gPairingQrOverlayWindow);
|
||||
UIWindow *w = gPairingQrOverlayWindow;
|
||||
gPairingQrOverlayWindow = nil;
|
||||
gOnScanned = nullptr;
|
||||
gOnBack = nullptr;
|
||||
gTorchRequested = false;
|
||||
gPairingQrOverlayKeySince = -1.0;
|
||||
|
||||
if (w) {
|
||||
UIViewController *root = w.rootViewController;
|
||||
w.rootViewController = nil;
|
||||
w.hidden = YES;
|
||||
if ([root isKindOfClass:[AmneziaPairingQrOverlayViewController class]]) {
|
||||
[(AmneziaPairingQrOverlayViewController *)root stopCapturePipelineOnMainThread];
|
||||
}
|
||||
}
|
||||
|
||||
UIWindow *restore = amneziaPickQtAppWindowToRestore();
|
||||
if (restore) {
|
||||
NSLog(@"[PairingQrOverlay] teardown: restore keyWindow to %@", restore);
|
||||
[restore makeKeyWindow];
|
||||
} else {
|
||||
NSLog(@"[PairingQrOverlay] teardown: no window to restore as key");
|
||||
}
|
||||
amneziaPairingQrLogWindows(@"afterTeardown");
|
||||
}
|
||||
|
||||
void amneziaIosPairingQrOverlayPresent(AmneziaPairingQrScannedUtf8Handler onScanned, AmneziaPairingQrOverlayBackHandler onBack,
|
||||
const std::string &titleUtf8, const std::string &subtitleUtf8)
|
||||
{
|
||||
const bool hasScan = static_cast<bool>(onScanned);
|
||||
const bool hasBack = static_cast<bool>(onBack);
|
||||
NSLog(@"[PairingQrOverlay] C++ present requested hasScan=%d hasBack=%d callerThread=%p", (int)hasScan, (int)hasBack,
|
||||
(void *)NSThread.currentThread);
|
||||
AmneziaPairingQrScannedUtf8Handler scanH = std::move(onScanned);
|
||||
AmneziaPairingQrOverlayBackHandler backH = std::move(onBack);
|
||||
const std::string titleCopy = titleUtf8;
|
||||
const std::string subCopy = subtitleUtf8;
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSLog(@"[PairingQrOverlay] present block on main");
|
||||
amneziaPairingQrLogScenes(@"beforeTeardown");
|
||||
amneziaPairingQrLogWindows(@"beforeTeardown");
|
||||
amneziaPairingQrOverlayTeardownOnMain();
|
||||
gOnScanned = std::move(scanH);
|
||||
gOnBack = std::move(backH);
|
||||
NSLog(@"[PairingQrOverlay] callbacks stored scan=%d back=%d", (int)(bool)gOnScanned, (int)(bool)gOnBack);
|
||||
|
||||
UIWindowScene *scene = amneziaForegroundWindowScene();
|
||||
if (!scene) {
|
||||
NSLog(@"[PairingQrOverlay] present: no UIWindowScene");
|
||||
amneziaPairingQrLogScenes(@"sceneNil");
|
||||
gOnScanned = nullptr;
|
||||
gOnBack = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
const CGFloat bottomReserve = amneziaPairingQrBottomTabStripReserve(scene);
|
||||
const CGRect sceneBounds = scene.coordinateSpace.bounds;
|
||||
const CGRect overlayFrame = CGRectMake(0, 0, sceneBounds.size.width, sceneBounds.size.height - bottomReserve);
|
||||
|
||||
AmneziaPairingQrOverlayViewController *vc = [[AmneziaPairingQrOverlayViewController alloc] init];
|
||||
NSString *nsTitle = titleCopy.empty() ? nil : [NSString stringWithUTF8String:titleCopy.c_str()];
|
||||
NSString *nsSub = subCopy.empty() ? nil : [NSString stringWithUTF8String:subCopy.c_str()];
|
||||
vc.chromeTitleText = nsTitle;
|
||||
vc.chromeSubtitleText = nsSub;
|
||||
|
||||
UIWindow *w = [[UIWindow alloc] initWithWindowScene:scene];
|
||||
w.frame = overlayFrame;
|
||||
w.windowLevel = kAmneziaPairingQrOverlayWindowLevel;
|
||||
w.backgroundColor = [UIColor blackColor];
|
||||
w.rootViewController = vc;
|
||||
gPairingQrOverlayWindow = w;
|
||||
NSLog(@"[PairingQrOverlay] created UIWindow ptr=%p frame=%@ (sceneH=%.0f bottomReserve=%.0f) level=%.1f", (void *)w,
|
||||
NSStringFromCGRect(w.frame), sceneBounds.size.height, bottomReserve, w.windowLevel);
|
||||
|
||||
[w makeKeyAndVisible];
|
||||
[w layoutIfNeeded];
|
||||
[vc.view setNeedsLayout];
|
||||
[vc.view layoutIfNeeded];
|
||||
NSLog(@"[PairingQrOverlay] after layout vc.view.bounds=%@ window.key=%d", NSStringFromCGRect(vc.view.bounds),
|
||||
(int)w.isKeyWindow);
|
||||
|
||||
gPairingQrOverlayKeySince = CFAbsoluteTimeGetCurrent();
|
||||
|
||||
if (![vc startCapturePipelineOnMainThread]) {
|
||||
NSLog(@"[PairingQrOverlay] startCapture failed");
|
||||
}
|
||||
amneziaPairingQrLogWindows(@"afterPresent");
|
||||
});
|
||||
}
|
||||
|
||||
void amneziaIosPairingQrOverlayDismiss()
|
||||
{
|
||||
NSLog(@"[PairingQrOverlay] C++ dismiss requested");
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
amneziaPairingQrOverlayTeardownOnMain();
|
||||
});
|
||||
}
|
||||
|
||||
bool amneziaIosPairingQrOverlayIsPresented()
|
||||
{
|
||||
if ([NSThread isMainThread]) {
|
||||
return gPairingQrOverlayWindow != nil && !gPairingQrOverlayWindow.hidden;
|
||||
}
|
||||
__block bool out = false;
|
||||
dispatch_sync(dispatch_get_main_queue(), ^{
|
||||
out = (gPairingQrOverlayWindow != nil && !gPairingQrOverlayWindow.hidden);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
void amneziaIosPairingQrOverlaySetTorchEnabled(bool on)
|
||||
{
|
||||
NSLog(@"[PairingQrOverlay] C++ setTorch=%d", (int)on);
|
||||
gTorchRequested = on;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
UIWindow *win = gPairingQrOverlayWindow;
|
||||
if (!win) {
|
||||
NSLog(@"[PairingQrOverlay] setTorch: no overlay window");
|
||||
return;
|
||||
}
|
||||
UIViewController *root = win.rootViewController;
|
||||
if ([root isKindOfClass:[AmneziaPairingQrOverlayViewController class]]) {
|
||||
AmneziaPairingQrOverlayViewController *vc = (AmneziaPairingQrOverlayViewController *)root;
|
||||
[vc applyTorchFromGlobalFlag];
|
||||
if (vc.torchButton) {
|
||||
if (on) {
|
||||
vc.torchButton.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.42];
|
||||
vc.torchButton.layer.borderWidth = 2;
|
||||
vc.torchButton.layer.borderColor = [UIColor colorWithRed:1 green:0.75 blue:0.45 alpha:1].CGColor;
|
||||
} else {
|
||||
vc.torchButton.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.18];
|
||||
vc.torchButton.layer.borderWidth = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void amneziaIosPairingQrOverlayRestartCapture()
|
||||
{
|
||||
NSLog(@"[PairingQrOverlay] C++ restartCapture requested");
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
const CFAbsoluteTime now = CFAbsoluteTimeGetCurrent();
|
||||
if (gPairingQrOverlayKeySince > 0 && (now - gPairingQrOverlayKeySince) < 1.0) {
|
||||
NSLog(@"[PairingQrOverlay] restartCapture: skipped warm-up (%.3fs since overlay key)", now - gPairingQrOverlayKeySince);
|
||||
return;
|
||||
}
|
||||
UIWindow *w = gPairingQrOverlayWindow;
|
||||
if (!w) {
|
||||
NSLog(@"[PairingQrOverlay] restartCapture: no overlay window");
|
||||
return;
|
||||
}
|
||||
UIViewController *root = w.rootViewController;
|
||||
if (![root isKindOfClass:[AmneziaPairingQrOverlayViewController class]]) {
|
||||
NSLog(@"[PairingQrOverlay] restartCapture: rootVC class=%@", root ? NSStringFromClass(root.class) : @"(nil)");
|
||||
return;
|
||||
}
|
||||
AmneziaPairingQrOverlayViewController *vc = (AmneziaPairingQrOverlayViewController *)root;
|
||||
NSLog(@"[PairingQrOverlay] restartCapture: stop+start");
|
||||
[vc stopCapturePipelineOnMainThread];
|
||||
if (![vc startCapturePipelineOnMainThread]) {
|
||||
NSLog(@"[PairingQrOverlay] restart startCapture failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -8,8 +8,12 @@
|
||||
#include <QRegularExpression>
|
||||
#include <QTimer>
|
||||
#include <QUuid>
|
||||
#include <string>
|
||||
|
||||
#include "platforms/ios/iosPairingCameraAccess.h"
|
||||
#if defined(Q_OS_IOS)
|
||||
#include "platforms/ios/iosPairingQrOverlayWindow.h"
|
||||
#endif
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
#include "platforms/android/android_controller.h"
|
||||
@@ -100,6 +104,15 @@ PairingUiController *g_pairingUiForAndroidQr = nullptr;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool PairingUiController::iosNativePairingQrOverlayBuild() const
|
||||
{
|
||||
#if defined(Q_OS_IOS)
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
PairingUiController::PairingUiController(PairingController *pairingController, ServersController *serversController,
|
||||
SubscriptionController *subscriptionController,
|
||||
SecureAppSettingsRepository *appSettingsRepository, QObject *parent)
|
||||
@@ -123,6 +136,9 @@ PairingUiController::~PairingUiController()
|
||||
g_pairingUiForAndroidQr = nullptr;
|
||||
}
|
||||
#endif
|
||||
#if defined(Q_OS_IOS)
|
||||
amneziaIosPairingQrOverlayDismiss();
|
||||
#endif
|
||||
}
|
||||
|
||||
void PairingUiController::setPendingPhonePairingUuid(const QString &uuid)
|
||||
@@ -227,15 +243,78 @@ void PairingUiController::syncIosEmbeddedPairingQrNativeBottomExtra(int extraPt)
|
||||
#endif
|
||||
}
|
||||
|
||||
void PairingUiController::refreshIosEmbeddedPairingQrChrome()
|
||||
{
|
||||
#if defined(Q_OS_IOS)
|
||||
if (!m_embeddedPairingQrCameraActive) {
|
||||
return;
|
||||
}
|
||||
qInfo() << "[PairingUi] refreshIosEmbeddedPairingQrChrome (reapply UIView underlay)";
|
||||
amneziaIosApplyEmbeddedCameraUnderlayToQtView(true);
|
||||
#else
|
||||
// no-op on non-iOS
|
||||
#endif
|
||||
}
|
||||
|
||||
void PairingUiController::setPairingQrTorchEnabled(bool enabled)
|
||||
{
|
||||
#if defined(Q_OS_ANDROID)
|
||||
AndroidController::instance()->setPairingQrEmbeddedTorch(enabled);
|
||||
#elif defined(Q_OS_IOS)
|
||||
amneziaIosPairingQrOverlaySetTorchEnabled(enabled);
|
||||
#else
|
||||
Q_UNUSED(enabled);
|
||||
#endif
|
||||
}
|
||||
|
||||
void PairingUiController::presentIosPairingQrNativeOverlayScanner(const QString &title, const QString &subtitle)
|
||||
{
|
||||
#if defined(Q_OS_IOS)
|
||||
qInfo() << "[PairingUi] presentIosPairingQrNativeOverlayScanner: scheduling native UIWindow overlay";
|
||||
const std::string titleUtf8 = title.isEmpty() ? std::string() : title.toStdString();
|
||||
const std::string subtitleUtf8 = subtitle.isEmpty() ? std::string() : subtitle.toStdString();
|
||||
amneziaIosPairingQrOverlayPresent(
|
||||
[this](const char *utf8) {
|
||||
const QString code = QString::fromUtf8(utf8);
|
||||
QMetaObject::invokeMethod(
|
||||
this,
|
||||
[this, code]() {
|
||||
if (!applyScannedTextAsPairingUuid(code)) {
|
||||
emit pairingSendQrScanRejectedInvalidPayload();
|
||||
}
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
},
|
||||
[this]() {
|
||||
QMetaObject::invokeMethod(
|
||||
this,
|
||||
[this]() { emit pairingIosNativeQrOverlayBackRequested(); },
|
||||
Qt::QueuedConnection);
|
||||
},
|
||||
titleUtf8, subtitleUtf8);
|
||||
#else
|
||||
Q_UNUSED(title);
|
||||
Q_UNUSED(subtitle);
|
||||
qInfo() << "[PairingUi] presentIosPairingQrNativeOverlayScanner: no-op (not iOS build)";
|
||||
#endif
|
||||
}
|
||||
|
||||
void PairingUiController::dismissIosPairingQrNativeOverlayScanner()
|
||||
{
|
||||
#if defined(Q_OS_IOS)
|
||||
qInfo() << "[PairingUi] dismissIosPairingQrNativeOverlayScanner";
|
||||
amneziaIosPairingQrOverlayDismiss();
|
||||
#endif
|
||||
}
|
||||
|
||||
void PairingUiController::restartIosPairingQrNativeOverlayCapture()
|
||||
{
|
||||
#if defined(Q_OS_IOS)
|
||||
qInfo() << "[PairingUi] restartIosPairingQrNativeOverlayCapture";
|
||||
amneziaIosPairingQrOverlayRestartCapture();
|
||||
#endif
|
||||
}
|
||||
|
||||
bool PairingUiController::applyScannedTextAsPairingUuid(const QString &raw)
|
||||
{
|
||||
const QString t = raw.trimmed();
|
||||
|
||||
@@ -38,6 +38,8 @@ class PairingUiController : public QObject
|
||||
/** Full-screen pairing QR camera under QML (mobile); drives translucent main window. */
|
||||
Q_PROPERTY(bool embeddedPairingQrCameraActive READ embeddedPairingQrCameraActive WRITE setEmbeddedPairingQrCameraActive NOTIFY
|
||||
embeddedPairingQrCameraActiveChanged)
|
||||
/** True only on iOS builds: use native UIWindow QR overlay (not Qt.platform.os, which can differ). */
|
||||
Q_PROPERTY(bool iosNativePairingQrOverlayBuild READ iosNativePairingQrOverlayBuild CONSTANT)
|
||||
|
||||
public:
|
||||
PairingUiController(PairingController *pairingController, ServersController *serversController,
|
||||
@@ -59,9 +61,25 @@ public:
|
||||
QString lastSuccessfulPhonePairingDisplayName() const { return m_lastSuccessfulPhonePairingDisplayName; }
|
||||
int tvPairingUiPhase() const { return m_tvPairingUiPhase; }
|
||||
bool embeddedPairingQrCameraActive() const { return m_embeddedPairingQrCameraActive; }
|
||||
bool iosNativePairingQrOverlayBuild() const;
|
||||
Q_INVOKABLE void setEmbeddedPairingQrCameraActive(bool active);
|
||||
/** iOS: native dim strip height uses safe bottom + extraPt (see PageSettingsApiQrPairingSend scanDimBleedBottom). No-op elsewhere. */
|
||||
Q_INVOKABLE void syncIosEmbeddedPairingQrNativeBottomExtra(int extraPt);
|
||||
/**
|
||||
* iOS: reapply UIView transparency + safe-area dim strips when embedded pairing is already active.
|
||||
* Needed after multitask resume: setEmbeddedPairingQrCameraActive(true) is a no-op if the flag stayed true,
|
||||
* but QUIMetalView / hierarchy may have been rebuilt opaque so the camera only shows in the status bar band.
|
||||
*/
|
||||
Q_INVOKABLE void refreshIosEmbeddedPairingQrChrome();
|
||||
|
||||
/**
|
||||
* iOS: UIKit UIWindow QR scanner (see iosPairingQrOverlayWindow). Pass translated title/subtitle for native chrome.
|
||||
* No-op on other platforms.
|
||||
*/
|
||||
Q_INVOKABLE void presentIosPairingQrNativeOverlayScanner(const QString &title = QString(),
|
||||
const QString &subtitle = QString());
|
||||
Q_INVOKABLE void dismissIosPairingQrNativeOverlayScanner();
|
||||
Q_INVOKABLE void restartIosPairingQrNativeOverlayCapture();
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
static bool tryConsumeAndroidQrScan(const QString &code);
|
||||
@@ -116,6 +134,10 @@ signals:
|
||||
/** After requestPairingCameraAccess(): true if OS granted camera access. */
|
||||
void pairingCameraAccessFinished(bool granted);
|
||||
void embeddedPairingQrCameraActiveChanged();
|
||||
/** iOS native overlay scanner: payload was not a pairing session UUID (toast in QML). */
|
||||
void pairingSendQrScanRejectedInvalidPayload();
|
||||
/** Native overlay back chevron tapped — dismiss scanner and close page from QML. */
|
||||
void pairingIosNativeQrOverlayBackRequested();
|
||||
|
||||
private:
|
||||
void setTvBusy(bool busy);
|
||||
|
||||
@@ -22,6 +22,9 @@ PageType {
|
||||
/** 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"
|
||||
|
||||
/** iOS-only: full-screen UIKit UIWindow scanner (PairingUiController.iosNativePairingQrOverlayBuild — not Qt.platform.os). */
|
||||
readonly property bool useIosNativePairingQrOverlay: PairingUiController.iosNativePairingQrOverlayBuild
|
||||
|
||||
/** 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
|
||||
@@ -147,6 +150,11 @@ PageType {
|
||||
|
||||
function stopMobileScanner() {
|
||||
torchOn = false
|
||||
if (root.useIosNativePairingQrOverlay) {
|
||||
PairingUiController.setPairingQrTorchEnabled(false)
|
||||
PairingUiController.dismissIosPairingQrNativeOverlayScanner()
|
||||
return
|
||||
}
|
||||
if (Qt.platform.os === "android") {
|
||||
PairingUiController.setPairingQrTorchEnabled(false)
|
||||
} else if (root.useIosStyleNativeQrReader) {
|
||||
@@ -160,17 +168,31 @@ PageType {
|
||||
if (!GC.isMobile()) {
|
||||
return
|
||||
}
|
||||
console.warn("[PairingQrSend] startMobileScanner Qt.platform.os=", Qt.platform.os,
|
||||
"iosNativePairingQrOverlayBuild=", PairingUiController.iosNativePairingQrOverlayBuild,
|
||||
"useIosNativePairingQrOverlay=", root.useIosNativePairingQrOverlay)
|
||||
if (!PairingUiController.isPairingCameraAccessGranted()) {
|
||||
awaitingCameraPermissionForScan = true
|
||||
PairingUiController.requestPairingCameraAccess()
|
||||
return
|
||||
}
|
||||
if (root.useIosNativePairingQrOverlay) {
|
||||
PairingUiController.presentIosPairingQrNativeOverlayScanner(
|
||||
qsTr("Add device via QR"),
|
||||
qsTr("Scan the session QR shown on the device you want to add. You will confirm before the subscription is sent."))
|
||||
/** Do not run pairingCameraKickTimer here: restartCapture during first startRunning races the session (torch needs 2–3 taps). */
|
||||
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()
|
||||
/** After resume embedded can stay true so setEmbedded skips; QUIMetalView may be opaque again. */
|
||||
Qt.callLater(function () {
|
||||
PairingUiController.refreshIosEmbeddedPairingQrChrome()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +225,10 @@ PageType {
|
||||
}
|
||||
|
||||
function restartPairingIosCamera() {
|
||||
if (root.useIosNativePairingQrOverlay) {
|
||||
PairingUiController.restartIosPairingQrNativeOverlayCapture()
|
||||
return
|
||||
}
|
||||
if (!root.useIosStyleNativeQrReader || pairingWizardStep !== 0) {
|
||||
return
|
||||
}
|
||||
@@ -243,6 +269,18 @@ PageType {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* StackView often instantiates the page already visible — onVisibleChanged(true) may never run, so
|
||||
* startMobileScanner (and native overlay present) would be skipped; only stop/dismiss runs. Same pattern as
|
||||
* PageSetupWizardApiQrPairingReceive (Component.onCompleted + visible).
|
||||
*/
|
||||
Component.onCompleted: {
|
||||
if (GC.isMobile() && root.visible && pairingWizardStep === 0) {
|
||||
console.warn("[PairingQrSend] Component.onCompleted: schedule startMobileScanner (page created visible)")
|
||||
Qt.callLater(startMobileScanner)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Qt.application
|
||||
|
||||
@@ -251,6 +289,46 @@ PageType {
|
||||
return
|
||||
}
|
||||
root.tryResumeScanAfterCameraSettings()
|
||||
if (!root.useIosStyleNativeQrReader || root.pairingWizardStep !== 0
|
||||
|| !PairingUiController.isPairingCameraAccessGranted()) {
|
||||
return
|
||||
}
|
||||
if (root.useIosNativePairingQrOverlay) {
|
||||
Qt.callLater(function () {
|
||||
if (!root.visible || root.pairingWizardStep !== 0 || !GC.isMobile()) {
|
||||
return
|
||||
}
|
||||
PairingUiController.restartIosPairingQrNativeOverlayCapture()
|
||||
})
|
||||
return
|
||||
}
|
||||
/**
|
||||
* No fixed ms delay: f1 reapply UIView transparency; f2 restart AVCapture; f3 refresh again —
|
||||
* after restartPairingIosCamera, QUIMetalView / render thread often rebuilds opaque layers (same bug
|
||||
* as status-bar-only camera) until underlay is reapplied once more.
|
||||
*/
|
||||
Qt.callLater(function () {
|
||||
if (!root.visible || root.pairingWizardStep !== 0 || !GC.isMobile()) {
|
||||
return
|
||||
}
|
||||
console.warn("[PairingQrResume] ApplicationActive f1 underlay")
|
||||
PairingUiController.embeddedPairingQrCameraActive = true
|
||||
PairingUiController.refreshIosEmbeddedPairingQrChrome()
|
||||
Qt.callLater(function () {
|
||||
if (!root.visible || root.pairingWizardStep !== 0) {
|
||||
return
|
||||
}
|
||||
console.warn("[PairingQrResume] ApplicationActive f2 restart camera")
|
||||
root.restartPairingIosCamera()
|
||||
Qt.callLater(function () {
|
||||
if (!root.visible || root.pairingWizardStep !== 0) {
|
||||
return
|
||||
}
|
||||
console.warn("[PairingQrResume] ApplicationActive f3 underlay post-camera")
|
||||
PairingUiController.refreshIosEmbeddedPairingQrChrome()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +520,8 @@ PageType {
|
||||
anchors.topMargin: 8 + PageController.safeAreaTopMargin
|
||||
spacing: 10
|
||||
z: 2
|
||||
/** Native iOS overlay draws its own header. */
|
||||
visible: !GC.isMobile() || !root.useIosNativePairingQrOverlay
|
||||
|
||||
BackButtonType {
|
||||
width: parent.width
|
||||
@@ -475,7 +555,7 @@ PageType {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottomMargin: 28 + PageController.safeAreaBottomMargin
|
||||
visible: GC.isMobile()
|
||||
visible: GC.isMobile() && !root.useIosNativePairingQrOverlay
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
@@ -495,6 +575,8 @@ PageType {
|
||||
root.torchOn = !root.torchOn
|
||||
if (Qt.platform.os === "android") {
|
||||
PairingUiController.setPairingQrTorchEnabled(root.torchOn)
|
||||
} else if (root.useIosNativePairingQrOverlay) {
|
||||
PairingUiController.setPairingQrTorchEnabled(root.torchOn)
|
||||
} else if (root.useIosStyleNativeQrReader) {
|
||||
pairingQrReader.setTorchEnabled(root.torchOn)
|
||||
}
|
||||
@@ -526,6 +608,9 @@ PageType {
|
||||
// Same idea as PageSetupWizardQrReader: ensure startReading runs even if
|
||||
// StackView/onVisible timing skips startMobileScanner once.
|
||||
Component.onCompleted: {
|
||||
if (root.useIosNativePairingQrOverlay) {
|
||||
return
|
||||
}
|
||||
if (!root.useIosStyleNativeQrReader || root.pairingWizardStep !== 0) {
|
||||
return
|
||||
}
|
||||
@@ -666,5 +751,22 @@ PageType {
|
||||
pairingWizardStep = 1
|
||||
})
|
||||
}
|
||||
|
||||
function onPairingSendQrScanRejectedInvalidPayload() {
|
||||
if (!root.useIosNativePairingQrOverlay || root.pairingWizardStep !== 0) {
|
||||
return
|
||||
}
|
||||
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."))
|
||||
}
|
||||
}
|
||||
|
||||
function onPairingIosNativeQrOverlayBackRequested() {
|
||||
stopMobileScanner()
|
||||
PageController.closePage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user