Фикс: группы — пароль вложений hex→plain (Android parity, Desktop decrypt fix)
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
if timestampInline {
|
||||
bubbleH += topPad + textMeasurement.size.height + bottomPad
|
||||
if photoH > 0 { bubbleH += 6 }
|
||||
} 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 }
|
||||
|
||||
|
||||
@@ -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: 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
|
||||
} else if let data = try? CryptoManager.shared.decryptWithPassword(blob, password: groupKey),
|
||||
let decryptedString = String(data: data, encoding: .utf8) {
|
||||
processedPacket.attachments[i].blob = decryptedString
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -21,20 +21,28 @@ struct ZoomableImagePage: View {
|
||||
@GestureState private var pinchScale: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
let effectiveScale = zoomScale * pinchScale
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
.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) {
|
||||
@@ -90,11 +98,6 @@ struct ZoomableImagePage: View {
|
||||
}
|
||||
: nil
|
||||
)
|
||||
// Dismiss drag handled by HeroPanGesture on ImageGalleryViewer level.
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) {
|
||||
image = cached
|
||||
|
||||
Reference in New Issue
Block a user