Skip to content

Commit d3b90c4

Browse files
authored
Fix playback of animated GIFs in notification content extension (home-assistant#1711)
Fixes home-assistant#1709. ## Summary Loads a webview to play back gif attachments, since they may be animated and UIImageView doesn't handle that for us. ## Any other notes Due to sandboxing and security scoping, getting this to display reliably is a little convoluted - we need to load by Data and inject a CSS style to get the image to scale as we want, since we size the webview to fit based on aspect fit rules.
1 parent 2180a17 commit d3b90c4

File tree

1 file changed

+84
-13
lines changed

1 file changed

+84
-13
lines changed

Sources/Extensions/NotificationContent/ImageAttachmentViewController.swift

+84-13
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,29 @@ import Shared
44
import UIKit
55
import UserNotifications
66
import UserNotificationsUI
7+
import WebKit
78

89
class ImageAttachmentViewController: UIViewController, NotificationCategory {
910
let attachmentURL: URL
1011
let needsEndSecurityScoped: Bool
1112
let image: UIImage
12-
let imageView = with(UIImageView()) {
13-
$0.contentMode = .scaleAspectFit
13+
let imageData: Data
14+
let imageUTI: CFString
15+
16+
enum ImageViewType {
17+
case imageView(UIImageView)
18+
case webView(WKWebView)
19+
20+
var view: UIView {
21+
switch self {
22+
case let .imageView(imageView): return imageView
23+
case let .webView(webView): return webView
24+
}
25+
}
1426
}
1527

28+
let visibleView: ImageViewType
29+
1630
required init(notification: UNNotification, attachmentURL: URL?) throws {
1731
guard let attachmentURL = attachmentURL else {
1832
throw ImageAttachmentError.noAttachment
@@ -25,12 +39,55 @@ class ImageAttachmentViewController: UIViewController, NotificationCategory {
2539
// has the full list of what is advertised - at time of writing (iOS 14.5) it's jpeg, gif and png
2640
// but iOS 14 also supports webp, so who knows if it'll be added silently or not
2741

28-
guard let image = UIImage(contentsOfFile: attachmentURL.path) else {
42+
do {
43+
let data = try Data(contentsOf: attachmentURL, options: .alwaysMapped)
44+
guard let image = UIImage(data: data) else {
45+
throw ImageAttachmentError.imageDecodeFailure
46+
}
47+
self.image = image
48+
self.imageData = data
49+
50+
if let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
51+
let uti = CGImageSourceGetType(imageSource) {
52+
self.imageUTI = uti
53+
} else {
54+
// can't figure out, just assume JPEG
55+
self.imageUTI = kUTTypeJPEG
56+
}
57+
58+
if UTTypeConformsTo(imageUTI, kUTTypeGIF) {
59+
// use a WebView for gif so we can animate without pulling in a third party library
60+
let config = with(WKWebViewConfiguration()) {
61+
$0.userContentController = with(WKUserContentController()) {
62+
// we can't use `loadHTMLString` with `<img>` inside to do styling because the webview can't get
63+
// the security scoped file if loaded by the service extension so we need to load data directly
64+
$0.addUserScript(WKUserScript(source: """
65+
var style = document.createElement('style');
66+
style.innerHTML = `
67+
img { width: 100%; height: 100%; }
68+
`;
69+
document.head.appendChild(style);
70+
""", injectionTime: .atDocumentEnd, forMainFrameOnly: true))
71+
}
72+
}
73+
74+
visibleView = .webView(with(WKWebView(frame: .zero, configuration: config)) {
75+
$0.scrollView.isScrollEnabled = false
76+
$0.isOpaque = false
77+
$0.backgroundColor = .clear
78+
$0.scrollView.backgroundColor = .clear
79+
})
80+
} else {
81+
self.visibleView = .imageView(with(UIImageView()) {
82+
$0.contentMode = .scaleAspectFit
83+
})
84+
}
85+
86+
} catch {
2987
attachmentURL.stopAccessingSecurityScopedResource()
30-
throw ImageAttachmentError.imageDecodeFailure
88+
throw error
3189
}
3290

33-
self.image = image
3491
self.attachmentURL = attachmentURL
3592
super.init(nibName: nil, bundle: nil)
3693
}
@@ -68,9 +125,22 @@ class ImageAttachmentViewController: UIViewController, NotificationCategory {
68125
}
69126

70127
func start() -> Promise<Void> {
71-
imageView.image = image
72128
lastAttachmentURL = attachmentURL
73-
aspectRatioConstraint = NSLayoutConstraint.aspectRatioConstraint(on: imageView, size: image.size)
129+
130+
switch visibleView {
131+
case let .webView(webView):
132+
let mime = UTTypeCopyPreferredTagWithClass(imageUTI, kUTTagClassMIMEType)?.takeRetainedValue() as String?
133+
webView.load(
134+
imageData,
135+
mimeType: mime ?? "image/gif",
136+
characterEncodingName: "UTF-8",
137+
baseURL: attachmentURL
138+
)
139+
case let .imageView(imageView):
140+
imageView.image = image
141+
}
142+
143+
aspectRatioConstraint = NSLayoutConstraint.aspectRatioConstraint(on: visibleView.view, size: image.size)
74144

75145
return .value(())
76146
}
@@ -92,13 +162,14 @@ class ImageAttachmentViewController: UIViewController, NotificationCategory {
92162
override func viewDidLoad() {
93163
super.viewDidLoad()
94164

95-
view.addSubview(imageView)
96-
imageView.translatesAutoresizingMaskIntoConstraints = false
165+
let subview = visibleView.view
166+
view.addSubview(subview)
167+
subview.translatesAutoresizingMaskIntoConstraints = false
97168
NSLayoutConstraint.activate([
98-
imageView.topAnchor.constraint(equalTo: view.topAnchor),
99-
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
100-
imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
101-
imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
169+
subview.topAnchor.constraint(equalTo: view.topAnchor),
170+
subview.leadingAnchor.constraint(equalTo: view.leadingAnchor),
171+
subview.trailingAnchor.constraint(equalTo: view.trailingAnchor),
172+
subview.bottomAnchor.constraint(equalTo: view.bottomAnchor),
102173
])
103174
}
104175

0 commit comments

Comments
 (0)