Skip to content

Commit 30097e3

Browse files
authored
Refresh concealed media previews when revealed (#150)
* Keep concealed media renders out of the normal image cache * Add native neutral-album spoiler cache regression harness * Verify spoiler reveal with neutral native image buffers * Limit spoiler cache isolation to real photo previews * Isolate all ink-covered message previews while preserving secret recycling * Describe spoiler screenshots as isolated component evidence and release diagnostics * Observe diagnostic window closure without changing initializer inheritance
1 parent 4bb6fc2 commit 30097e3

11 files changed

Lines changed: 274 additions & 3 deletions

File tree

‎packages/ChatUI/Sources/ChatUI/ChatInteractiveContentView.swift‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,6 +1000,7 @@ public class ChatInteractiveContentView: ChatMediaContentView {
10001000
}
10011001

10021002
let isSpoiler = (messageSpoiler || forceSpoiler || isSensitive) && (parameters?.isRevealed == false)
1003+
let bypassSharedPreviewCache = parent != nil && parent?.containsSecretMedia != true && isSpoiler
10031004

10041005

10051006
let corners = mediaCorners(positionFlags, parameters)
@@ -1150,18 +1151,18 @@ public class ChatInteractiveContentView: ChatMediaContentView {
11501151
}
11511152
}
11521153

1153-
self.image.setSignal(signal: cachedMedia(media: media, arguments: arguments, scale: backingScaleFactor, positionFlags: positionFlags), clearInstantly: clearInstantly)
1154+
self.image.setSignal(signal: bypassSharedPreviewCache ? .single(nil) : cachedMedia(media: media, arguments: arguments, scale: backingScaleFactor, positionFlags: positionFlags), clearInstantly: clearInstantly)
11541155

