From 42e87a38d7fc54863e0a139a22e0726cb6838558 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Mon, 20 Apr 2026 16:42:40 -0400 Subject: [PATCH] don't screenshot dictation placeholders as pasted images --- patches/expo-paste-input+0.1.15.patch | 136 +++++++++++++++++++++++ patches/expo-paste-input+0.1.15.patch.md | 22 ++++ 2 files changed, 158 insertions(+) create mode 100644 patches/expo-paste-input+0.1.15.patch create mode 100644 patches/expo-paste-input+0.1.15.patch.md diff --git a/patches/expo-paste-input+0.1.15.patch b/patches/expo-paste-input+0.1.15.patch new file mode 100644 index 0000000000..329fbe866f --- /dev/null +++ b/patches/expo-paste-input+0.1.15.patch @@ -0,0 +1,136 @@ +diff --git a/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift b/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift +index 2164aec4ec1d..d216db6d2927 100644 +--- a/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift ++++ b/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift +@@ -511,14 +511,17 @@ class ExpoPasteInputView: ExpoView { + var attachmentRanges: [NSRange] = [] + var mediaPayloads: [MediaPayload] = [] + ++ // Only track ranges for attachments we successfully extract a real payload ++ // from. Attachments without a payload (e.g. iOS dictation placeholders) ++ // are left alone — sanitizing them would delete characters the system ++ // manages itself, and emitting "unsupported" would raise a spurious error. + attributedText.enumerateAttribute(.attachment, in: NSRange(location: 0, length: attributedText.length), options: []) { value, range, _ in + guard let attachment = value as? NSTextAttachment else { + return + } + +- attachmentRanges.append(range) +- + if let payload = self.extractMediaPayload(from: attachment, textView: textView, range: range) { ++ attachmentRanges.append(range) + mediaPayloads.append(payload) + } + } +@@ -529,9 +532,8 @@ class ExpoPasteInputView: ExpoView { + return + } + +- attachmentRanges.append(range) +- + if let payload = self.extractMediaPayload(from: adaptiveGlyph) { ++ attachmentRanges.append(range) + mediaPayloads.append(payload) + } + } +@@ -539,17 +541,12 @@ class ExpoPasteInputView: ExpoView { + + attachmentRanges = uniqueRanges(attachmentRanges) + +- guard !attachmentRanges.isEmpty else { +- return +- } +- +- sanitizeAttachments(in: textView, ranges: attachmentRanges) +- + guard !mediaPayloads.isEmpty else { +- handleUnsupportedPaste() + return + } + ++ sanitizeAttachments(in: textView, ranges: attachmentRanges) ++ + emitImagesAsync(for: mediaPayloads) + } + +@@ -651,6 +648,11 @@ class ExpoPasteInputView: ExpoView { + } + + private func extractMediaPayload(from attachment: NSTextAttachment, textView: UITextView, range: NSRange) -> MediaPayload? { ++ // Only accept attachments that carry real image payloads. We intentionally ++ // do not fall back to `image(forBounds:)` or rendering the text view's ++ // hierarchy, because system-inserted attachments (e.g. the iOS dictation ++ // placeholder) draw themselves via those paths and would cause us to ++ // emit a screenshot of the composer as a "pasted image". + if let fileWrapperData = attachment.fileWrapper?.regularFileContents, + let payload = extractMediaPayload(fromData: fileWrapperData) { + return payload +@@ -667,20 +669,6 @@ class ExpoPasteInputView: ExpoView { + return .image(image) + } + +- let attachmentBounds = attachment.bounds.size.width > 0 && attachment.bounds.size.height > 0 +- ? attachment.bounds +- : CGRect(origin: .zero, size: CGSize(width: 128, height: 128)) +- +- if let image = attachment.image(forBounds: attachmentBounds, textContainer: textView.textContainer, characterIndex: range.location), +- image.size.width > 0, +- image.size.height > 0 { +- return .image(image) +- } +- +- if let renderedImage = renderTextAttachment(in: textView, range: range) { +- return .image(renderedImage) +- } +- + return nil + } + +@@ -701,47 +689,6 @@ class ExpoPasteInputView: ExpoView { + return .imageData(data) + } + +- private func renderTextAttachment(in textView: UITextView, range: NSRange) -> UIImage? { +- let glyphRange = textView.layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) +- var rect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer) +- +- rect.origin.x += textView.textContainerInset.left - textView.contentOffset.x +- rect.origin.y += textView.textContainerInset.top - textView.contentOffset.y +- rect = rect.integral +- +- guard rect.width > 1, rect.height > 1 else { +- return nil +- } +- +- let format = UIGraphicsImageRendererFormat.default() +- format.scale = textView.window?.screen.scale ?? UIScreen.main.scale +- format.opaque = false +- +- let image = UIGraphicsImageRenderer(size: rect.size, format: format).image { _ in +- let drawRect = CGRect( +- origin: CGPoint(x: -rect.origin.x, y: -rect.origin.y), +- size: textView.bounds.size +- ) +- +- if textView.window != nil { +- textView.drawHierarchy(in: drawRect, afterScreenUpdates: false) +- } else { +- guard let context = UIGraphicsGetCurrentContext() else { +- return +- } +- +- context.translateBy(x: -rect.origin.x, y: -rect.origin.y) +- textView.layer.render(in: context) +- } +- } +- +- guard image.size.width > 0, image.size.height > 0 else { +- return nil +- } +- +- return image +- } +- + @available(iOS 18.0, *) + private func handleAdaptiveImageGlyphInsertion(_ adaptiveGlyph: NSAdaptiveImageGlyph) -> Bool { + guard let payload = extractMediaPayload(from: adaptiveGlyph) else { diff --git a/patches/expo-paste-input+0.1.15.patch.md b/patches/expo-paste-input+0.1.15.patch.md new file mode 100644 index 0000000000..8fd6644144 --- /dev/null +++ b/patches/expo-paste-input+0.1.15.patch.md @@ -0,0 +1,22 @@ +# Expo Paste Input Patch + +`expo-paste-input` observes `UITextView.textDidChangeNotification` and treats any +`NSTextAttachment` in the text view's `attributedText` as a pasted image. When +it can't find a real image payload on an attachment, it falls back to +`image(forBounds:)` and, failing that, to a `drawHierarchy` screenshot of the +text view at the attachment's glyph rect. + +iOS Dictation inserts its own `NSTextAttachment` (the shimmer/cursor indicator) +into the text view during dictation. Those attachments don't carry real image +data, so the fallbacks would fire — emitting a zoomed-in screenshot of the +composer as if the user had pasted an image at the end of dictation. + +This patch: + +- Removes the `image(forBounds:)` and `renderTextAttachment` fallbacks in + `extractMediaPayload` so the library only accepts attachments carrying a real + payload (`fileWrapper`, `contents`, or `image`). +- Only sanitizes (deletes) attachment ranges that produced a payload, and + skips the "unsupported" toast when an attachment has no payload. Unknown + system attachments like the dictation placeholder are left alone rather + than being ripped out from under iOS.