Files
amnezia-client/client/platforms/ios/iosPairingCameraAccess.mm
T
2026-05-08 22:36:53 +03:00

401 lines
16 KiB
Plaintext

#include "platforms/ios/iosPairingCameraAccess.h"
#import <AVFoundation/AVFoundation.h>
#import <UIKit/UIKit.h>
static UIViewController *amneziaKeyWindowViewController(void)
{
UIApplication *app = [UIApplication sharedApplication];
if (@available(iOS 13.0, *)) {
for (UIScene *scene in app.connectedScenes) {
if (scene.activationState != UISceneActivationStateForegroundActive) {
continue;
}
if (![scene isKindOfClass:[UIWindowScene class]]) {
continue;
}
UIWindowScene *windowScene = (UIWindowScene *)scene;
for (UIWindow *window in windowScene.windows) {
if (window.isKeyWindow && window.rootViewController) {
return window.rootViewController;
}
}
for (UIWindow *window in windowScene.windows) {
if (!window.isHidden && window.rootViewController) {
return window.rootViewController;
}
}
}
}
for (UIWindow *window in app.windows) {
if (window.isKeyWindow && window.rootViewController) {
return window.rootViewController;
}
}
for (UIWindow *window in app.windows) {
if (window.rootViewController) {
return window.rootViewController;
}
}
return nil;
}
/** QML window is shorter than UIKit UIWindow (e.g. 759 vs 852); camera preview covers full window — dim strips in safe areas. */
static UIView *s_pairingSafeTopDim = nil;
static UIView *s_pairingSafeBottomDim = nil;
static id s_pairingSafeDimOrientationToken = nil;
/** Qt root for layout: bottom strip fills from maxY to host bottom when Qt view is shorter than host. */
static __unsafe_unretained UIView *s_pairingDimQtRoot = nil;
/** QML-driven tab band (e.g. scanDimBleedBottom) added to safe bottom when Qt root is full height. */
static int s_pairingNativeBottomExtraPt = 0;
/** Opaque strip on UIWindow.layer above AVCaptureVideoPreviewLayer — preview can composite above UIDropShadowView subviews. */
static CALayer *s_pairingWindowBottomMaskLayer = nil;
static UIColor *amneziaPairingBottomChromeOpaqueColor(void)
{
return [UIColor colorWithRed:(CGFloat)(28.0 / 255.0) green:(CGFloat)(29.0 / 255.0) blue:(CGFloat)(33.0 / 255.0) alpha:1.0f];
}
static CALayer *amneziaFindVideoPreviewLayerInWindow(UIWindow *window)
{
if (!window) {
return nil;
}
for (CALayer *ly in window.layer.sublayers) {
if ([ly isKindOfClass:[AVCaptureVideoPreviewLayer class]]) {
return ly;
}
}
return nil;
}
static void amneziaRemovePairingWindowBottomMaskLayer(void)
{
[s_pairingWindowBottomMaskLayer removeFromSuperlayer];
s_pairingWindowBottomMaskLayer = nil;
}
/** Maps bottom strip from host (UIDropShadowView) into window coords and stacks above camera preview on window.layer. */
static void amneziaSyncPairingWindowBottomMaskLayer(UIWindow *window, UIView *host, CGFloat bottomY, CGFloat bottomH, CGFloat width)
{
if (!window || !host || bottomH < 0.5f) {
amneziaRemovePairingWindowBottomMaskLayer();
return;
}
CGRect hostRect = CGRectMake(0, bottomY, width, bottomH);
CGRect winRect = [window convertRect:hostRect fromView:host];
if (!s_pairingWindowBottomMaskLayer) {
s_pairingWindowBottomMaskLayer = [[CALayer alloc] init];
s_pairingWindowBottomMaskLayer.backgroundColor = amneziaPairingBottomChromeOpaqueColor().CGColor;
}
s_pairingWindowBottomMaskLayer.frame = winRect;
s_pairingWindowBottomMaskLayer.zPosition = -500.f;
CALayer *preview = amneziaFindVideoPreviewLayerInWindow(window);
if (preview) {
[window.layer insertSublayer:s_pairingWindowBottomMaskLayer above:preview];
} else if (s_pairingWindowBottomMaskLayer.superlayer) {
[s_pairingWindowBottomMaskLayer removeFromSuperlayer];
}
}
static void amneziaRemovePairingSafeAreaDimStrips(void)
{
if (s_pairingSafeDimOrientationToken) {
[[NSNotificationCenter defaultCenter] removeObserver:s_pairingSafeDimOrientationToken];
s_pairingSafeDimOrientationToken = nil;
}
[s_pairingSafeTopDim removeFromSuperview];
[s_pairingSafeBottomDim removeFromSuperview];
s_pairingSafeTopDim = nil;
s_pairingSafeBottomDim = nil;
s_pairingDimQtRoot = nil;
amneziaRemovePairingWindowBottomMaskLayer();
}
static void amneziaLayoutPairingSafeAreaDimStrips(void)
{
if (!s_pairingSafeTopDim || !s_pairingSafeTopDim.superview) {
return;
}
UIWindow *w = s_pairingSafeTopDim.window;
UIView *host = s_pairingSafeTopDim.superview;
if (!w || !host) {
return;
}
UIEdgeInsets insets = w.safeAreaInsets;
CGRect hb = host.bounds;
s_pairingSafeTopDim.frame = CGRectMake(0, 0, hb.size.width, insets.top);
CGFloat bottomY = hb.size.height - insets.bottom;
CGFloat bottomH = insets.bottom;
UIView *qt = s_pairingDimQtRoot;
CGFloat qMaxYForLog = hb.size.height;
if (qt && qt.superview) {
CGRect qInHost = [host convertRect:qt.bounds fromView:qt];
const CGFloat qMaxY = CGRectGetMaxY(qInHost);
qMaxYForLog = qMaxY;
if (qMaxY < hb.size.height - 0.5f) {
bottomY = qMaxY;
bottomH = hb.size.height - qMaxY;
} else if (s_pairingNativeBottomExtraPt > 0) {
bottomH = insets.bottom + (CGFloat)s_pairingNativeBottomExtraPt;
bottomY = hb.size.height - bottomH;
}
}
if (bottomH < 0.5f) {
bottomH = 0.f;
bottomY = hb.size.height;
} else {
/** Pull strip slightly past layout bounds — QML vs UIKit gap can leave a hairline of preview at the physical bottom. */
const CGFloat kBottomOverscanPt = 12.f;
bottomH += kBottomOverscanPt;
bottomY = hb.size.height - bottomH;
if (bottomY < insets.top + 2.f) {
bottomY = insets.top + 2.f;
bottomH = hb.size.height - bottomY;
}
/** Stack↔tab hairline: preview on UIWindow.layer can leak slightly above computed strip top vs Qt chrome. */
const CGFloat kHairlineCoverUpPt = 4.f;
bottomY -= kHairlineCoverUpPt;
bottomH += kHairlineCoverUpPt;
if (bottomY < insets.top + 2.f) {
bottomH -= (insets.top + 2.f) - bottomY;
bottomY = insets.top + 2.f;
}
}
s_pairingSafeBottomDim.frame = CGRectMake(0, bottomY, hb.size.width, bottomH);
amneziaSyncPairingWindowBottomMaskLayer(w, host, bottomY, bottomH, hb.size.width);
CGRect winStrip = [w convertRect:s_pairingSafeBottomDim.frame fromView:host];
CALayer *previewLy = amneziaFindVideoPreviewLayerInWindow(w);
CGRect previewWin = CGRectZero;
if (previewLy) {
previewWin = [w.layer convertRect:previewLy.bounds fromLayer:previewLy];
}
CGRect maskWin = s_pairingWindowBottomMaskLayer ? s_pairingWindowBottomMaskLayer.frame : CGRectZero;
CGRect qtWin = CGRectZero;
if (qt) {
qtWin = [w convertRect:qt.bounds fromView:qt];
}
NSLog(@"[PairingCamera] safeAreaDim bottom strip y=%.1f h=%.1f qtMaxY=%.1f hostH=%.1f safeBottom=%.1f extraPt=%d winMask=%d",
bottomY, bottomH, qMaxYForLog, hb.size.height, insets.bottom, s_pairingNativeBottomExtraPt,
(int)(s_pairingWindowBottomMaskLayer.superlayer != nil));
NSLog(@"[PairingCamera] geom winStrip={{%.1f,%.1f},{%.1f,%.1f}} qtWin={{%.1f,%.1f},{%.1f,%.1f}} previewWin={{%.1f,%.1f},{%.1f,%.1f}} "
@"maskWin={{%.1f,%.1f},{%.1f,%.1f}} dyStripTopMinusPreviewTop=%.1f dyStripTopMinusQtMaxY=%.1f",
winStrip.origin.x, winStrip.origin.y, winStrip.size.width, winStrip.size.height, qtWin.origin.x, qtWin.origin.y,
qtWin.size.width, qtWin.size.height, previewWin.origin.x, previewWin.origin.y, previewWin.size.width,
previewWin.size.height, maskWin.origin.x, maskWin.origin.y, maskWin.size.width, maskWin.size.height,
CGRectGetMinY(winStrip) - CGRectGetMinY(previewWin), CGRectGetMinY(winStrip) - CGRectGetMaxY(qtWin));
}
static void amneziaInstallPairingSafeAreaDimStrips(UIWindow *window, UIView *qtRootView)
{
amneziaRemovePairingSafeAreaDimStrips();
if (!window || !qtRootView) {
return;
}
UIView *host = qtRootView.superview ?: window;
if (!host) {
return;
}
UIEdgeInsets insets = window.safeAreaInsets;
CGRect hb = host.bounds;
UIColor *dim = [UIColor colorWithWhite:0 alpha:0.55f];
UIColor *bottomChromeOpaque = amneziaPairingBottomChromeOpaqueColor();
s_pairingSafeTopDim = [[UIView alloc] initWithFrame:CGRectMake(0, 0, hb.size.width, insets.top)];
s_pairingSafeTopDim.backgroundColor = dim;
s_pairingSafeTopDim.opaque = NO;
s_pairingSafeTopDim.layer.opaque = NO;
s_pairingSafeTopDim.userInteractionEnabled = NO;
s_pairingSafeTopDim.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleBottomMargin;
s_pairingSafeBottomDim = [[UIView alloc] initWithFrame:CGRectMake(0, hb.size.height - insets.bottom, hb.size.width, insets.bottom)];
s_pairingSafeBottomDim.backgroundColor = bottomChromeOpaque;
s_pairingSafeBottomDim.opaque = YES;
s_pairingSafeBottomDim.layer.opaque = YES;
s_pairingSafeBottomDim.userInteractionEnabled = NO;
s_pairingSafeBottomDim.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin;
s_pairingDimQtRoot = qtRootView;
[host insertSubview:s_pairingSafeTopDim belowSubview:qtRootView];
[host insertSubview:s_pairingSafeBottomDim belowSubview:qtRootView];
NSLog(@"[PairingCamera] safeAreaDim host=%@ top=%.1f bottomInset=%.1f qtFrame=%@ hostBounds=%@ window=%@",
NSStringFromClass(host.class), insets.top, insets.bottom, NSStringFromCGRect(qtRootView.frame), NSStringFromCGRect(hb),
NSStringFromCGRect(window.bounds));
s_pairingSafeDimOrientationToken = [[NSNotificationCenter defaultCenter]
addObserverForName:UIDeviceOrientationDidChangeNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(__unused NSNotification *note) {
dispatch_async(dispatch_get_main_queue(), ^{
amneziaLayoutPairingSafeAreaDimStrips();
});
}];
}
static void amneziaApplyUnderlayTransparencyToView(UIView *view, BOOL transparent, NSUInteger depth, NSUInteger maxDepth)
{
if (!view || depth > maxDepth) {
return;
}
if (transparent) {
view.opaque = NO;
view.backgroundColor = [UIColor clearColor];
view.layer.opaque = NO;
} else {
view.opaque = YES;
view.backgroundColor = [UIColor blackColor];
view.layer.opaque = YES;
}
if (depth < maxDepth) {
for (UIView *child in view.subviews) {
amneziaApplyUnderlayTransparencyToView(child, transparent, depth + 1, maxDepth);
}
}
}
/** 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];
return status == AVAuthorizationStatusAuthorized;
}
void amneziaIosRequestPairingCameraAccess(const std::function<void(bool)> &onDone)
{
const AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
if (status == AVAuthorizationStatusAuthorized) {
onDone(true);
return;
}
if (status == AVAuthorizationStatusDenied || status == AVAuthorizationStatusRestricted) {
onDone(false);
return;
}
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo
completionHandler:^(BOOL granted) {
dispatch_async(dispatch_get_main_queue(), ^{
onDone(static_cast<bool>(granted));
});
}];
}
void amneziaIosOpenApplicationSettings()
{
NSURL *url = [NSURL URLWithString:UIApplicationOpenSettingsURLString];
if (url != nil) {
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}
}
void amneziaIosPairingRelayoutChromeIfNeeded(void)
{
if (!s_pairingSafeBottomDim || !s_pairingSafeBottomDim.superview) {
return;
}
amneziaLayoutPairingSafeAreaDimStrips();
}
void amneziaIosSetPairingEmbeddedCameraNativeBottomExtraPt(int extraPt)
{
const int v = extraPt < 0 ? 0 : extraPt;
if (s_pairingNativeBottomExtraPt == v) {
return;
}
s_pairingNativeBottomExtraPt = v;
void (^relayout)(void) = ^{
amneziaLayoutPairingSafeAreaDimStrips();
};
if ([NSThread isMainThread]) {
relayout();
} else {
dispatch_async(dispatch_get_main_queue(), relayout);
}
}
void amneziaIosApplyEmbeddedCameraUnderlayToQtView(bool enable)
{
void (^work)(void) = ^{
UIViewController *vc = amneziaKeyWindowViewController();
if (!vc || !vc.view) {
NSLog(@"[PairingCamera] amneziaIosApplyEmbeddedCameraUnderlayToQtView: no root VC (enable=%d)", (int)enable);
return;
}
UIView *root = vc.view;
UIWindow *win = root.window;
if (!enable) {
s_pairingNativeBottomExtraPt = 0;
amneziaRemovePairingSafeAreaDimStrips();
}
if (enable) {
vc.edgesForExtendedLayout = UIRectEdgeAll;
if (@available(iOS 11.0, *)) {
vc.extendedLayoutIncludesOpaqueBars = YES;
}
}
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();
} else {
dispatch_sync(dispatch_get_main_queue(), work);
}
}