From cdb6c7e51e12476879fd014c9018b516d1f37e7f Mon Sep 17 00:00:00 2001 From: senseiGai Date: Mon, 6 Apr 2026 00:48:07 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81:=20=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D1=8B=20=E2=80=94=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20hex?= =?UTF-8?q?=E2=86=92plain=20(Android=20parity,=20Desktop=20decrypt=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta/Core/Crypto/MessageCrypto.swift | 5 +- Rosetta/Core/Layout/MessageCellLayout.swift | 29 ++-- Rosetta/Core/Services/SessionManager.swift | 32 +++-- .../Chats/ChatDetail/NativeMessageCell.swift | 2 +- .../Chats/ChatDetail/ZoomableImagePage.swift | 133 +++++++++--------- 5 files changed, 109 insertions(+), 92 deletions(-) diff --git a/Rosetta/Core/Crypto/MessageCrypto.swift b/Rosetta/Core/Crypto/MessageCrypto.swift index 3d7dd0f..793f612 100644 --- a/Rosetta/Core/Crypto/MessageCrypto.swift +++ b/Rosetta/Core/Crypto/MessageCrypto.swift @@ -176,8 +176,9 @@ enum MessageCrypto { var seen = Set() return candidates.filter { seen.insert($0).inserted } } - // Group key or plain password — Desktop encrypts group attachments with - // Buffer.from(groupKey).toString('hex') (hex of UTF-8 bytes). + // Group key or plain password — Android/iOS use plain groupKey. + // Desktop SENDS with hex but RECEIVES with plain (Desktop bug). + // Candidates include both hex and plain for backward compat. // If stored is already hex-encoded (128+ chars, all hex digits), use as-is // to avoid generating a 256-char double-hex garbage candidate. let isAlreadyHex = stored.count >= 128 && stored.allSatisfy { $0.isHexDigit } diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 2ff9057..f5d844f 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -221,7 +221,7 @@ extension MessageCellLayout { // Outgoing: timestamp at 5pt from edge (checkmarks fill remaining space → rightPad-5=6pt compensation) // Incoming: timestamp at rightPad (11pt) from edge, same as text → 0pt compensation let statusTrailingCompensation: CGFloat - if (isTextMessage || isForwardWithCaption) && config.isOutgoing { + if (isTextMessage || isForwardWithCaption || messageType == .photoWithCaption) && config.isOutgoing { statusTrailingCompensation = max(0, rightPad - textStatusLaneMetrics.textStatusRightInset) } else { statusTrailingCompensation = 0 @@ -263,7 +263,7 @@ extension MessageCellLayout { let metadataWidth = tsSize.width + timeToCheckGap + checkW let trailingWidthForStatus: CGFloat - if (isTextMessage || isForwardWithCaption) && !config.text.isEmpty { + if (isTextMessage || isForwardWithCaption || messageType == .photoWithCaption) && !config.text.isEmpty { if let cachedTextLayout { if cachedTextLayout.lastLineHasRTL { trailingWidthForStatus = 10_000 @@ -300,8 +300,8 @@ extension MessageCellLayout { }() // ── STEP 3: Inline vs Wrapped determination ── - let timestampInline: Bool - if (isTextMessage || isForwardWithCaption) && !config.text.isEmpty { + var timestampInline: Bool + if (isTextMessage || isForwardWithCaption || messageType == .photoWithCaption) && !config.text.isEmpty { timestampInline = inlineStatusContentWidth <= maxTextWidth } else { timestampInline = true @@ -374,11 +374,22 @@ extension MessageCellLayout { let textNeedW = leftPad + textMeasurement.size.width + rightPad bubbleW = max(bubbleW, min(textNeedW, effectiveMaxBubbleWidth)) } + // Recheck: photo bubble may be narrower than maxTextWidth + if messageType == .photoWithCaption && !config.text.isEmpty { + let actualMaxInline = bubbleW - leftPad - rightPad + if inlineStatusContentWidth > actualMaxInline { + timestampInline = false + } + } // Telegram: 2pt inset on all 4 sides → bubble is photoH + 4pt taller bubbleH += photoH + photoInset * 2 if !config.text.isEmpty { - bubbleH += topPad + textMeasurement.size.height + bottomPad - if photoH > 0 { bubbleH += 6 } + if timestampInline { + bubbleH += topPad + textMeasurement.size.height + bottomPad + } else { + bubbleH += topPad + textMeasurement.size.height + 15 + bottomPad + } + if photoH > 0 { bubbleH += 3 } } } else if messageType == .emojiOnly { // Emoji-only: no bubble — rendered like a sticker. @@ -529,7 +540,7 @@ extension MessageCellLayout { } let statusEndX = bubbleW - metadataRightInset let statusEndY = bubbleH - metadataBottomInset - let statusVerticalOffset: CGFloat = (isTextMessage || isForwardWithCaption) + let statusVerticalOffset: CGFloat = (isTextMessage || isForwardWithCaption || messageType == .photoWithCaption) ? textStatusLaneMetrics.verticalOffset : 0 @@ -587,8 +598,8 @@ extension MessageCellLayout { if forwardHeaderH > 0 { textY = forwardHeaderH + 2 } if photoH > 0 { // Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap - textY = photoH + 4 + 6 + topPad - if config.hasReplyQuote { textY = replyH + photoH + 4 + 6 + topPad } + textY = photoH + 4 + 3 + topPad + if config.hasReplyQuote { textY = replyH + photoH + 4 + 3 + topPad } } if fileH > 0 { textY = fileH + (config.hasReplyQuote ? replyH : 0) + topPad } diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index cf72277..d29a9aa 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -591,8 +591,9 @@ final class SessionManager { Data((messageText.isEmpty ? "" : messageText).utf8), password: groupKey ) - // Desktop parity: Buffer.from(groupKey).toString('hex') — hex of UTF-8 bytes - attachmentPassword = Data(groupKey.utf8).map { String(format: "%02x", $0) }.joined() + // Android parity: plain groupKey as PBKDF2 password. + // Desktop SENDS with hex(UTF-8 bytes) but RECEIVES with plain — align with receive path. + attachmentPassword = groupKey outChachaKey = "" outAesChachaKey = "" } else { @@ -886,11 +887,10 @@ final class SessionManager { password: groupKey ) - // Desktop parity: reply blob encrypted with Buffer.from(groupKey).toString('hex') - let hexGroupKey = Data(groupKey.utf8).map { String(format: "%02x", $0) }.joined() + // Android parity: plain groupKey as PBKDF2 password for reply/forward blobs. let encryptedReplyBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( replyJSON, - password: hexGroupKey + password: groupKey ) let replyAttachment = MessageAttachment( @@ -932,7 +932,7 @@ final class SessionManager { MessageRepository.shared.upsertFromMessagePacket( localPacket, myPublicKey: currentPublicKey, - decryptedText: messageText, attachmentPassword: hexGroupKey, + decryptedText: messageText, attachmentPassword: groupKey, fromSync: false, dialogIdentityOverride: normalizedTarget ) DialogRepository.shared.updateDialogFromMessages(opponentKey: normalizedTarget) @@ -2068,20 +2068,22 @@ final class SessionManager { } } } else if let groupKey { - // Desktop parity: Buffer.from(groupKey).toString('hex') - let hexGroupKey = Data(groupKey.utf8).map { String(format: "%02x", $0) }.joined() - resolvedAttachmentPassword = hexGroupKey + // Android parity: plain groupKey as PBKDF2 password. + // Try plain first (Android + new iOS), hex second (old iOS + Desktop sends). + resolvedAttachmentPassword = groupKey for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages { let blob = processedPacket.attachments[i].blob guard !blob.isEmpty else { continue } - // Desktop encrypts reply/forward blobs with hex-encoded groupKey (primary), - // fallback to plain groupKey for backward compat. - if let data = try? CryptoManager.shared.decryptWithPassword(blob, password: hexGroupKey), + if let data = try? CryptoManager.shared.decryptWithPassword(blob, password: groupKey), let decryptedString = String(data: data, encoding: .utf8) { processedPacket.attachments[i].blob = decryptedString - } else if let data = try? CryptoManager.shared.decryptWithPassword(blob, password: groupKey), - let decryptedString = String(data: data, encoding: .utf8) { - processedPacket.attachments[i].blob = decryptedString + } else { + // Backward compat: old iOS + Desktop send with hex(groupKey) + let hexGroupKey = Data(groupKey.utf8).map { String(format: "%02x", $0) }.joined() + if let data = try? CryptoManager.shared.decryptWithPassword(blob, password: hexGroupKey), + let decryptedString = String(data: data, encoding: .utf8) { + processedPacket.attachments[i].blob = decryptedString + } } } } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 9f2ab89..b032f92 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -550,7 +550,7 @@ final class NativeMessageCell: UICollectionViewCell { let isOutgoing = currentLayout?.isOutgoing ?? false let isMediaStatus: Bool = { guard let type = currentLayout?.messageType else { return false } - return type == .photo || type == .photoWithCaption || type == .emojiOnly + return type == .photo || type == .emojiOnly }() // Text — use cached CoreTextTextLayout from measurement phase. diff --git a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift index 2dc7317..92a57c6 100644 --- a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift +++ b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift @@ -21,80 +21,83 @@ struct ZoomableImagePage: View { @GestureState private var pinchScale: CGFloat = 1.0 var body: some View { - Group { - if let image { - let effectiveScale = zoomScale * pinchScale + let effectiveScale = zoomScale * pinchScale - Image(uiImage: image) - .resizable() - .scaledToFit() - .scaleEffect(effectiveScale) - .offset(x: effectiveScale > 1.05 ? zoomOffset.width : 0, - y: effectiveScale > 1.05 ? zoomOffset.height : 0) - // 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) { + // Color.clear always fills ALL proposed space from the parent — TabView page, + // hero frame, etc. The Image in .overlay sizes relative to Color.clear's actual + // rendered frame. Previous approach (.scaledToFit + .frame(maxWidth: .infinity)) + // sometimes got a stale/zero proposed size from TabView lazy page creation, + // causing the image to render at thumbnail size. + Color.clear + .overlay { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFit() + .scaleEffect(effectiveScale) + .offset( + x: effectiveScale > 1.05 ? zoomOffset.width : 0, + y: effectiveScale > 1.05 ? zoomOffset.height : 0 + ) + } else { + placeholder + } + } + .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 + let edgeZone = width * 0.20 + if location.x < edgeZone { + onEdgeTap?(-1) + } else if location.x > width - edgeZone { + onEdgeTap?(1) + } else { + showControls.toggle() + } + } + // Pinch zoom + .simultaneousGesture( + MagnifyGesture() + .updating($pinchScale) { value, state, _ in + state = value.magnification + } + .onEnded { value in + let newScale = zoomScale * value.magnification withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { - if zoomScale > 1.1 { + zoomScale = min(max(newScale, 1.0), 5.0) + if zoomScale <= 1.05 { 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 - let edgeZone = width * 0.20 - if location.x < edgeZone { - onEdgeTap?(-1) - } else if location.x > width - edgeZone { - onEdgeTap?(1) - } else { - showControls.toggle() - } + ) + // Pan when zoomed + .simultaneousGesture( + zoomScale > 1.05 ? + DragGesture() + .onChanged { value in + zoomOffset = value.translation } - // Pinch zoom - .simultaneousGesture( - MagnifyGesture() - .updating($pinchScale) { value, state, _ in - state = value.magnification - } - .onEnded { value in - let newScale = zoomScale * value.magnification - withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { - zoomScale = min(max(newScale, 1.0), 5.0) - if zoomScale <= 1.05 { - zoomScale = 1.0 - zoomOffset = .zero - } - currentScale = zoomScale - } - } - ) - // Pan when zoomed - .simultaneousGesture( - zoomScale > 1.05 ? - DragGesture() - .onChanged { value in - zoomOffset = value.translation - } - .onEnded { _ in - // Clamp offset - } - : nil - ) - // Dismiss drag handled by HeroPanGesture on ImageGalleryViewer level. - } else { - placeholder - } - } + .onEnded { _ in + // Clamp offset + } + : nil + ) .task { if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) { image = cached