177 lines
6.7 KiB
Swift
177 lines
6.7 KiB
Swift
import Foundation
|
|
|
|
/// Shared parser/composer for attachment preview fields.
|
|
///
|
|
/// Cross-platform canonical formats:
|
|
/// - image/avatar: `tag::blurhash` or `::blurhash` (placeholder before upload)
|
|
/// - file: `tag::size::name` or `size::name`
|
|
/// - legacy/local-only: raw preview payload without `tag::` prefix
|
|
enum AttachmentPreviewCodec {
|
|
|
|
// Legacy iOS upload IDs were generated from [a-z0-9] with fixed length 8.
|
|
// We intentionally keep this strict to avoid stripping arbitrary prefixes
|
|
// from valid blurhash payloads that may contain "::".
|
|
private static let legacyTransportIdRegex = try! NSRegularExpression(
|
|
pattern: "^[a-z0-9]{8}$",
|
|
options: []
|
|
)
|
|
|
|
struct ParsedFilePreview: Equatable {
|
|
let downloadTag: String
|
|
let fileSize: Int
|
|
let fileName: String
|
|
let payload: String
|
|
}
|
|
|
|
static func isDownloadTag(_ value: String) -> Bool {
|
|
UUID(uuidString: value.trimmingCharacters(in: .whitespacesAndNewlines)) != nil
|
|
}
|
|
|
|
static func downloadTag(from preview: String) -> String {
|
|
let firstPart = preview.components(separatedBy: "::").first ?? ""
|
|
return isDownloadTag(firstPart) ? firstPart : ""
|
|
}
|
|
|
|
static func payload(from preview: String) -> String {
|
|
let parts = preview.components(separatedBy: "::")
|
|
guard parts.isEmpty == false else { return "" }
|
|
|
|
if isDownloadTag(parts[0]) {
|
|
return normalizePayload(parts.dropFirst().joined(separator: "::"))
|
|
}
|
|
|
|
// Placeholder preview before upload (`::blurhash` / `::size::name`).
|
|
if parts[0].isEmpty {
|
|
return normalizePayload(parts.dropFirst().joined(separator: "::"))
|
|
}
|
|
|
|
return normalizePayload(preview)
|
|
}
|
|
|
|
static func blurHash(from preview: String) -> String {
|
|
let raw = payload(from: preview)
|
|
|
|
// Legacy compatibility: older iOS builds could embed an 8-char upload
|
|
// id in preview, e.g. "jbov1nac::blurhash". We strip only this known
|
|
// prefix shape to avoid corrupting valid blurhash values that may
|
|
// legitimately contain "::".
|
|
var candidate = raw
|
|
if let sep = raw.range(of: "::") {
|
|
let prefix = String(raw[..<sep.lowerBound])
|
|
let suffix = String(raw[sep.upperBound...])
|
|
if isLegacyTransportId(prefix), !suffix.isEmpty {
|
|
candidate = suffix
|
|
}
|
|
}
|
|
|
|
// Strip trailing "|WxH" dimension suffix if present.
|
|
if let pipeIdx = candidate.lastIndex(of: "|") {
|
|
return String(candidate[candidate.startIndex..<pipeIdx])
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
/// Extract pixel dimensions encoded as `|WxH` suffix in image preview.
|
|
/// Format: "tag::blurhash|WxH" — "|" separator avoids breaking desktop's `::` parsing.
|
|
/// Returns nil if no dimensions found (legacy messages).
|
|
static func imageDimensions(from preview: String) -> CGSize? {
|
|
let raw = payload(from: preview)
|
|
guard let pipeIdx = raw.lastIndex(of: "|") else { return nil }
|
|
let dimStr = raw[raw.index(after: pipeIdx)...]
|
|
guard let xIdx = dimStr.firstIndex(of: "x"),
|
|
let w = Int(dimStr[dimStr.startIndex..<xIdx]),
|
|
let h = Int(dimStr[dimStr.index(after: xIdx)...]),
|
|
w >= 10, h >= 10 else { return nil }
|
|
return CGSize(width: CGFloat(w), height: CGFloat(h))
|
|
}
|
|
|
|
static func parseFilePreview(
|
|
_ preview: String,
|
|
fallbackFileName: String = "file",
|
|
fallbackFileSize: Int = 0
|
|
) -> ParsedFilePreview {
|
|
let tag = downloadTag(from: preview)
|
|
let normalizedPayload = payload(from: preview)
|
|
let components = normalizedPayload.components(separatedBy: "::")
|
|
|
|
var fileSize = max(fallbackFileSize, 0)
|
|
var fileName = fallbackFileName
|
|
|
|
if components.count >= 2, let parsedSize = Int(components[0]) {
|
|
fileSize = max(parsedSize, 0)
|
|
let joinedName = components.dropFirst().joined(separator: "::")
|
|
if joinedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
|
fileName = joinedName
|
|
}
|
|
} else if components.count >= 2 {
|
|
// Legacy payload without explicit size.
|
|
let joinedName = components.joined(separator: "::")
|
|
if joinedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
|
fileName = joinedName
|
|
}
|
|
} else if let onlyComponent = components.first,
|
|
onlyComponent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
|
fileName = onlyComponent
|
|
}
|
|
|
|
return ParsedFilePreview(
|
|
downloadTag: tag,
|
|
fileSize: fileSize,
|
|
fileName: fileName,
|
|
payload: normalizedPayload
|
|
)
|
|
}
|
|
|
|
static func compose(downloadTag: String, payload: String) -> String {
|
|
let tag = downloadTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let normalizedPayload = normalizePayload(payload)
|
|
if tag.isEmpty { return normalizedPayload }
|
|
return "\(tag)::\(normalizedPayload)"
|
|
}
|
|
|
|
static func parseCallDurationSeconds(_ preview: String) -> Int {
|
|
let normalized = payload(from: preview)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if let direct = Int(normalized) {
|
|
return max(direct, 0)
|
|
}
|
|
|
|
let patterns = [
|
|
#"duration(?:Sec|Seconds)?\s*[:=]\s*(\d+)"#,
|
|
#"duration\s+(\d+)"#,
|
|
]
|
|
|
|
for pattern in patterns {
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
|
continue
|
|
}
|
|
let nsRange = NSRange(normalized.startIndex..<normalized.endIndex, in: normalized)
|
|
guard let match = regex.firstMatch(in: normalized, options: [], range: nsRange),
|
|
match.numberOfRanges > 1,
|
|
let valueRange = Range(match.range(at: 1), in: normalized),
|
|
let value = Int(normalized[valueRange])
|
|
else {
|
|
continue
|
|
}
|
|
return max(value, 0)
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
private static func normalizePayload(_ payload: String) -> String {
|
|
var value = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
while value.hasPrefix("::") {
|
|
value.removeFirst(2)
|
|
}
|
|
return value
|
|
}
|
|
|
|
private static func isLegacyTransportId(_ value: String) -> Bool {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let range = NSRange(trimmed.startIndex..<trimmed.endIndex, in: trimmed)
|
|
return legacyTransportIdRegex.firstMatch(in: trimmed, options: [], range: range) != nil
|
|
}
|
|
}
|