Уведомления CarPlay, панель вложений с Lottie, фикс reply preview, плавная анимация клавиатуры, стабильность WebSocket

This commit is contained in:
2026-03-22 01:58:13 +05:00
parent 65e5991f97
commit 9289bb2efd
20 changed files with 645 additions and 172 deletions

View File

@@ -323,7 +323,9 @@ final class ProtocolManager: @unchecked Sendable {
sendPacketDirect(handshake)
// Timeout
// Timeout force reconnect instead of permanent disconnect.
// `client.disconnect()` sets `isManuallyClosed = true` which kills all
// future reconnection attempts. Use `forceReconnect()` to retry.
handshakeTimeoutTask?.cancel()
handshakeTimeoutTask = Task { [weak self] in
do {
@@ -333,8 +335,13 @@ final class ProtocolManager: @unchecked Sendable {
}
guard let self, !Task.isCancelled else { return }
if !self.handshakeComplete {
Self.logger.error("Handshake timeout")
self.client.disconnect()
Self.logger.error("Handshake timeout — forcing reconnect")
self.handshakeComplete = false
self.heartbeatTask?.cancel()
Task { @MainActor in
self.connectionState = .connecting
}
self.client.forceReconnect()
}
}
}

View File

@@ -126,6 +126,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
}
task.send(.data(data)) { [weak self] error in
guard let self else { return }
// Ignore errors from old sockets after forceReconnect.
guard task === self.webSocketTask else { return }
if let error {
Self.logger.error("Send error: \(error.localizedDescription)")
onFailure?(error)
@@ -138,9 +140,12 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
func sendText(_ text: String) {
guard let task = webSocketTask else { return }
task.send(.string(text)) { [weak self] error in
guard let self else { return }
// Ignore errors from old sockets after forceReconnect.
guard task === self.webSocketTask else { return }
if let error {
Self.logger.error("Send text error: \(error.localizedDescription)")
self?.handleDisconnect(error: error)
self.handleDisconnect(error: error)
}
}
}
@@ -162,6 +167,11 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
Self.logger.info("WebSocket didOpen")
// Ignore callbacks from old (cancelled) sockets after forceReconnect.
guard webSocketTask === self.webSocketTask else {
Self.logger.info("didOpen ignored: stale socket (not current task)")
return
}
guard !isManuallyClosed else { return }
// Android parity: reset isConnecting on successful open (Protocol.kt onOpen).
isConnecting = false
@@ -177,6 +187,11 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
Self.logger.info("WebSocket didClose: \(closeCode.rawValue)")
// Ignore callbacks from old (cancelled) sockets after forceReconnect.
guard webSocketTask === self.webSocketTask else {
Self.logger.info("didClose ignored: stale socket (not current task)")
return
}
isConnecting = false
isConnected = false
handleDisconnect(error: nil)
@@ -189,6 +204,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
task.receive { [weak self] result in
guard let self, !isManuallyClosed else { return }
// Ignore callbacks from old (cancelled) sockets after forceReconnect.
guard task === self.webSocketTask else { return }
switch result {
case .success(let message):

View File

@@ -285,16 +285,15 @@ final class SessionManager {
password: attachmentPassword
)
// Upload encrypted blob to transport server (desktop: uploadFile)
let tag = try await TransportManager.shared.uploadFile(
id: attachmentId,
content: Data(encryptedBlob.utf8)
)
// Desktop parity: preview = "tag::blurhash" (same as IMAGE attachments)
// Cache avatar locally BEFORE upload so outgoing avatar shows instantly
// (same pattern as sendMessageWithAttachments AttachmentCache.saveImage before upload).
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey)
if let avatarImage {
AttachmentCache.shared.saveImage(avatarImage, forAttachmentId: attachmentId)
}
// BlurHash for preview (computed before upload so optimistic UI has it)
let blurhash = avatarImage?.blurHash(numberOfComponents: (4, 3)) ?? ""
let preview = "\(tag)::\(blurhash)"
// Build aesChachaKey with Latin-1 payload (desktop sync parity)
let aesChachaPayload = Data(latin1ForSync.utf8)
@@ -303,7 +302,7 @@ final class SessionManager {
password: privKey
)
// Build packet with avatar attachment
// Build packet with avatar attachment preview will be updated with tag after upload
var packet = PacketMessage()
packet.fromPublicKey = currentPublicKey
packet.toPublicKey = toPublicKey
@@ -316,8 +315,8 @@ final class SessionManager {
packet.attachments = [
MessageAttachment(
id: attachmentId,
preview: preview,
blob: "", // Desktop parity: blob cleared after upload
preview: blurhash, // Will be updated with "tag::blurhash" after upload
blob: "",
type: .avatar
),
]
@@ -333,21 +332,40 @@ final class SessionManager {
myPublicKey: currentPublicKey
)
// Optimistic UI
let isConnected = ProtocolManager.shared.connectionState == .authenticated
let offlineAsSend = !isConnected
// Optimistic UI show message IMMEDIATELY (before upload)
MessageRepository.shared.upsertFromMessagePacket(
packet, myPublicKey: currentPublicKey, decryptedText: " ",
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
fromSync: offlineAsSend
fromSync: false
)
DialogRepository.shared.updateDialogFromMessages(opponentKey: packet.toPublicKey)
if offlineAsSend {
// Upload encrypted blob to transport server in background (desktop: uploadFile)
let tag: String
do {
tag = try await TransportManager.shared.uploadFile(
id: attachmentId,
content: Data(encryptedBlob.utf8)
)
} catch {
// Upload failed mark as error
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
Self.logger.error("📤 Avatar upload failed: \(error.localizedDescription)")
throw error
}
// Update preview with CDN tag (tag::blurhash)
let preview = "\(tag)::\(blurhash)"
packet.attachments = [
MessageAttachment(
id: attachmentId,
preview: preview,
blob: "", // Desktop parity: blob cleared after upload
type: .avatar
),
]
// Saved Messages mark delivered locally but STILL send to server
// for cross-device avatar sync. Other devices receive via sync and
// update their local avatar cache.

View File

@@ -22,7 +22,7 @@ final class PerformanceLogger {
/// Enable for performance testing. Disabled by default zero overhead in production.
#if DEBUG
var isEnabled = false // Set to `true` to enable perf logging
var isEnabled = true // Set to `true` to enable perf logging
#else
var isEnabled = false
#endif

View File

@@ -11,26 +11,26 @@ enum ReleaseNotes {
Entry(
version: appVersion,
body: """
**Доставка сообщений**
Сообщения больше не теряются при потере сети. Автоматический повтор отправки при восстановлении соединения. Отправленные фото отображаются мгновенно.
**Уведомления**
Вибрация и бейдж работают когда приложение закрыто. Счётчик непрочитанных обновляется в фоне.
Поддержка CarPlay и фильтров Focus. Переход в чат по нажатию на уведомление из любого состояния приложения. Автопереключение на вкладку Chats.
**Фото и файлы**
Фото скачиваются только по тапу. Пересланные фото загружаются из кэша. Файлы открываются по тапу. Свайп между фото в полноэкранном просмотре. Плавное закрытие свайпом вниз.
**Сеть**
Автоматический реконнект при таймауте handshake. Защита от дублирования при переподключении.
**Клавиатура**
Плавная анимация подъёма и опускания инпута. Устранено дёрганье при закрытии клавиатуры.
**Вложения**
Новый экран отправки аватарки с анимацией. Обновлённый экран выбора файлов. Анимированный индикатор вкладок.
**Просмотр фото**
Жесты зума и навигации работают по всему экрану. Исправлен double-tap для сброса зума.
**Аватарки**
Превью аватарки отображается как размытое изображение до скачивания. Плавная анимация при загрузке. Скачивание по тапу.
**Шифрование**
Улучшена совместимость шифрования фото и файлов между устройствами. Пересланные фото корректно расшифровываются получателем.
**Производительность**
Оптимизация FPS клавиатуры и скролла в длинных переписках. Снижен нагрев устройства.
Мгновенное отображение до загрузки на сервер. Корректное отображение отправленных аватарок.
**Исправления**
Исправлена отправка фото и аватаров на Desktop. Исправлено шифрование пересланных сообщений. Исправлен бейдж непрочитанных в tab bar. Исправлен счётчик после синхронизации. Исправлены кнопки на iOS 26+. Группировка баблов для фото-сообщений. Saved Messages: иконка закладки.
Отображение типа вложения (Photo, Avatar, File) при ответе на сообщение. Стабильность WebSocket-соединения.
"""
)
]

View File

@@ -0,0 +1,124 @@
import SwiftUI
import UIKit
/// Wraps SwiftUI content in a UIKit container whose vertical position is animated
/// using the keyboard's exact Core Animation curve. This achieves Telegram-level
/// keyboard sync because both the keyboard and this container are animated by the
/// render server in the same Core Animation transaction zero relative movement.
///
/// On iOS 26+, SwiftUI handles keyboard natively this wrapper is a no-op passthrough.
struct KeyboardSyncedContainer<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
if #available(iOS 26, *) {
content
} else {
_KeyboardSyncedRepresentable(content: content)
.ignoresSafeArea(.keyboard)
}
}
}
// MARK: - UIViewControllerRepresentable bridge
private struct _KeyboardSyncedRepresentable<Content: View>: UIViewControllerRepresentable {
let content: Content
func makeUIViewController(context: Context) -> _KeyboardSyncedVC<Content> {
_KeyboardSyncedVC(rootView: content)
}
func updateUIViewController(_ vc: _KeyboardSyncedVC<Content>, context: Context) {
vc.hostingController.rootView = content
}
func sizeThatFits(
_ proposal: ProposedViewSize,
uiViewController vc: _KeyboardSyncedVC<Content>,
context: Context
) -> CGSize? {
let width = proposal.width ?? UIScreen.main.bounds.width
let fittingSize = vc.hostingController.sizeThatFits(
in: CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)
)
return CGSize(width: width, height: fittingSize.height)
}
}
// MARK: - UIKit view controller that animates with the keyboard
final class _KeyboardSyncedVC<Content: View>: UIViewController {
let hostingController: UIHostingController<Content>
private var bottomInset: CGFloat = 34
init(rootView: Content) {
hostingController = UIHostingController(rootView: rootView)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
addChild(hostingController)
hostingController.view.backgroundColor = .clear
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hostingController.didMove(toParent: self)
// Read safe area bottom inset
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.keyWindow ?? scene.windows.first {
let bottom = window.safeAreaInsets.bottom
bottomInset = bottom < 50 ? bottom : 34
}
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillChangeFrame),
name: UIResponder.keyboardWillChangeFrameNotification,
object: nil
)
}
@objc private func keyboardWillChangeFrame(_ notification: Notification) {
guard let info = notification.userInfo,
let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int
else { return }
let screenHeight = UIScreen.main.bounds.height
let keyboardTop = endFrame.origin.y
let isVisible = keyboardTop < screenHeight
let endHeight = isVisible ? (screenHeight - keyboardTop) : 0
let padding = isVisible ? max(0, endHeight - bottomInset) : 0
// Animate with the KEYBOARD'S EXACT curve in the SAME Core Animation transaction.
// The render server interpolates both the keyboard position and our transform
// together for each frame pixel-perfect sync, zero gap variation.
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
UIView.animate(withDuration: duration, delay: 0, options: [options, .beginFromCurrentState]) {
self.view.transform = CGAffineTransform(translationX: 0, y: -padding)
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}

View File

@@ -40,14 +40,11 @@ final class KeyboardTracker: ObservableObject {
private var animTickCount = 0
private var animationNumber = 0
private var lastTickTime: CFTimeInterval = 0
/// Monotonic guard sync view can give non-monotonic eased values
/// in the first 2 ticks while Core Animation commits the animation.
private var lastEased: CGFloat = 0
// Presentation-layer sync: a hidden UIView animated with the keyboard's
// exact curve. Reading its presentation layer on each CADisplayLink tick
// gives us the real easing value no guessing control points.
private var syncView: UIView?
private var usingSyncAnimation = false
// Cubic bezier fallback (used when syncView is unavailable)
// Cubic bezier control points deterministic keyboard curve approximation.
private var bezierP1x: CGFloat = 0.25
private var bezierP1y: CGFloat = 0.1
private var bezierP2x: CGFloat = 0.25
@@ -86,6 +83,12 @@ final class KeyboardTracker: ObservableObject {
if #available(iOS 26, *) { return }
PerformanceLogger.shared.track("keyboard.kvo")
guard !isAnimating else { return }
#if DEBUG
let rawPad = max(0, keyboardHeight - bottomInset)
if abs(rawPad - keyboardPadding) > 4 {
print("⌨️ 👆 KVO | height=\(Int(keyboardHeight)) → pad=\(Int(rawPad)) | current=\(Int(keyboardPadding))")
}
#endif
if keyboardHeight <= 0 {
// Flush any pending KVO value and stop coalescing
@@ -183,9 +186,19 @@ final class KeyboardTracker: ObservableObject {
lastNotificationPadding = targetPadding
PerformanceLogger.shared.track("keyboard.notification")
// CADisplayLink at 30fps smooth interpolation synced with keyboard curve.
// BubbleContextMenuOverlay.updateUIView is SKIPPED during animation
// (isAnimatingKeyboard flag) eliminates 40+ UIKit bridge operations per tick.
#if DEBUG
let direction = targetPadding > keyboardPadding ? "⬆️ SHOW" : "⬇️ HIDE"
print("⌨️ \(direction) | current=\(Int(keyboardPadding)) → target=\(Int(targetPadding)) | delta=\(Int(delta)) | duration=\(String(format: "%.3f", duration))s | curve=\(curveRaw)")
#endif
// Filter spurious notifications (delta=0, duration=0) keyboard frame
// didn't actually change. Happens on some iOS versions during text input.
guard abs(delta) > 1 || targetPadding != keyboardPadding else { return }
// Animate with sync view (keyboard's exact CA curve) no pre-apply.
// Pre-apply caused a visible "jump" because input moved 16pt instantly
// before the keyboard had started moving. Without pre-apply, there may
// be ~1 frame of slight overlap (keyboard ahead of input) but it's
// imperceptible at 60Hz (16ms) and provides a much smoother feel.
if abs(delta) > 1, targetPadding != keyboardPadding {
isAnimatingKeyboard = true
startPaddingAnimation(to: targetPadding, duration: duration, curveRaw: curveRaw)
@@ -238,31 +251,32 @@ final class KeyboardTracker: ObservableObject {
}
}
// MARK: - CADisplayLink animation (legacy, kept for reference)
// MARK: - CADisplayLink animation (sync view + bezier fallback)
// Sync view: hidden UIView animated with keyboard's exact curve.
// Reading its presentation layer gives the real easing value no guessing.
private var syncView: UIView?
private func startPaddingAnimation(to target: CGFloat, duration: CFTimeInterval, curveRaw: Int) {
animationNumber += 1
animStartPadding = keyboardPadding
animTargetPadding = target
animStartTime = 0
animStartTime = CACurrentMediaTime()
animDuration = max(duration, 0.05)
animTickCount = 0
lastEased = 0
// Primary: sync with the keyboard's exact curve via presentation layer.
// UIView.animate called HERE lands in the same Core Animation transaction
// as the keyboard notification identical timing function and start time.
usingSyncAnimation = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
// Primary: sync view matches keyboard's exact curve (same CA transaction).
// No lead, no pre-apply the natural ~1 frame SwiftUI processing delay
// creates the "keyboard pushes input" effect (like Telegram).
let syncOK = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
// Fallback: cubic bezier approximation (CSS "ease").
if !usingSyncAnimation {
// Fallback: cubic bezier (only if sync view can't be created).
if !syncOK {
configureBezier(curveRaw: curveRaw)
}
// Reuse existing display link to preserve vsync phase alignment.
// Creating a new CADisplayLink on each animation resets the phase,
// causing alternating frame intervals (15/18ms instead of steady 16.6ms).
// No FPS cap runs at device native rate (120Hz ProMotion, 60Hz standard).
// BubbleContextMenuOverlay.updateUIView is a no-op, so per-tick cost is trivial.
if let proxy = displayLinkProxy {
proxy.isPaused = false
} else {
@@ -272,18 +286,13 @@ final class KeyboardTracker: ObservableObject {
}
}
/// Starts a hidden UIView animation matching the keyboard's exact curve.
/// Returns true if sync animation was set up successfully.
/// Creates a hidden UIView animated with the keyboard's exact curve.
private func setupSyncAnimation(duration: CFTimeInterval, curveRaw: Int) -> Bool {
guard let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene }).first?.keyWindow else {
return false
}
// Remove previous sync view completely a fresh view
// guarantees clean presentation layer with no animation history.
// Reusing the same view causes Core Animation to coalesce/skip
// repeated 01 opacity animations on subsequent calls.
syncView?.layer.removeAllAnimations()
syncView?.removeFromSuperview()
@@ -292,15 +301,8 @@ final class KeyboardTracker: ObservableObject {
window.addSubview(view)
syncView = view
// UIView.AnimationOptions encodes the curve in bits 16-19.
// For rawValue 7 (private keyboard curve), this passes through to
// Core Animation with Apple's exact timing function.
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
UIView.animate(
withDuration: duration,
delay: 0,
options: [options]
) {
UIView.animate(withDuration: duration, delay: 0, options: [options]) {
view.alpha = 1
}
@@ -308,33 +310,42 @@ final class KeyboardTracker: ObservableObject {
}
private func animationTick() {
let tickStart = CACurrentMediaTime()
PerformanceLogger.shared.track("keyboard.animTick")
animTickCount += 1
let now = CACurrentMediaTime()
lastTickTime = now
// Get eased fraction either from presentation layer (exact) or bezier (fallback).
let eased: CGFloat
var isComplete = false
if usingSyncAnimation, let presentation = syncView?.layer.presentation() {
let fraction = CGFloat(presentation.opacity)
eased = fraction
isComplete = fraction >= 0.999
} else {
// Bezier fallback
if animStartTime == 0 { animStartTime = now }
let elapsed = now - animStartTime
let timeComplete = elapsed >= animDuration
// Read eased fraction: sync view (exact) or bezier (fallback).
var eased: CGFloat
if let presentation = syncView?.layer.presentation() {
eased = min(max(CGFloat(presentation.opacity), 0), 1)
} else {
let t = min(elapsed / animDuration, 1.0)
eased = cubicBezierEase(t)
isComplete = t >= 1.0
}
// Guard: presentation layer can return NaN opacity during edge cases
// (window transition, sync view removed). NaN propagating to keyboardPadding
// causes `Color.clear.frame(height: NaN)` CoreGraphics NaN errors FPS freeze.
// Enforce monotonic progress sync view's presentation layer can give
// non-monotonic values in the first 2 ticks while Core Animation commits
// the animation to the render server. Without this guard, padding oscillates
// (e.g. 312 306 310 302) causing a visible "jerk".
if eased < lastEased {
eased = lastEased
}
lastEased = eased
// SHOW only: lead by ~1 frame to prevent keyboard overlapping input.
// The value we publish NOW is rendered by SwiftUI NEXT frame.
// Without lead, rendered input position is 1 frame behind keyboard overlap.
// 0.065 1 frame at 60Hz (16.6ms / 250ms). Continuous, not a jump.
// HIDE doesn't need lead natural 1-frame delay creates acceptable gap.
if animTargetPadding > animStartPadding {
eased = min(eased + 0.065, 1.0)
}
guard eased.isFinite else {
displayLinkProxy?.isPaused = true
lastTickTime = 0
@@ -342,28 +353,33 @@ final class KeyboardTracker: ObservableObject {
return
}
// Round to nearest 1pt now that BubbleContextMenuOverlay.updateUIView
// is a no-op and rows don't re-evaluate during keyboard animation,
// per-tick cost is trivial (just spacer + padded view). 1pt = maximum smoothness.
let raw = animStartPadding + (animTargetPadding - animStartPadding) * eased
let rounded = max(0, round(raw))
if isComplete || animTickCount > 30 {
if timeComplete || animTickCount > 30 {
let prevPadding = keyboardPadding
keyboardPadding = max(0, animTargetPadding)
// Pause instead of invalidate preserves vsync phase for next animation.
displayLinkProxy?.isPaused = true
lastTickTime = 0
isAnimatingKeyboard = false
} else if rounded != keyboardPadding {
keyboardPadding = rounded
}
#if DEBUG
let tickMs = (CACurrentMediaTime() - tickStart) * 1000
if tickMs > 16 {
PerformanceLogger.shared.track("keyboard.slowTick")
let elapsedMs = elapsed * 1000
print("⌨️ ✅ DONE | ticks=\(animTickCount) | final=\(Int(animTargetPadding)) | lastDelta=\(Int(animTargetPadding - prevPadding))pt | elapsed=\(String(format: "%.0f", elapsedMs))ms")
#endif
} else if rounded != keyboardPadding {
#if DEBUG
let prevPad = keyboardPadding
#endif
keyboardPadding = rounded
#if DEBUG
if animTickCount <= 5 {
let delta = Int(rounded - prevPad)
print("⌨️ TICK #\(animTickCount) | eased=\(String(format: "%.3f", eased)) | pad=\(Int(rounded)) | delta=\(delta)pt | elapsed=\(String(format: "%.1f", elapsed * 1000))ms")
}
#endif
}
}
// MARK: - Cubic bezier fallback
@@ -380,7 +396,8 @@ final class KeyboardTracker: ObservableObject {
bezierP1x = 0; bezierP1y = 0
bezierP2x = 1.0; bezierP2y = 1.0
default:
// CSS "ease" closest known approximation of curve 7.
// CSS "ease" approximation of iOS keyboard curve 7.
// Only used as fallback when sync view is unavailable.
bezierP1x = 0.25; bezierP1y = 0.1
bezierP2x = 0.25; bezierP2y = 1.0
}

View File

@@ -113,6 +113,7 @@ struct CallsView: View {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.fixedSize(horizontal: true, vertical: false)
.frame(height: 44)
.padding(.horizontal, 12)
}

View File

@@ -1,6 +1,7 @@
import SwiftUI
import Photos
import PhotosUI
import Lottie
// MARK: - AttachmentPanelView
@@ -33,6 +34,9 @@ struct AttachmentPanelView: View {
@State private var capturedImage: UIImage?
@State private var captionText: String = ""
@State private var previewAsset: IdentifiableAsset?
/// Tab widths/origins for sliding selection indicator (RosettaTabBar parity).
@State private var tabWidths: [AttachmentTab: CGFloat] = [:]
@State private var tabOrigins: [AttachmentTab: CGFloat] = [:]
private var hasSelection: Bool { !selectedAssets.isEmpty }
@@ -60,8 +64,7 @@ struct AttachmentPanelView: View {
case .file:
fileTabContent
case .avatar:
// Avatar is an action tab handled in tabButton tap
Spacer()
avatarTabContent
}
Spacer(minLength: 0)
@@ -104,6 +107,8 @@ struct AttachmentPanelView: View {
)
.background(TransparentFullScreenBackground())
}
.onPreferenceChange(AttachTabWidthKey.self) { tabWidths.merge($0) { _, new in new } }
.onPreferenceChange(AttachTabOriginKey.self) { tabOrigins.merge($0) { _, new in new } }
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.attachmentCornerRadius(20)
@@ -200,19 +205,85 @@ struct AttachmentPanelView: View {
}
}
// MARK: - Avatar Tab Content (Android parity: AttachAlertAvatarLayout)
/// Dedicated avatar tab with Lottie animation and "Send Avatar" button.
/// Android: `AttachAlertAvatarLayout.kt` looping Lottie + title + subtitle + button.
private var avatarTabContent: some View {
VStack(spacing: 16) {
Spacer()
// Lottie animation (Android: avatar.json, 100×100dp, infinite loop)
LottieView(
animationName: "avatar",
loopMode: .loop,
animationSpeed: 1.0
)
.frame(width: 100, height: 100)
// Title
Text("Send Avatar")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
// Subtitle
Text("Share your profile avatar\nwith this contact")
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.5))
.multilineTextAlignment(.center)
// Send button (capsule style matching File tab's "Browse Files" button)
Button {
if hasAvatar {
onSendAvatar()
dismiss()
} else {
dismiss()
onSetAvatar?()
}
} label: {
Text(hasAvatar ? "Send Avatar" : "Set Avatar")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(Color(hex: 0x008BFF), in: Capsule())
}
Spacer()
// Reserve space for tab bar so content doesn't get clipped
Color.clear.frame(height: 70)
}
.frame(maxWidth: .infinity)
}
// MARK: - File Tab Content
private var fileTabContent: some View {
VStack(spacing: 20) {
VStack(spacing: 16) {
Spacer()
Image(systemName: "doc.fill")
.font(.system(size: 48))
.foregroundStyle(.white.opacity(0.3))
// Lottie animation (matching avatar tab layout)
LottieView(
animationName: "file_folder",
loopMode: .loop,
animationSpeed: 1.0
)
.frame(width: 100, height: 100)
// Title
Text("Send File")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
// Subtitle
Text("Select a file to send")
.font(.system(size: 16))
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.5))
.multilineTextAlignment(.center)
// Browse button (capsule style matching avatar tab)
Button {
showFilePicker = true
} label: {
@@ -225,6 +296,9 @@ struct AttachmentPanelView: View {
}
Spacer()
// Reserve space for tab bar so content doesn't get clipped
Color.clear.frame(height: 70)
}
.frame(maxWidth: .infinity)
.task {
@@ -315,28 +389,66 @@ struct AttachmentPanelView: View {
TelegramGlassRoundedRect(cornerRadius: 21)
}
// MARK: - Tab Bar (Figma: glass capsule, 3 tabs)
// MARK: - Tab Bar (RosettaTabBar parity: glass capsule + sliding indicator)
/// Glass capsule tab bar matching RosettaTabBar pattern.
/// Glass capsule tab bar matching RosettaTabBar pattern exactly.
/// Tabs: Gallery | File | Avatar.
/// Colors from RosettaTabBar: selected=#008BFF, unselected=white.
/// Background: .regularMaterial (iOS < 26) / .glassEffect (iOS 26+).
/// Selection indicator: sliding glass pill behind selected tab (spring animation).
private var tabBar: some View {
HStack(spacing: 0) {
tabButton(.gallery, icon: "photo.fill", label: "Gallery")
.background(tabWidthReader(.gallery))
tabButton(.file, icon: "doc.fill", label: "File")
.background(tabWidthReader(.file))
tabButton(.avatar, icon: "person.crop.circle.fill", label: "Avatar")
.background(tabWidthReader(.avatar))
}
.padding(4)
.background { tabBarBackground }
.coordinateSpace(name: "attachTabBar")
.background(alignment: .leading) {
// Sliding selection indicator (RosettaTabBar parity)
attachmentSelectionIndicator
}
.background { TelegramGlassCapsule() }
.clipShape(Capsule())
.contentShape(Capsule())
.tabBarShadow()
}
/// Glass background matching RosettaTabBar (lines 136149).
private var tabBarBackground: some View {
TelegramGlassCapsule()
/// Reads tab width and origin for selection indicator positioning.
private func tabWidthReader(_ tab: AttachmentTab) -> some View {
GeometryReader { geo in
Color.clear.preference(
key: AttachTabOriginKey.self,
value: [tab: geo.frame(in: .named("attachTabBar")).origin.x]
)
.preference(
key: AttachTabWidthKey.self,
value: [tab: geo.size.width]
)
}
}
/// Sliding glass pill behind the selected tab (matches RosettaTabBar).
@ViewBuilder
private var attachmentSelectionIndicator: some View {
let width = tabWidths[selectedTab] ?? 0
let xOffset = tabOrigins[selectedTab] ?? 0
if #available(iOS 26, *) {
Capsule().fill(.clear)
.glassEffect(.regular, in: .capsule)
.allowsHitTesting(false)
.frame(width: width)
.offset(x: xOffset)
.animation(.spring(response: 0.34, dampingFraction: 0.82), value: selectedTab)
} else {
Capsule().fill(.thinMaterial)
.frame(width: max(0, width - 4))
.padding(.vertical, 4)
.offset(x: xOffset + 2)
.animation(.spring(response: 0.34, dampingFraction: 0.82), value: selectedTab)
}
}
/// Individual tab button matching RosettaTabBar dimensions exactly.
@@ -345,21 +457,9 @@ struct AttachmentPanelView: View {
let isSelected = selectedTab == tab
return Button {
if tab == .avatar {
if hasAvatar {
onSendAvatar()
dismiss()
} else {
// No avatar set offer to set one
dismiss()
onSetAvatar?()
}
return
} else {
withAnimation(.easeInOut(duration: 0.2)) {
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
selectedTab = tab
}
}
} label: {
VStack(spacing: 2) {
Image(systemName: icon)
@@ -372,11 +472,6 @@ struct AttachmentPanelView: View {
.foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white)
.frame(minWidth: 66, maxWidth: .infinity)
.padding(.vertical, 6)
.background {
if isSelected {
TelegramGlassCapsule()
}
}
}
.buttonStyle(.plain)
}
@@ -457,6 +552,22 @@ private enum AttachmentTab: Hashable {
case avatar
}
// MARK: - Tab Bar Preference Keys (selection indicator positioning)
private struct AttachTabWidthKey: PreferenceKey {
static var defaultValue: [AttachmentTab: CGFloat] = [:]
static func reduce(value: inout [AttachmentTab: CGFloat], nextValue: () -> [AttachmentTab: CGFloat]) {
value.merge(nextValue()) { _, new in new }
}
}
private struct AttachTabOriginKey: PreferenceKey {
static var defaultValue: [AttachmentTab: CGFloat] = [:]
static func reduce(value: inout [AttachmentTab: CGFloat], nextValue: () -> [AttachmentTab: CGFloat]) {
value.merge(nextValue()) { _, new in new }
}
}
// MARK: - IdentifiableAsset
/// Wrapper to make PHAsset usable with SwiftUI `.fullScreenCover(item:)`.

View File

@@ -181,7 +181,7 @@ struct ChatDetailView: View {
}
.overlay { chatEdgeGradients }
// FPS overlay uncomment for performance testing:
// .overlay { FPSOverlayView() }
.overlay { FPSOverlayView() }
.overlay(alignment: .bottom) {
if !route.isSystemAccount {
KeyboardPaddedView {
@@ -466,10 +466,11 @@ private extension ChatDetailView {
color: .white
)
.frame(width: 11, height: 20)
.allowsHitTesting(false)
}
.frame(width: 36, height: 36)
.contentShape(Circle())
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
.buttonStyle(.plain)
.accessibilityLabel("Back")
}
@@ -479,6 +480,7 @@ private extension ChatDetailView {
.padding(.horizontal, 12)
.frame(minWidth: 120)
.frame(height: 44)
.contentShape(Capsule())
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
@@ -490,6 +492,7 @@ private extension ChatDetailView {
Button { openProfile() } label: {
ChatDetailToolbarAvatar(route: route, size: 35)
.frame(width: 36, height: 36)
.contentShape(Circle())
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
.buttonStyle(.plain)
@@ -508,6 +511,7 @@ private extension ChatDetailView {
.padding(.horizontal, 16)
.frame(minWidth: 120)
.frame(height: 44)
.contentShape(Capsule())
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
@@ -519,6 +523,7 @@ private extension ChatDetailView {
Button { openProfile() } label: {
ChatDetailToolbarAvatar(route: route, size: 38)
.frame(width: 44, height: 44)
.contentShape(Circle())
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
.buttonStyle(.plain)
@@ -533,10 +538,10 @@ private extension ChatDetailView {
color: .white
)
.frame(width: 11, height: 20)
.allowsHitTesting(false)
.frame(width: 36, height: 36)
.frame(height: 44)
.padding(.horizontal, 4)
.contentShape(Capsule())
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
@@ -790,9 +795,8 @@ private extension ChatDetailView {
scroll
.scrollIndicators(.hidden)
.overlay(alignment: .bottom) {
KeyboardPaddedView(extraPadding: composerHeight + 4) {
scrollToBottomButton(proxy: proxy)
}
.padding(.bottom, composerHeight + 4)
}
}
}
@@ -1766,6 +1770,13 @@ private extension ChatDetailView {
func openProfile() {
guard !route.isSavedMessages, !route.isSystemAccount else { return }
isInputFocused = false
// Force-dismiss keyboard at UIKit level immediately.
// On iOS 26+, the async resignFirstResponder via syncFocus races with
// the navigation transition the system may re-focus the text view
// when returning from the profile, causing a ghost keyboard.
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil
)
showOpponentProfile = true
}
@@ -2109,17 +2120,31 @@ private extension ChatDetailView {
func replyBar(for message: ChatMessage) -> some View {
let senderName = senderDisplayName(for: message.fromPublicKey)
let previewText: String = {
let trimmed = message.text.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty { return message.text }
if message.attachments.contains(where: { $0.type == .image }) { return "Photo" }
// Attachment type labels check BEFORE text so photo/avatar messages
// always show their type even if text contains invisible characters.
if message.attachments.contains(where: { $0.type == .image }) {
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
return caption.isEmpty ? "Photo" : caption
}
if let file = message.attachments.first(where: { $0.type == .file }) {
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !caption.isEmpty { return caption }
// Parse filename from preview (tag::fileSize::fileName)
let parts = file.preview.components(separatedBy: "::")
if parts.count >= 3 { return parts[2] }
return file.id.isEmpty ? "File" : file.id
}
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
// No known attachment type fall back to text
let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return message.text }
if !message.attachments.isEmpty { return "Attachment" }
return ""
}()
#if DEBUG
let _ = print("📋 REPLY: preview='\(previewText.prefix(30))' text='\(message.text.prefix(30))' textHex=\(Array(message.text.utf8).prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")) atts=\(message.attachments.count) types=\(message.attachments.map { $0.type.rawValue })")
#endif
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.5)

View File

@@ -37,24 +37,15 @@ struct FullScreenImageViewer: View {
.opacity(backgroundOpacity)
.ignoresSafeArea()
// Zoomable image
// Zoomable image (visual only no gestures here)
Image(uiImage: image)
.resizable()
.scaledToFit()
.scaleEffect(scale)
.offset(x: offset.width, y: offset.height + dismissOffset)
.gesture(dragGesture)
.gesture(pinchGesture)
.onTapGesture(count: 2) {
doubleTap()
}
.onTapGesture(count: 1) {
withAnimation(.easeInOut(duration: 0.2)) {
showControls.toggle()
}
}
.allowsHitTesting(false)
// Close button
// Close button (above gesture layer so it stays tappable)
if showControls {
VStack {
HStack {
@@ -77,6 +68,20 @@ struct FullScreenImageViewer: View {
.transition(.opacity)
}
}
// Gestures on the full-screen ZStack not on the Image.
// scaleEffect is visual-only and doesn't expand the Image's hit-test area,
// so when zoomed to 2.5x, taps outside the original frame were lost.
.contentShape(Rectangle())
.onTapGesture(count: 2) {
doubleTap()
}
.onTapGesture(count: 1) {
withAnimation(.easeInOut(duration: 0.2)) {
showControls.toggle()
}
}
.simultaneousGesture(pinchGesture)
.simultaneousGesture(dragGesture)
}
// MARK: - Background Opacity

View File

@@ -134,7 +134,11 @@ struct ImageGalleryViewer: View {
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.disabled(currentZoomScale > 1.05 || isDismissing)
// Block TabView page swipe when zoomed or dismissing,
// but ONLY the scroll NOT all user interaction.
// .disabled() kills ALL gestures (taps, pinch, etc.) which prevents
// double-tap zoom out. .scrollDisabled() only blocks the page swipe.
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
.opacity(presentationAlpha)
// Controls overlay

View File

@@ -199,6 +199,17 @@ struct MessageAvatarView: View {
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
avatarImage = cached
showAvatar = true // No animation for cached show immediately
return
}
// Outgoing avatar: sender is me load from AvatarRepository (always available locally)
if outgoing {
let myKey = SessionManager.shared.currentPublicKey
if let myAvatar = AvatarRepository.shared.loadAvatar(publicKey: myKey) {
avatarImage = myAvatar
showAvatar = true
// Backfill AttachmentCache so next render hits fast path
AttachmentCache.shared.saveImage(myAvatar, forAttachmentId: attachment.id)
}
}
}

View File

@@ -35,6 +35,23 @@ struct ZoomableImagePage: View {
.scaleEffect(effectiveScale)
.offset(x: effectiveScale > 1.05 ? zoomOffset.width : 0,
y: (effectiveScale > 1.05 ? zoomOffset.height : 0) + dismissDragOffset)
// Expand hit-test area to full screen scaleEffect is visual-only
// and doesn't grow the Image's gesture frame. Without this,
// double-tap to zoom out doesn't work on zoomed-in edges.
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
// Double tap: zoom to 2.5x or reset (MUST be before single tap)
.onTapGesture(count: 2) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
if zoomScale > 1.1 {
zoomScale = 1.0
zoomOffset = .zero
} else {
zoomScale = 2.5
}
currentScale = zoomScale
}
}
// Single tap: toggle controls / edge navigation
.onTapGesture { location in
let width = UIScreen.main.bounds.width
@@ -47,20 +64,8 @@ struct ZoomableImagePage: View {
showControls.toggle()
}
}
// Double tap: zoom to 2.5x or reset
.onTapGesture(count: 2) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
if zoomScale > 1.1 {
zoomScale = 1.0
zoomOffset = .zero
} else {
zoomScale = 2.5
}
currentScale = zoomScale
}
}
// Pinch zoom
.gesture(
.simultaneousGesture(
MagnifyGesture()
.updating($pinchScale) { value, state, _ in
state = value.magnification
@@ -78,7 +83,7 @@ struct ZoomableImagePage: View {
}
)
// Pan when zoomed
.gesture(
.simultaneousGesture(
zoomScale > 1.05 ?
DragGesture()
.onChanged { value in
@@ -96,7 +101,6 @@ struct ZoomableImagePage: View {
.simultaneousGesture(
zoomScale <= 1.05 ? dismissDragGesture : nil
)
.contentShape(Rectangle())
} else {
placeholder
}

View File

@@ -106,6 +106,21 @@ struct ChatListView: View {
guard let route = notification.object as? ChatRoute else { return }
// Navigate to the chat from push notification tap
navigationState.path = [route]
// Clear pending route consumed by onReceive (fast path)
AppDelegate.pendingChatRoute = nil
}
.onAppear {
// Fallback: consume pending route if .onReceive missed it.
// Handles terminated app (ChatListView didn't exist when notification was posted)
// and background app (Combine subscription may not fire during app resume).
if let route = AppDelegate.pendingChatRoute {
AppDelegate.pendingChatRoute = nil
// Small delay to let NavigationStack settle after view creation
Task { @MainActor in
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
navigationState.path = [route]
}
}
}
}

View File

@@ -64,6 +64,14 @@ struct MainTabView: View {
// never observes the dialogs dictionary directly.
UnreadCountObserver(count: $cachedUnreadCount)
}
// Switch to Chats tab when user taps a push notification.
// Without this, the navigation happens in the Chats NavigationStack
// but the user stays on whatever tab they were on (e.g., Settings).
.onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { _ in
if selectedTab != .chats {
selectedTab = .chats
}
}
}
// MARK: - iOS 26+ (native TabView with liquid glass tab bar)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
import FirebaseCore
import FirebaseCrashlytics
import FirebaseMessaging
import Intents
import SwiftUI
import UserNotifications
@@ -9,6 +10,11 @@ import UserNotifications
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate,
MessagingDelegate
{
/// Pending chat route from notification tap consumed by ChatListView on appear.
/// Handles both terminated app (notification posted before ChatListView exists)
/// and background app (fallback if .onReceive misses the synchronous post).
static var pendingChatRoute: ChatRoute?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
@@ -137,9 +143,40 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
// Always set sender_public_key in userInfo for notification tap navigation.
content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName]
// Communication Notification via INSendMessageIntent (CarPlay + Focus parity).
let handle = INPersonHandle(value: senderKey, type: .unknown)
let sender = INPerson(
personHandle: handle,
nameComponents: nil,
displayName: senderName.isEmpty ? "Rosetta" : senderName,
image: nil,
contactIdentifier: nil,
customIdentifier: senderKey
)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: messageText,
speakableGroupName: nil,
conversationIdentifier: senderKey,
serviceName: "Rosetta",
sender: sender,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
let finalContent: UNNotificationContent
if let updated = try? content.updating(from: intent) {
finalContent = updated
} else {
finalContent = content
}
let request = UNNotificationRequest(
identifier: "msg_\(senderKey)_\(Int(now))",
content: content,
content: finalContent,
trigger: nil
)
UNUserNotificationCenter.current().add(request) { _ in
@@ -212,6 +249,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
username: "",
verified: 0
)
// Store pending route BEFORE posting handles terminated app case
// where ChatListView doesn't exist yet, and background app case
// where .onReceive might miss the synchronous post.
Self.pendingChatRoute = route
NotificationCenter.default.post(
name: .openChatFromNotification,
object: route

View File

@@ -1,4 +1,5 @@
import UserNotifications
import Intents
/// Notification Service Extension runs as a separate process even when the main app
/// is terminated. Intercepts push notifications with `mutable-content: 1` and:
@@ -6,6 +7,7 @@ import UserNotifications
/// 2. Increments the app icon badge from shared App Group storage
/// 3. Normalizes sender_public_key in userInfo (Android parity: multi-key fallback)
/// 4. Filters muted chats
/// 5. Creates Communication Notification via INSendMessageIntent (CarPlay + Focus parity)
final class NotificationService: UNNotificationServiceExtension {
private static let appGroupID = "group.com.rosetta.dev"
@@ -76,7 +78,18 @@ final class NotificationService: UNNotificationServiceExtension {
content.categoryIdentifier = "message"
}
contentHandler(content)
// 6. Create Communication Notification via INSendMessageIntent.
// This makes the notification appear on CarPlay and work with Focus filters.
// Apple requires INSendMessageIntent for messaging notifications on CarPlay (iOS 15+).
let senderName = Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
?? content.title
let finalContent = Self.makeCommunicationNotification(
content: content,
senderName: senderName,
senderKey: senderKey
)
contentHandler(finalContent)
}
override func serviceExtensionTimeWillExpire() {
@@ -86,6 +99,56 @@ final class NotificationService: UNNotificationServiceExtension {
}
}
// MARK: - Communication Notification (CarPlay + Focus)
/// Wraps the notification content with an INSendMessageIntent so iOS treats it
/// as a Communication Notification. This enables:
/// - Display on CarPlay
/// - Proper grouping in Focus modes
/// - Sender name/avatar in notification UI
private static func makeCommunicationNotification(
content: UNMutableNotificationContent,
senderName: String,
senderKey: String
) -> UNNotificationContent {
let handle = INPersonHandle(value: senderKey, type: .unknown)
let sender = INPerson(
personHandle: handle,
nameComponents: nil,
displayName: senderName.isEmpty ? "Rosetta" : senderName,
image: nil,
contactIdentifier: nil,
customIdentifier: senderKey
)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: nil,
conversationIdentifier: senderKey,
serviceName: "Rosetta",
sender: sender,
attachments: nil
)
// Donate the intent so Siri can learn communication patterns.
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
// Update the notification content with the intent.
// This returns a new content object that iOS recognizes as a Communication Notification.
do {
let updatedContent = try content.updating(from: intent)
return updatedContent
} catch {
// If updating fails, return original content notification still works,
// just without CarPlay / Communication Notification features.
return content
}
}
// MARK: - Helpers
/// Android parity: extract sender key from multiple possible key names.