diff --git a/client/cmake/ios.cmake b/client/cmake/ios.cmake index b07848721..65df0426d 100644 --- a/client/cmake/ios.cmake +++ b/client/cmake/ios.cmake @@ -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 diff --git a/client/cmake/sources.cmake b/client/cmake/sources.cmake index 8d904f6dd..42001999b 100644 --- a/client/cmake/sources.cmake +++ b/client/cmake/sources.cmake @@ -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 diff --git a/client/platforms/ios/QRCodeReaderBase.mm b/client/platforms/ios/QRCodeReaderBase.mm index ea691fec5..418aa68ee 100644 --- a/client/platforms/ios/QRCodeReaderBase.mm +++ b/client/platforms/ios/QRCodeReaderBase.mm @@ -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]; diff --git a/client/platforms/ios/iosPairingCameraAccess.mm b/client/platforms/ios/iosPairingCameraAccess.mm index 70566027d..f73f1690b 100644 --- a/client/platforms/ios/iosPairingCameraAccess.mm +++ b/client/platforms/ios/iosPairingCameraAccess.mm @@ -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(); diff --git a/client/platforms/ios/iosPairingQrOverlayWindow.h b/client/platforms/ios/iosPairingQrOverlayWindow.h new file mode 100644 index 000000000..e9849cfe0 --- /dev/null +++ b/client/platforms/ios/iosPairingQrOverlayWindow.h @@ -0,0 +1,21 @@ +#ifndef IOS_PAIRING_QR_OVERLAY_WINDOW_H +#define IOS_PAIRING_QR_OVERLAY_WINDOW_H + +#include +#include + +/** + * 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; +using AmneziaPairingQrOverlayBackHandler = std::function; + +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 diff --git a/client/platforms/ios/iosPairingQrOverlayWindow.mm b/client/platforms/ios/iosPairingQrOverlayWindow.mm new file mode 100644 index 000000000..d3d273ed9 --- /dev/null +++ b/client/platforms/ios/iosPairingQrOverlayWindow.mm @@ -0,0 +1,674 @@ +#include "platforms/ios/iosPairingQrOverlayWindow.h" + +#import +#import + +#include + +/** 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 () +@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(onScanned); + const bool hasBack = static_cast(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"); + } + }); +} diff --git a/client/ui/controllers/api/pairingUiController.cpp b/client/ui/controllers/api/pairingUiController.cpp index 1714c9172..55a4fe32c 100644 --- a/client/ui/controllers/api/pairingUiController.cpp +++ b/client/ui/controllers/api/pairingUiController.cpp @@ -8,8 +8,12 @@ #include #include #include +#include #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(); diff --git a/client/ui/controllers/api/pairingUiController.h b/client/ui/controllers/api/pairingUiController.h index 0c7ccc634..d044bb05d 100644 --- a/client/ui/controllers/api/pairingUiController.h +++ b/client/ui/controllers/api/pairingUiController.h @@ -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); diff --git a/client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml b/client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml index c28230748..d7893430d 100644 --- a/client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml +++ b/client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml @@ -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() + } } }