Here is the code of my message extension, if that helps. I am indeed trying to write to the shared storage of the App Groups
import os.log // Apple's modern, fast, privacy-safe logging system
class NotificationService: UNNotificationServiceExtension {
private let log = OSLog(
subsystem: Bundle.main.bundleIdentifier!,
category: "pushnotificationsmessageextension"
)
var contentHandler: ((UNNotificationContent) -> Void)!
// A mutable copy of the notification content — this is what we'll modify or save
var bestAttemptContent: UNMutableNotificationContent?
// Main entry point — called every time a push arrives with `mutable-content: 1`
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler:
@escaping (UNNotificationContent) -> Void
) {
// Save the handler so we can call it later (required!)
self.contentHandler = contentHandler
// Make a mutable copy so we can modify title, body, attachments, etc.
bestAttemptContent =
request.content.mutableCopy() as? UNMutableNotificationContent
// If something went wrong making the mutable copy, just pass through the original
guard let bestAttemptContent = bestAttemptContent else {
contentHandler(request.content)
return
}
// 1. Create a mutable dictionary to work with
var mutablePayload = request.content.userInfo
// extract Title and Body from the 'aps' dictionary
let aps = mutablePayload["aps"] as? [String: Any]
let alert = aps?["alert"] as? [String: Any]
let title = alert?["title"] as? String ?? "No Title"
let body = alert?["body"] as? String ?? "No Body"
// 2. Add the current timestamp as an ISO 8601 string
let now = Date()
let formatter = ISO8601DateFormatter()
// Set the timezone to America/Toronto (covers both Toronto and New York Eastern Time)
if let easternTimeZone = TimeZone(identifier: "America/Toronto") {
formatter.timeZone = easternTimeZone
} else {
// Fallback: If the specified timezone isn't found, use UTC (Good for reliability)
formatter.timeZone = TimeZone(secondsFromGMT: 0)
print(
"WARNING: 'America/Toronto' TimeZone not found, falling back to UTC for timestamp."
)
}
// Set the format to include the time and timezone offset (e.g., 2025-12-08T11:34:21-05:00)
formatter.formatOptions = [
.withInternetDateTime, .withFractionalSeconds, .withTimeZone,
]
let timestampString = formatter.string(from: now)
let uuid = UUID()
let minimalPayload: [String: Any] = [
"unread": true,
"timestamp": timestampString,
"title": title,
"body": body,
"uuid": uuid.uuidString
]
// 3. Serialize the MODIFIED dictionary into a JSON string
let jsonData = try? JSONSerialization.data(
withJSONObject: minimalPayload,
options: []
)
let newJsonString = jsonData.flatMap {
String(data: $0, encoding: .utf8)
}
// Access shared container (App Groups) between main app and extension
guard
let shared = UserDefaults(
suiteName: "group.com.mycompany.pushnotifications"
)
else {
print(
"FATAL ERROR: Could not initialize shared UserDefaults (App Group may be missing or incorrect)."
)
return
}
if let stringToSave = newJsonString {
// 4. Read the existing HISTORY string (not array)
// If the key doesn't exist, it defaults to an empty JSON array string "[]"
let existingHistoryString =
shared.string(forKey: "push_notification_history_json") ?? "[]"
// 5. Convert the existing JSON string back into a Swift array of strings
var notificationHistory: [String] = []
if let data = existingHistoryString.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(
with: data,
options: []
) as? [String]
{
notificationHistory = array
}
// 6. Add the new, timestamped JSON string to the list
notificationHistory.append(stringToSave)
// Optional: Limit the size of the history to prevent the storage file from growing infinitely.
// E.g., keep only the last 100 notifications.
let maxHistoryCount = 100
if notificationHistory.count > maxHistoryCount {
// Keeps the latest 'maxHistoryCount' items
notificationHistory.removeFirst(notificationHistory.count - maxHistoryCount)
}
// 7. Serialize the ENTIRE array of JSON strings back into ONE single JSON string
if let dataToWrite = try? JSONSerialization.data(
withJSONObject: notificationHistory,
options: []
),
let finalHistoryString = String(
data: dataToWrite,
encoding: .utf8
)
{
// 8. Save the final JSON string under a new key (renamed for clarity)
shared.set(
finalHistoryString,
forKey: "push_notification_history_json"
)
shared.synchronize()
print(
"Successfully saved entire history as one JSON string. Current count: \(notificationHistory.count)"
)
} else {
print(
"FATAL ERROR: Could not re-serialize history array for saving."
)
}
} else {
print(
"WARNING: Could not serialize payload. Nothing was saved to history."
)
}
// FINALLY: tell iOS to show the notification (with our modifications if any)
contentHandler(bestAttemptContent)
}
// Called by iOS when it's about to kill the extension due to timeout (~30 seconds)
// If we haven't called contentHandler yet, we do it now with whatever we have
// Prevents notification from being dropped entirely
override func serviceExtensionTimeWillExpire() {
// iOS is about to kill the extension – deliver what we have
if let contentHandler = contentHandler,
let bestAttemptContent = bestAttemptContent
{
contentHandler(bestAttemptContent)
}
}
}