11551156
var alreadyRendered: TransformImageArguments? = nil
1156-
if self.image.isFullyLoaded, case .none = arguments.resizeMode {
1157+
if !bypassSharedPreviewCache, self.image.isFullyLoaded, case .none = arguments.resizeMode {
11571158
alreadyRendered = arguments
11581159
}
11591160

11601161
if let updateImageSignal = updateImageSignal {
11611162
self.image.ignoreFullyLoad = mediaUpdated
11621163

11631164
self.image.setSignal(updateImageSignal, animate: removeViewAnimated, cacheImage: { [weak media] result in
1164-
if let media = media {
1165+
if let media = media, !bypassSharedPreviewCache {
11651166
cacheMedia(result, media: media, arguments: arguments, scale: System.backingScale, positionFlags: positionFlags)
11661167
}
11671168
}, isProtected: isProtected, alreadyRendered: alreadyRendered)
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import Cocoa
2+
import TGUIKit
3+
import TelegramCore
4+
import Postbox
5+
import SwiftSignalKit
6+
import TelegramTheme
7+
import TelegramContext
8+
9+
private var spoilerEvidence: SpoilerEvidence?
10+
private final class SpoilerEvidenceButton: NSButton {
11+
var invoke: (() -> Void)?
12+
@objc func run() { invoke?() }
13+
}
14+
private final class SpoilerEvidence {
15+
let context: any AccountContextProtocol
16+
var closeObserver: NSObjectProtocol?
17+
let window: Window
18+
let status: NSTextField
19+
var tiles: [(ChatInteractiveContentView, TelegramMediaImage, Message)] = []
20+
var revealed = false
21+
init(source: ChatInteraction) {
22+
context = source.interactionContext
23+
window = Window(contentRect: NSMakeRect(0, 0, 820, 420), styleMask: [.titled, .closable], backing: .buffered, defer: false)
24+
window.isReleasedWhenClosed = false
25+
window.title = "Media spoiler — isolated preview components"
26+
let host = View(frame: NSMakeRect(0, 0, 820, 420))
27+
host.backgroundColor = theme.colors.chatBackground
28+
window.contentView = host
29+
status = NSTextField(labelWithString: "Hidden local checkerboard photos · no chat contents")
30+
status.frame = NSMakeRect(20, 15, 780, 25)
31+
status.textColor = theme.colors.text
32+
closeObserver = NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification, object: window, queue: .main) { [weak self] notification in
33+
self?.windowWillClose(notification)
34+
}
35+
host.addSubview(status)
36+
let button = SpoilerEvidenceButton(frame: NSMakeRect(20, 48, 250, 30))
37+
button.title = "Reveal test previews"
38+
button.bezelStyle = .rounded
39+
button.target = button
40+
button.action = #selector(SpoilerEvidenceButton.run)
41+
button.invoke = { [weak self] in self?.reveal() }
42+
host.addSubview(button)
43+
for i in 0..<3 {
44+
let resource = LocalFileMediaResource(fileId: Int64.random(in: 1...Int64.max))
45+
let thumb = LocalFileMediaResource(fileId: Int64.random(in: 1...Int64.max))
46+
context.account.postbox.mediaBox.storeResourceData(resource.id, data: Self.checker(size: 256))
47+
context.account.postbox.mediaBox.storeResourceData(thumb.id, data: Self.checker(size: 32))
48+
let image = TelegramMediaImage(imageId: MediaId(namespace: 0, id: resource.fileId), representations: [TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 32, height: 32), resource: thumb, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false), TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 256, height: 256), resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: [])
49+
let peer = source.peer!
50+
let message = Message(stableId: UInt32(10+i), stableVersion: 0, id: MessageId(peerId: peer.id, namespace: Namespaces.Message.Cloud, id: Int32(10+i)), globallyUniqueId: nil, groupingKey: 1, groupInfo: nil, threadId: nil, timestamp: 1782388800, flags: [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: peer, text: "", attributes: [], media: [image], peers: SimpleDictionary([peer.id: peer]), associatedMessages: .init(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:])
51+
let view = ChatInteractiveContentView(frame: NSMakeRect(20+CGFloat(i)*265, 110, 250, 250))
52+
host.addSubview(view)
53+
tiles.append((view, image, message))
54+
update(view, image, message, revealed: false)
55+
}
56+
window.center()
57+
window.makeKeyAndOrderFront(nil)
58+
}
59+
deinit {
60+
if let closeObserver { NotificationCenter.default.removeObserver(closeObserver) }
61+
}
62+
func windowWillClose(_ notification: Notification) {
63+
if spoilerEvidence === self {
64+
spoilerEvidence = nil
65+
}
66+
}
67+
static func checker(size: Int) -> Data {
68+
let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: size, pixelsHigh: size, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: size*4, bitsPerPixel: 32)!
69+
let square = max(1, size/16)
70+
let bytes = rep.bitmapData!
71+
for y in 0..<size {
72+
for x in 0..<size {
73+
let value: UInt8 = (x/square+y/square)%2 == 0 ? 0 : 255
74+
let offset = (y*size+x)*4
75+
bytes[offset] = value
76+
bytes[offset+1] = value
77+
bytes[offset+2] = value
78+
bytes[offset+3] = 255
79+
}
80+
}
81+
return rep.representation(using: .png, properties: [:])!
82+
}
83+
func update(_ view: ChatInteractiveContentView, _ image: TelegramMediaImage, _ message: Message, revealed: Bool) {
84+
let parameters = ChatMediaLayoutParameters(presentation: .Empty, media: image, automaticDownload: false, isRevealed: revealed)
85+
parameters.forceSpoiler = true
86+
view.update(with: image, size: NSMakeSize(250, 250), context: context, parent: message, table: nil, parameters: parameters, animated: false, approximateSynchronousValue: true)
87+
}
88+
func reveal() {
89+
guard !revealed else { return }
90+
revealed = true
91+
for (view, image, message) in tiles { update(view, image, message, revealed: true) }
92+
DispatchQueue.main.asyncAfter(deadline: .now()+1) { [weak self] in self?.report() }
93+
}
94+
func report() {
95+
var checks: [[String: Any]] = []
96+
for (i, tile) in tiles.enumerated() {
97+
let (image, hidden) = tile.0.codexSpoilerProbe()
98+
var values: [CGFloat] = []
99+
if let image {
100+
let rep = NSBitmapImageRep(cgImage: image)
101+
for y in 0..<16 {
102+
for x in 0..<16 {
103+
if let color = rep.colorAt(x: (2*x+1)*rep.pixelsWide/32, y: (2*y+1)*rep.pixelsHigh/32)?.usingColorSpace(.deviceRGB) { values.append(color.redComponent) }
104+
}
105+
}
106+
}
107+
let mean = values.reduce(0,+)/CGFloat(max(1,values.count))
108+
let variance = values.map { ($0-mean)*($0-mean) }.reduce(0,+)/CGFloat(max(1,values.count))
109+
checks.append(["name": "tile \(i+1) revealed without ink", "pass": !hidden])
110+
checks.append(["name": "tile \(i+1) sharp after reveal", "pass": variance > 0.1, "pixelVariance": variance])
111+
}
112+
let passed = checks.filter { $0["pass"] as? Bool == true }.count
113+
status.stringValue = "\(passed)/\(checks.count) checks passed · Revealed preview components"
114+
let file = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("spoiler-media.json")
115+
if let data = try? JSONSerialization.data(withJSONObject: checks, options: [.prettyPrinted, .sortedKeys]) { try? data.write(to: file, options: .atomic) }
116+
}
117+
}
118+
func codexInstallSpoilerEvidence(source: ChatInteraction, parent: NSView) {
119+
guard source.mode == .history else { return }
120+
let button = SpoilerEvidenceButton(frame: NSMakeRect(20, 130, 260, 30))
121+
button.title = "Media spoiler validation"
122+
button.bezelStyle = .rounded
123+
button.target = button
124+
button.action = #selector(SpoilerEvidenceButton.run)
125+
button.invoke = { [weak source] in
126+
guard let source else { return }
127+
spoilerEvidence?.window.close()
128+
spoilerEvidence = SpoilerEvidence(source: source)
129+
}
130+
parent.addSubview(button)
131+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import argparse,subprocess
2+
from pathlib import Path
3+
from isolated_native import isolated_source_root
4+
p=argparse.ArgumentParser();p.add_argument('--source-root',type=Path,default=Path(__file__).resolve().parents[3]);p.add_argument('--derived-data',type=Path,required=True);p.add_argument('--baseline');args=p.parse_args()
5+
r=isolated_source_root(args.source_root);base=r/'packages/ChatUI/Sources/ChatUI';paths=[base/'ChatController.swift',base/'ChatInteractiveContentView.swift'];saved={x:x.read_bytes() for x in paths};helper=base/'SpoilerMediaHarness.swift';assert not helper.exists()
6+
try:
7+
if args.baseline:paths[1].write_bytes(subprocess.check_output(['git','show',args.baseline+':'+str(paths[1].relative_to(r))],cwd=r))
8+
helper.write_bytes(Path(__file__).with_name('SpoilerMediaHarness.swift').read_bytes())
9+
x=paths[0];s=x.read_text();marker=' public override func viewDidAppear(_ animated: Bool) {\n super.viewDidAppear(animated)';assert s.count(marker)==1;x.write_text(s.replace(marker,marker+'\n codexInstallSpoilerEvidence(source: chatInteraction, parent: genericView)'))
10+
x=paths[1];s=x.read_text();marker=' private var previousIsSpoiler: Bool? = nil';assert s.count(marker)==1;x.write_text(s.replace(marker,' func codexSpoilerProbe() -> (CGImage?, Bool) { (image.view?.image ?? image.layer?.image, inkView != nil) }\n'+marker))
11+
result=subprocess.run(['xcodebuild','-workspace','Telegram-Mac.xcworkspace','-scheme','Telegram','-configuration','Debug','-destination','platform=macOS,arch=arm64','-derivedDataPath',str(args.derived_data.resolve()),'-jobs','1','CODE_SIGNING_ALLOWED=NO','COMPILER_INDEX_STORE_ENABLE=NO','build'],cwd=r)
12+
finally:
13+
for x,data in saved.items():x.write_bytes(data)
14+
helper.unlink(missing_ok=True)
15+
raise SystemExit(result.returncode)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from pathlib import Path
2+
import atexit
3+
import subprocess
4+
import tempfile
5+
6+
7+
def isolated_source_root(source):
8+
source = Path(source).resolve()
9+
scratch = tempfile.TemporaryDirectory(prefix='telegram-native-validation-')
10+
target = Path(scratch.name) / 'checkout'
11+
try:
12+
subprocess.run(['ditto', '--clone', str(source), str(target)], check=True)
13+
except BaseException:
14+
scratch.cleanup()
15+
raise
16+
atexit.register(scratch.cleanup)
17+
print('Disposable native validation checkout: ' + str(target), flush=True)
18+
return target
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from pathlib import Path
2+
import subprocess
3+
import tempfile
4+
root = Path(__file__).resolve().parents[3]
5+
source = (root/'packages/ChatUI/Sources/ChatUI/ChatInteractiveContentView.swift').read_text()
6+
line = next(line.strip() for line in source.splitlines() if 'let bypassSharedPreviewCache =' in line)
7+
fixture = '''import Foundation
8+
struct Parent { var containsSecretMedia = false }
9+
class TelegramMediaImage {}
10+
func bypass(_ parent: Parent?, _ isSensitive: Bool, _ isSpoiler: Bool, _ media: Any) -> Bool {
11+
__POLICY__
12+
return bypassSharedPreviewCache
13+
}
14+
let image = TelegramMediaImage()
15+
let cases: [(String, Bool, Bool)] = [
16+
("real spoiler photo bypasses shared sharp cache", bypass(Parent(), false, true, image), true),
17+
("revealed photo uses ordinary cache", bypass(Parent(), false, false, image), false),
18+
("send preview without parent keeps ordinary cache", bypass(nil, false, true, image), false),
19+
("secret-media recycling retains original cache behavior", bypass(Parent(containsSecretMedia: true), false, true, image), false),
20+
("concealed message video bypasses shared preview cache", bypass(Parent(), false, true, "video"), true),
21+
("concealed sensitive photo bypasses shared preview cache", bypass(Parent(), true, true, image), true)]
22+
var failures = 0
23+
for (name, actual, expected) in cases { print("\\(actual == expected ? "PASS" : "FAIL"): \\(name)"); if actual != expected { failures += 1 } }
24+
exit(failures == 0 ? 0 : 1)
25+
'''.replace('__POLICY__', line)
26+
assert source.count('!bypassSharedPreviewCache') == 2
27+
assert 'signal: bypassSharedPreviewCache ? .single(nil)' in source
28+
with tempfile.TemporaryDirectory() as temp:
29+
temp = Path(temp); (temp/'main.swift').write_text(fixture)
30+
subprocess.run(['swiftc', str(temp/'main.swift'), '-o', str(temp/'tests')], check=True)
31+
raise SystemExit(subprocess.run([str(temp/'tests')]).returncode)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Spoiler preview cache verification
2+
3+
Real concealed message photo/video previews, including sensitive-content previews, bypass the shared sharp-image cache and do not satisfy its already-rendered shortcut. Parentless send/rich-editor previews and secret self-destructing media retain the original paths, avoiding repeated decode and empty recycled secret frames.
4+
5+
`python3 tools/tests/spoiler-media/review-tests.py` compiles the production cache policy with controlled parent/media values: six cases and three wiring assertions cover concealed/revealed, parentless, secret, video and sensitive previews. These establish cache routing, not video playback/reveal rendering.
6+
7+
The retained Before/After images come from a separate diagnostic window containing three standalone production ChatInteractiveContentView instances with neutral checkerboard images. It passes parent messages with a grouping key but no grouped row or positionFlags. Reveal directly calls update with isRevealed=true; it does not click the real ink overlay or exercise the chat album reveal flow. The images demonstrate the component cache transition only, not an actual album row or end-to-end reveal. Old labels saying album do not establish album coverage. Sensitive/video/secret rendering and a real grouped-row reveal have not been demonstrated by these captures.
8+
9+
`build-native.py --source-root /path/to/full/checkout --derived-data /path/to/derived-data` clones the supplied source tree into a disposable APFS copy before installing SpoilerMediaHarness and its probe. Hard termination can leave that copy but cannot leave diagnostic writes in the supplied checkout. These diagnostic sources are committed under tools and excluded from shipping target membership. Never use private user media as public evidence.
10+
11+
The diagnostic window now clears its global owner on close, disables release-on-close and weakly captures the launcher interaction. New window/button labels call these isolated previews. The retained screenshots are historical and are not edited to simulate a new test.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[
2+
{
3+
"name" : "tile 1 revealed without ink",
4+
"pass" : true
5+
},
6+
{
7+
"name" : "tile 1 sharp after reveal",
8+
"pass" : true,
9+
"pixelVariance" : 0.25
10+
},
11+
{
12+
"name" : "tile 2 revealed without ink",
13+
"pass" : true
14+
},
15+
{
16+
"name" : "tile 2 sharp after reveal",
17+
"pass" : true,
18+
"pixelVariance" : 0.25
19+
},
20+
{
21+
"name" : "tile 3 revealed without ink",
22+
"pass" : true
23+
},
24+
{
25+
"name" : "tile 3 sharp after reveal",
26+
"pass" : true,
27+
"pixelVariance" : 0.25
28+
}
29+
]
181 KB
Loading
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[
2+
{
3+
"name" : "tile 1 revealed without ink",
4+
"pass" : true
5+
},
6+
{
7+
"name" : "tile 1 sharp after reveal",
8+
"pass" : false,
9+
"pixelVariance" : 0.00074467567570499454
10+
},
11+
{
12+
"name" : "tile 2 revealed without ink",
13+
"pass" : true
14+
},
15+
{
16+
"name" : "tile 2 sharp after reveal",
17+
"pass" : false,
18+
"pixelVariance" : 0.00074467567570499454
19+
},
20+
{
21+
"name" : "tile 3 revealed without ink",
22+
"pass" : true
23+
},
24+
{
25+
"name" : "tile 3 sharp after reveal",
26+
"pass" : false,
27+
"pixelVariance" : 0.00074467567570499454
28+
}
29+
]
79.8 KB
Loading

0 commit comments

Comments
 (0)