Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.
71
89%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
Rich notifications enhance the standard notification banner with images, video, audio, custom UI, and interactive elements. They use two extension types: Notification Service Extension (modifies content before display) and Notification Content Extension (provides custom UI in the expanded notification).
A Notification Service Extension runs when a notification with mutable-content: 1 arrives. It has approximately 30 seconds to modify the notification content before the system displays it. If the extension does not call the content handler in time, the system displays the original notification.
In Xcode: File > New > Target > Notification Service Extension. This creates a new target with a NotificationService class.
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
// Modify the notification content here
Task {
await processNotification(content: content)
contentHandler(content)
}
}
/// Called if the extension is about to be terminated (ran out of time).
/// Deliver the best attempt -- even a partially modified notification
/// is better than nothing.
override func serviceExtensionTimeWillExpire() {
if let handler = contentHandler, let content = bestAttemptContent {
handler(content)
}
}
private func processNotification(content: UNMutableNotificationContent) async {
// Download image if URL is provided
if let imageUrlString = content.userInfo["imageUrl"] as? String {
await attachImage(from: imageUrlString, to: content)
}
// Decrypt body if encrypted
if let encrypted = content.userInfo["encryptedBody"] as? String {
content.body = decrypt(encrypted)
}
}
}Download media from a URL and attach it to the notification. The attachment must be written to disk in the extension's temporary directory.
extension NotificationService {
private func attachImage(
from urlString: String,
to content: UNMutableNotificationContent
) async {
guard let url = URL(string: urlString) else { return }
do {
let (data, response) = try await URLSession.shared.data(from: url)
// Determine file extension from MIME type
let ext = fileExtension(for: response.mimeType)
let tempDir = FileManager.default.temporaryDirectory
let fileURL = tempDir.appendingPathComponent(UUID().uuidString + ext)
try data.write(to: fileURL)
let attachment = try UNNotificationAttachment(
identifier: "image",
url: fileURL,
options: nil
)
content.attachments = [attachment]
} catch {
print("Failed to download notification image: \(error)")
// Notification displays without the image -- graceful degradation
}
}
private func fileExtension(for mimeType: String?) -> String {
switch mimeType {
case "image/jpeg": return ".jpg"
case "image/png": return ".png"
case "image/gif": return ".gif"
case "video/mp4", "video/mpeg4": return ".mp4"
case "audio/mpeg", "audio/mp3": return ".mp3"
case "audio/wav": return ".wav"
default: return ".jpg"
}
}
}Use the service extension to decrypt sensitive notification content. The APNs payload carries an encrypted blob; the extension decrypts it before display.
extension NotificationService {
private func decrypt(_ encryptedBase64: String) -> String {
guard let data = Data(base64Encoded: encryptedBase64) else {
return "New notification" // fallback
}
// Use your encryption library (CryptoKit, etc.) to decrypt
// The decryption key should be stored in the shared Keychain
// accessible to both the main app and the extension via App Groups.
do {
let decrypted = try EncryptionService.shared.decrypt(data)
return String(data: decrypted, encoding: .utf8) ?? "New notification"
} catch {
return "New notification"
}
}
}The service extension runs in a separate process from the main app. Use App Groups to share data (Keychain items, UserDefaults, files) between them.
group.com.example.myapp).// Shared UserDefaults
let sharedDefaults = UserDefaults(suiteName: "group.com.example.myapp")
// Shared file container
let sharedContainer = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.example.myapp"
)
// Shared Keychain: set kSecAttrAccessGroup to your App Group identifier
// when storing/retrieving Keychain items.Extension memory limit: Notification Service Extensions have a strict memory limit (approximately 24 MB). Do not load large frameworks or perform memory-intensive operations. If the extension exceeds memory limits, the system terminates it and shows the original notification.
UNNotificationAttachment supports images, GIFs, video, and audio. The system displays a thumbnail in the collapsed notification and the full media in the expanded view.
| Type | Formats | Max Size |
|---|---|---|
| Image | JPEG, PNG, GIF | 10 MB |
| Audio | AIFF, WAV, MP3, M4A | 5 MB |
| Video | MPEG, MPEG-2, MPEG-4, AVI | 50 MB |
// From a local file URL
let attachment = try UNNotificationAttachment(
identifier: "photo",
url: localFileURL,
options: nil
)
// With options for thumbnailing
let attachment = try UNNotificationAttachment(
identifier: "photo",
url: localFileURL,
options: [
UNNotificationAttachmentOptionsThumbnailClippingRectKey:
CGRect(x: 0, y: 0, width: 1, height: 0.5).dictionaryRepresentation,
UNNotificationAttachmentOptionsThumbnailTimeKey: 0 // for video: thumbnail at 0 seconds
]
)
// Attach to content
content.attachments = [attachment]Important: The file URL must point to a file that the notification system can access. For service extensions, write to the extension's temporary directory. For local notifications, write to a location accessible by the notification system (not the app's sandbox). The system moves the file after attachment creation, so the original URL becomes invalid.
You can attach multiple items, but only the first attachment is shown as the thumbnail in the collapsed notification. The expanded view can show all attachments.
content.attachments = [imageAttachment, audioAttachment]
// imageAttachment appears as the thumbnailGIFs animate automatically in the expanded notification view. No special handling needed -- attach the GIF file as any other image.
let gifURL = tempDir.appendingPathComponent("animation.gif")
try gifData.write(to: gifURL)
let attachment = try UNNotificationAttachment(
identifier: "animation",
url: gifURL,
options: nil
)A Notification Content Extension provides a custom view controller that displays when the user long-presses (or expands) a notification. Use it for richer UI than attachments alone can provide.
In Xcode: File > New > Target > Notification Content Extension. This creates a new target with a storyboard and a NotificationViewController.
The extension's Info.plist must declare which notification categories it handles:
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>UNNotificationExtensionCategory</key>
<!-- Single category -->
<string>MESSAGE_CATEGORY</string>
<!-- Or multiple categories -->
<array>
<string>MESSAGE_CATEGORY</string>
<string>PHOTO_CATEGORY</string>
</array>
<!-- Optional: size ratio (height / width). Default 1.0 -->
<key>UNNotificationExtensionInitialContentSizeRatio</key>
<real>0.5</real>
<!-- Optional: hide the default notification body below the custom UI -->
<key>UNNotificationExtensionDefaultContentHidden</key>
<true/>
<!-- Optional: allow user interaction in the custom UI -->
<key>UNNotificationExtensionUserInteractionEnabled</key>
<true/>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.content-extension</string>
</dict>import UIKit
import UserNotifications
import UserNotificationsUI
class NotificationViewController: UIViewController, UNNotificationContentExtension {
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var bodyLabel: UILabel!
@IBOutlet weak var imageView: UIImageView!
func didReceive(_ notification: UNNotification) {
let content = notification.request.content
titleLabel.text = content.title
bodyLabel.text = content.body
// Display the first attachment
if let attachment = content.attachments.first,
attachment.url.startAccessingSecurityScopedResource() {
defer { attachment.url.stopAccessingSecurityScopedResource() }
if let data = try? Data(contentsOf: attachment.url) {
imageView.image = UIImage(data: data)
}
}
}
/// Called when the user taps a notification action while the content
/// extension is visible.
func didReceive(
_ response: UNNotificationResponse,
completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption) -> Void
) {
switch response.actionIdentifier {
case "LIKE_ACTION":
// Update UI to show "liked" state
animateLikeConfirmation()
// Dismiss after a short delay
Task {
try? await Task.sleep(for: .seconds(0.5))
completion(.dismiss)
}
case "REPLY_ACTION":
if let textResponse = response as? UNTextInputNotificationResponse {
// Handle reply inline without opening the app
showReplySentConfirmation(text: textResponse.userText)
Task {
try? await Task.sleep(for: .seconds(1.0))
completion(.dismiss)
}
}
default:
// Forward to the app's notification delegate
completion(.dismissAndForwardAction)
}
}
}| Option | Behavior |
|---|---|
.doNotDismiss | Keep the content extension visible. Use for multi-step interactions. |
.dismiss | Dismiss the notification. The action is handled entirely in the extension. |
.dismissAndForwardAction | Dismiss and forward the action to UNUserNotificationCenterDelegate.didReceive. Use when the app needs to handle the action. |
The content extension can play audio or video. Implement mediaPlayPauseButtonType and mediaPlayPauseButtonFrame for a system-provided play/pause button.
class MediaNotificationViewController: UIViewController, UNNotificationContentExtension {
var player: AVPlayer?
override var mediaPlayPauseButtonType: UNNotificationContentExtensionMediaPlayPauseButtonType {
return .overlay // .none, .default, .overlay
}
override var mediaPlayPauseButtonFrame: CGRect {
return CGRect(x: 0, y: 0, width: view.bounds.width, height: view.bounds.height)
}
func mediaPlay() {
player?.play()
}
func mediaPause() {
player?.pause()
}
func didReceive(_ notification: UNNotification) {
guard let attachment = notification.request.content.attachments.first,
attachment.url.startAccessingSecurityScopedResource() else { return }
let playerItem = AVPlayerItem(url: attachment.url)
player = AVPlayer(playerItem: playerItem)
let playerLayer = AVPlayerLayer(player: player)
playerLayer.frame = view.bounds
view.layer.addSublayer(playerLayer)
}
}With UNNotificationExtensionUserInteractionEnabled set to true, the content extension supports gesture recognizers, buttons, and other interactive elements.
class InteractiveNotificationViewController: UIViewController, UNNotificationContentExtension {
private var ratingStars: [UIButton] = []
private var selectedRating = 0
override func viewDidLoad() {
super.viewDidLoad()
setupRatingUI()
}
func didReceive(_ notification: UNNotification) {
// Configure with notification content
}
private func setupRatingUI() {
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
for i in 1...5 {
let button = UIButton(type: .system)
button.setImage(UIImage(systemName: "star"), for: .normal)
button.tag = i
button.addTarget(self, action: #selector(starTapped(_:)), for: .touchUpInside)
stack.addArrangedSubview(button)
ratingStars.append(button)
}
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
@objc private func starTapped(_ sender: UIButton) {
selectedRating = sender.tag
for (index, button) in ratingStars.enumerated() {
let imageName = index < selectedRating ? "star.fill" : "star"
button.setImage(UIImage(systemName: imageName), for: .normal)
}
// Send rating to server
Task {
await submitRating(selectedRating)
}
}
}Communication notifications display the sender's avatar and name prominently. They use SiriKit intents (INSendMessageIntent) to provide sender information.
Intents framework to the Notification Service Extension target.INSendMessageIntent in the service extension before delivery.import Intents
import UserNotifications
extension NotificationService {
func configureCommunicationNotification(
content: UNMutableNotificationContent,
senderName: String,
senderImageURL: String?,
conversationId: String
) async {
// Create the sender identity
let handle = INPersonHandle(value: conversationId, type: .unknown)
var avatar: INImage? = nil
// Download sender avatar
if let urlString = senderImageURL,
let url = URL(string: urlString) {
if let (data, _) = try? await URLSession.shared.data(from: url) {
avatar = INImage(imageData: data)
}
}
let sender = INPerson(
personHandle: handle,
nameComponents: try? PersonNameComponents(senderName),
displayName: senderName,
image: avatar,
contactIdentifier: nil,
customIdentifier: conversationId
)
// Create the messaging intent
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: nil,
conversationIdentifier: conversationId,
serviceName: nil,
sender: sender,
attachments: nil
)
// Donate the interaction so Siri learns about this contact
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
try? await interaction.donate()
// Update the notification content with the intent
do {
let updatedContent = try content.updating(from: intent)
// Copy updated properties back to mutable content
// The updating(from:) method returns a new UNNotificationContent,
// so we need to use the content handler with the updated version.
// In practice, call the content handler with updatedContent directly.
} catch {
print("Failed to update content with intent: \(error)")
}
}
}The complete flow integrates communication notifications into the standard service extension:
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
contentHandler(request.content)
return
}
Task {
// Download and attach image
if let imageUrl = content.userInfo["imageUrl"] as? String {
await attachImage(from: imageUrl, to: content)
}
// Configure as communication notification if sender info is present
if let senderName = content.userInfo["senderName"] as? String,
let conversationId = content.userInfo["conversationId"] as? String {
let senderImage = content.userInfo["senderImageUrl"] as? String
let handle = INPersonHandle(value: conversationId, type: .unknown)
var avatar: INImage? = nil
if let urlString = senderImage,
let url = URL(string: urlString),
let (data, _) = try? await URLSession.shared.data(from: url) {
avatar = INImage(imageData: data)
}
let sender = INPerson(
personHandle: handle,
nameComponents: try? PersonNameComponents(senderName),
displayName: senderName,
image: avatar,
contactIdentifier: nil,
customIdentifier: conversationId
)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: nil,
conversationIdentifier: conversationId,
serviceName: nil,
sender: sender,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
try? await interaction.donate()
if let updatedContent = try? content.updating(from: intent) {
contentHandler(updatedContent)
return
}
}
contentHandler(content)
}
}Service extension not running:
mutable-content: 1 is set in the APNs payload.<main-app-bundle-id>.NotificationServiceExtension.Content extension not showing:
UNNotificationExtensionCategory in Info.plist matches the categoryIdentifier in the notification.UNNotificationExtensionInitialContentSizeRatio is set to a reasonable value.Memory pressure:
URLSession directly for network requests in extensions.Network access in extensions:
URLSession with the .default configuration.Debugging extensions:
Debug > Attach to Process by PID or Name with the extension's process name.os_log or print statements and view them in Console.app filtered by the extension's bundle identifier.A production-ready service extension that handles image download, body decryption, and communication notifications:
import UserNotifications
import Intents
class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
Task {
// Step 1: Decrypt body if needed
if let encrypted = content.userInfo["encryptedBody"] as? String,
let decrypted = decryptBody(encrypted) {
content.body = decrypted
}
// Step 2: Download and attach image
if let imageUrl = content.userInfo["imageUrl"] as? String {
await attachMedia(from: imageUrl, to: content)
}
// Step 3: Configure communication notification
if let senderName = content.userInfo["senderName"] as? String,
let convId = content.userInfo["conversationId"] as? String {
if let updated = await configureAsCommunication(
content: content,
senderName: senderName,
senderImageURL: content.userInfo["senderImage"] as? String,
conversationId: convId
) {
contentHandler(updated)
return
}
}
contentHandler(content)
}
}
override func serviceExtensionTimeWillExpire() {
if let handler = contentHandler, let content = bestAttemptContent {
handler(content)
}
}
// MARK: - Private
private func decryptBody(_ base64: String) -> String? {
guard let data = Data(base64Encoded: base64) else { return nil }
// Use shared Keychain via App Group to retrieve encryption key
let defaults = UserDefaults(suiteName: "group.com.example.myapp")
guard let keyData = defaults?.data(forKey: "encryptionKey") else { return nil }
// Decrypt using CryptoKit or similar
return try? Decryptor.decrypt(data, key: keyData)
}
private func attachMedia(
from urlString: String,
to content: UNMutableNotificationContent
) async {
guard let url = URL(string: urlString) else { return }
do {
let (data, response) = try await URLSession.shared.data(from: url)
let ext = fileExtension(for: response.mimeType)
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ext)
try data.write(to: fileURL)
let attachment = try UNNotificationAttachment(
identifier: UUID().uuidString,
url: fileURL,
options: nil
)
content.attachments = [attachment]
} catch {
// Image download failed -- notification displays without media
}
}
private func configureAsCommunication(
content: UNMutableNotificationContent,
senderName: String,
senderImageURL: String?,
conversationId: String
) async -> UNNotificationContent? {
let handle = INPersonHandle(value: conversationId, type: .unknown)
var avatar: INImage? = nil
if let urlStr = senderImageURL,
let url = URL(string: urlStr),
let (data, _) = try? await URLSession.shared.data(from: url) {
avatar = INImage(imageData: data)
}
let sender = INPerson(
personHandle: handle,
nameComponents: try? PersonNameComponents(senderName),
displayName: senderName,
image: avatar,
contactIdentifier: nil,
customIdentifier: conversationId
)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: nil,
conversationIdentifier: conversationId,
serviceName: nil,
sender: sender,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
try? await interaction.donate()
return try? content.updating(from: intent)
}
private func fileExtension(for mimeType: String?) -> String {
switch mimeType {
case "image/jpeg": return ".jpg"
case "image/png": return ".png"
case "image/gif": return ".gif"
case "video/mp4": return ".mp4"
case "audio/mpeg": return ".mp3"
default: return ".jpg"
}
}
}skills
accessorysetupkit
references
activitykit
references
adattributionkit
references
alarmkit
references
app-clips
app-intents
references
app-store-optimization
app-store-review
apple-on-device-ai
appmigrationkit
references
audioaccessorykit
references
authentication
references
avkit
references
background-processing
references
browserenginekit
references
callkit
references
carplay
references
cloudkit
references
contacts-framework
references
core-bluetooth
references
core-data
core-motion
references
core-nfc
references
coreml
references
cryptokit
references
cryptotokenkit
references
debugging-instruments
device-integrity
references
dockkit
references
energykit
references
eventkit
references
financekit
references
focus-engine
gamekit
references
healthkit
references
homekit
references
ios-accessibility
ios-localization
ios-networking
ios-simulator
references
mapkit
metrickit
references
musickit
references
natural-language
references
paperkit
references
passkit
references
pdfkit
references
pencilkit
references
permissionkit
references
photokit
push-notifications
realitykit
references
relevancekit
references
scenekit
references
sensorkit
references
speech-recognition
spritekit
references
storekit
swift-api-design-guidelines
swift-architecture
swift-charts
references
swift-codable
swift-concurrency
swift-formatstyle
swift-language
swift-security
references
swift-testing
swiftdata
swiftlint
swiftui-animation
swiftui-gestures
references
swiftui-layout-components
swiftui-liquid-glass
references
swiftui-patterns
swiftui-performance
swiftui-uikit-interop
swiftui-webkit
tabletopkit
references
tipkit
references
vision-framework
weatherkit
references
widgetkit
references