import UIKit import os.log import WebKit class TouchInterceptView: UIView { private weak var webView: UIView? private weak var overlayView: UIView? private var forwardTimer: Timer? private var lastTouchPoint: CGPoint? private let touchThreshold: CGFloat = 5.0 // pixels private let timerDelay: TimeInterval = 0.1 // seconds private var hasActiveCallCheck: (() -> Bool)? private var tilesActiveCheck: (() -> Bool)? func setupWithWebView(_ webView: UIView, overlayView: UIView) { self.webView = webView self.overlayView = overlayView // Ensure this view is transparent and doesn't interfere with display self.backgroundColor = .clear self.isOpaque = false // os_log(.debug, "TouchInterceptView: setupWithWebView - webView: %{public}s, overlayView: %{public}s", String(describing: webView), String(describing: overlayView)) } func setActiveCallCheck(_ check: @escaping () -> Bool) { self.hasActiveCallCheck = check } /// Healthy Homies (10/21): whether the face tiles are drawing the call. func setTilesActiveCheck(_ check: @escaping () -> Bool) { self.tilesActiveCheck = check } private func shouldInterceptTouches() -> Bool { // Check if there's an active call let hasActiveCall = hasActiveCallCheck?() ?? false return hasActiveCall } private func isInteractive(_ view: UIView) -> Bool { if view is UIControl { return true } if let grs = view.gestureRecognizers, !grs.isEmpty { return true } return false } private func nonGreedyInteractiveHitTest(in view: UIView, point: CGPoint, with event: UIEvent?) -> UIView? { // Traverse subviews in reverse order (frontmost first) for subview in view.subviews.reversed() where subview.isUserInteractionEnabled && !subview.isHidden && subview.alpha >= 0.01 { let converted = view.convert(point, to: subview) if subview.point(inside: converted, with: event) { if let hit = nonGreedyInteractiveHitTest(in: subview, point: converted, with: event) { return hit } } } // If no subview handled, return view itself only if truly interactive return isInteractive(view) && view.point(inside: point, with: event) ? view : nil } private func forwardClickToWeb(at pointInSelf: CGPoint) { guard let wk = webView as? WKWebView else { return } let locationInWeb = self.convert(pointInSelf, to: wk) let x = Int(locationInWeb.x) let y = Int(locationInWeb.y) let js = """ (() => { const x = \(x), y = \(y); const el = document.elementFromPoint(x, y); if (!el) return 'NO_ELEM'; const eventInit = { bubbles: true, cancelable: true, clientX: x, clientY: y }; const events = []; if (typeof TouchEvent === 'function') { try { const touchObj = { identifier: Date.now(), target: el, clientX: x, clientY: y }; const touchInit = { bubbles: true, cancelable: true, touches: [touchObj], targetTouches: [], changedTouches: [touchObj], shiftKey: false }; events.push(new TouchEvent('touchstart', touchInit)); events.push(new TouchEvent('touchend', touchInit)); } catch (e) { console.log('TouchEvent creation failed', e); } } events.push(new PointerEvent('pointerdown', { ...eventInit, pointerType: 'touch' })); events.push(new MouseEvent('mousedown', eventInit)); events.push(new PointerEvent('pointerup', { ...eventInit, pointerType: 'touch' })); events.push(new MouseEvent('mouseup', eventInit)); events.push(new MouseEvent('click', eventInit)); events.forEach(evt => el.dispatchEvent(evt)); console.log('Synthetic click sequence dispatched to', el); return el.tagName; })(); """ // os_log(.debug, "TouchInterceptView: forwardClickToWeb - (%{public}d,%{public}d)", x, y) wk.evaluateJavaScript(js) { _, error in if let error = error { // os_log(.error, "TouchInterceptView: JS error %{public}s", String(describing: error)) } else { // os_log(.debug, "TouchInterceptView: JS returned %{public}s", String(describing: result)) } } } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { // Check if we should intercept touches if !shouldInterceptTouches() { // Cancel any pending timer when not in a call forwardTimer?.invalidate() forwardTimer = nil lastTouchPoint = nil if let webView = self.webView { let webPoint = self.convert(point, to: webView) let result = webView.hitTest(webPoint, with: event) print("TouchInterceptView: hitTest - Not intercepting (no active call), direct WebView result \(String(describing: result)) at \(webPoint)") return result } return nil } print("TouchInterceptView: hitTest intercepting touch at \(point)") // Healthy Homies (10/21), the swipe fix: while the face tiles show, // nothing native takes a touch (the stage ignores them), so the page // gets every touch for real and no click is replayed 0.1s later. The // replay is what made a swipe starting on a call button press it. if tilesActiveCheck?() == true { forwardTimer?.invalidate() forwardTimer = nil lastTouchPoint = nil if let webView = self.webView { return webView.hitTest(self.convert(point, to: webView), with: event) } return nil } // Check if this is same touch location continuing if let lastPoint = lastTouchPoint { let distance = sqrt(pow(point.x - lastPoint.x, 2) + pow(point.y - lastPoint.y, 2)) if distance <= touchThreshold { // Same touch continuing, cancel existing timer forwardTimer?.invalidate() // os_log(.debug, "TouchInterceptView: Touch continuing at %{public}s, cancelling timer", String(describing: point)) } } // Store current point and start new timer lastTouchPoint = point forwardTimer?.invalidate() forwardTimer = Timer.scheduledTimer(withTimeInterval: timerDelay, repeats: false) { [weak self] _ in guard let self = self, self.shouldInterceptTouches() else { // os_log(.debug, "TouchInterceptView: Timer fired but no longer in call, skipping web forward") return } // os_log(.debug, "TouchInterceptView: Timer fired, forwarding click to web at %{public}s", String(describing: point)) self.forwardClickToWeb(at: point) } // 1. interactive hit on overlay (including root) if let overlayView = self.overlayView, !overlayView.isHidden { let overlayPoint = self.convert(point, to: overlayView) if let overlayHit = nonGreedyInteractiveHitTest(in: overlayView, point: overlayPoint, with: event) { print("TouchInterceptView: hitTest - Hit overlay view \(String(describing: overlayHit)) at \(overlayPoint)") return overlayHit } } // 2. webView fallback if let webView = self.webView { let webPoint = self.convert(point, to: webView) let result = webView.hitTest(webPoint, with: event) print("TouchInterceptView: hitTest - WebView fallback result \(String(describing: result)) at \(webPoint)") return result } print("TouchInterceptView: hitTest - No view found for \(point)") return nil } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { // Check if we should intercept touches if !shouldInterceptTouches() { guard let webView = self.webView else { return super.point(inside: point, with: event) } let webViewPoint = self.convert(point, to: webView) let result = webView.point(inside: webViewPoint, with: event) // os_log(.debug, "TouchInterceptView: point(inside) - Not intercepting, WebView only (%{public}s at %{public}s) for original point %{public}s = %s", String(describing: result), String(describing: webViewPoint), String(describing: point), String(describing: result)) return result } guard let webView = self.webView else { // os_log(.debug, "TouchInterceptView: point(inside) - webView is nil for point %{public}s. Checking overlay or deferring to super.", String(describing: point)) if let overlayView = self.overlayView, !overlayView.isHidden { let overlayPoint = self.convert(point, to: overlayView) let overlayViewConsidersPointInside = overlayView.point(inside: overlayPoint, with: event) // os_log(.debug, "TouchInterceptView: point(inside) - webView nil. Overlay (%{public}s) for converted point %{public}s = %s", String(describing: overlayViewConsidersPointInside), String(describing: overlayPoint), String(describing: overlayViewConsidersPointInside)) return overlayViewConsidersPointInside } return super.point(inside: point, with: event) } let webViewPoint = self.convert(point, to: webView) let webViewConsidersPointInside = webView.point(inside: webViewPoint, with: event) if let overlayView = self.overlayView, !overlayView.isHidden { let overlayPoint = self.convert(point, to: overlayView) let overlayViewConsidersPointInside = overlayView.point(inside: overlayPoint, with: event) let result = webViewConsidersPointInside || overlayViewConsidersPointInside // os_log(.debug, "TouchInterceptView: point(inside) - WebView (%{public}s at %{public}s) OR Visible Overlay (%{public}s at %{public}s) for original point %{public}s = %s", String(describing: webViewConsidersPointInside), String(describing: webViewPoint), String(describing: overlayViewConsidersPointInside), String(describing: overlayPoint), String(describing: point), String(describing: result)) return result } else { if self.overlayView == nil { // os_log(.debug, "TouchInterceptView: point(inside) - Overlay nil. WebView (%{public}s at %{public}s) for original point %{public}s = %s", String(describing: webViewConsidersPointInside), String(describing: webViewPoint), String(describing: point), String(describing: webViewConsidersPointInside)) } else { // os_log(.debug, "TouchInterceptView: point(inside) - Overlay hidden. WebView (%{public}s at %{public}s) for original point %{public}s = %s", String(describing: webViewConsidersPointInside), String(describing: webViewPoint), String(describing: point), String(describing: webViewConsidersPointInside)) } return webViewConsidersPointInside } } }