Фикс: группы — пароль вложений hex→plain (Android parity, Desktop decrypt fix)

This commit is contained in:
2026-04-06 00:48:07 +05:00
parent 55cb120db3
commit cdb6c7e51e
5 changed files with 109 additions and 92 deletions

View File

@@ -176,8 +176,9 @@ enum MessageCrypto {
var seen = Set<String>()
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 }

View File

@@ -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 }

View File

@@ -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
}
}
}
}

View File

@@ -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.

View File

@@ -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