Implement communication notifications
This commit is contained in:
parent
9cf4975bfd
commit
9f6910ba73
|
@ -12,13 +12,21 @@ import PushNotifications
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import OSLog
|
import OSLog
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
import Intents
|
||||||
|
import HTMLStreamer
|
||||||
|
import WebURL
|
||||||
|
|
||||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationService")
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationService")
|
||||||
|
|
||||||
class NotificationService: UNNotificationServiceExtension {
|
class NotificationService: UNNotificationServiceExtension {
|
||||||
|
|
||||||
|
private static let textConverter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLCallbacks.self)
|
||||||
|
|
||||||
|
private var pendingRequest: (UNMutableNotificationContent, (UNNotificationContent) -> Void, Task<Void, Never>)?
|
||||||
|
|
||||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||||
guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
||||||
|
logger.error("Couldn't get mutable content")
|
||||||
contentHandler(request.content)
|
contentHandler(request.content)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -54,10 +62,172 @@ class NotificationService: UNNotificationServiceExtension {
|
||||||
mutableContent.title = notification.title
|
mutableContent.title = notification.title
|
||||||
mutableContent.body = notification.body
|
mutableContent.body = notification.body
|
||||||
|
|
||||||
contentHandler(mutableContent)
|
let task = Task {
|
||||||
|
await updateNotificationContent(mutableContent, account: account, push: notification)
|
||||||
|
if !Task.isCancelled {
|
||||||
|
contentHandler(pendingRequest?.0 ?? mutableContent)
|
||||||
|
pendingRequest = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingRequest = (mutableContent, contentHandler, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func serviceExtensionTimeWillExpire() {
|
override func serviceExtensionTimeWillExpire() {
|
||||||
|
if let pendingRequest {
|
||||||
|
logger.debug("Expiring with pending request")
|
||||||
|
pendingRequest.2.cancel()
|
||||||
|
pendingRequest.1(pendingRequest.0)
|
||||||
|
self.pendingRequest = nil
|
||||||
|
} else {
|
||||||
|
logger.debug("Expiring without pending request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNotificationContent(_ content: UNMutableNotificationContent, account: UserAccountInfo, push: PushNotification) async {
|
||||||
|
let client = Client(baseURL: account.instanceURL, accessToken: account.accessToken)
|
||||||
|
let notification: Pachyderm.Notification
|
||||||
|
do {
|
||||||
|
notification = try await client.run(Client.getNotification(id: push.notificationID)).0
|
||||||
|
} catch {
|
||||||
|
logger.error("Error fetching notification: \(String(describing: error))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let kindStr: String?
|
||||||
|
switch notification.kind {
|
||||||
|
case .reblog:
|
||||||
|
kindStr = "🔁 Reblogged"
|
||||||
|
case .favourite:
|
||||||
|
kindStr = "⭐️ Favorited"
|
||||||
|
case .follow:
|
||||||
|
kindStr = "👤 Followed by @\(notification.account.acct)"
|
||||||
|
case .followRequest:
|
||||||
|
kindStr = "👤 Asked to follow by @\(notification.account.acct)"
|
||||||
|
case .poll:
|
||||||
|
kindStr = "📊 Poll finished"
|
||||||
|
case .update:
|
||||||
|
kindStr = "✏️ Edited"
|
||||||
|
default:
|
||||||
|
kindStr = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let notificationContent: String?
|
||||||
|
if let status = notification.status {
|
||||||
|
notificationContent = NotificationService.textConverter.convert(html: status.content)
|
||||||
|
} else if notification.kind == .follow || notification.kind == .followRequest {
|
||||||
|
notificationContent = nil
|
||||||
|
} else {
|
||||||
|
notificationContent = push.body
|
||||||
|
}
|
||||||
|
|
||||||
|
content.body = [kindStr, notificationContent].compactMap { $0 }.joined(separator: "\n")
|
||||||
|
|
||||||
|
let attachmentDataTask: Task<URL?, Never>?
|
||||||
|
// We deliberately don't include attachments for other types of notifications that have statuses (favs, etc.)
|
||||||
|
// because we risk just fetching the same thing a bunch of times for many senders.
|
||||||
|
if notification.kind == .mention || notification.kind == .status || notification.kind == .update,
|
||||||
|
let attachment = notification.status?.attachments.first {
|
||||||
|
let url = attachment.previewURL ?? attachment.url
|
||||||
|
attachmentDataTask = Task {
|
||||||
|
do {
|
||||||
|
let data = try await URLSession.shared.data(from: url).0
|
||||||
|
let localAttachmentURL = FileManager.default.temporaryDirectory.appendingPathComponent("attachment_\(attachment.id)").appendingPathExtension(url.pathExtension)
|
||||||
|
try data.write(to: localAttachmentURL)
|
||||||
|
return localAttachmentURL
|
||||||
|
} catch {
|
||||||
|
logger.error("Error setting notification attachments: \(String(describing: error))")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attachmentDataTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let conversationIdentifier: String?
|
||||||
|
if let status = notification.status {
|
||||||
|
if let context = status.pleromaExtras?.context {
|
||||||
|
conversationIdentifier = "context:\(context)"
|
||||||
|
} else if [Notification.Kind.reblog, .favourite, .poll, .update].contains(notification.kind) {
|
||||||
|
conversationIdentifier = "status:\(status.id)"
|
||||||
|
} else {
|
||||||
|
conversationIdentifier = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
conversationIdentifier = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let account: Account?
|
||||||
|
switch notification.kind {
|
||||||
|
case .mention, .status:
|
||||||
|
account = notification.status?.account
|
||||||
|
default:
|
||||||
|
account = notification.account
|
||||||
|
}
|
||||||
|
let sender: INPerson?
|
||||||
|
if let account {
|
||||||
|
let handle = INPersonHandle(value: "@\(account.acct)", type: .unknown)
|
||||||
|
let image: INImage?
|
||||||
|
if let avatar = account.avatar,
|
||||||
|
let (data, resp) = try? await URLSession.shared.data(from: avatar),
|
||||||
|
let code = (resp as? HTTPURLResponse)?.statusCode,
|
||||||
|
(200...299).contains(code) {
|
||||||
|
image = INImage(imageData: data)
|
||||||
|
} else {
|
||||||
|
image = nil
|
||||||
|
}
|
||||||
|
sender = INPerson(
|
||||||
|
personHandle: handle,
|
||||||
|
nameComponents: nil,
|
||||||
|
displayName: account.displayName,
|
||||||
|
image: image,
|
||||||
|
contactIdentifier: nil,
|
||||||
|
customIdentifier: account.id
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sender = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let intent = INSendMessageIntent(
|
||||||
|
recipients: nil,
|
||||||
|
outgoingMessageType: .outgoingMessageText,
|
||||||
|
content: notificationContent,
|
||||||
|
speakableGroupName: nil,
|
||||||
|
conversationIdentifier: conversationIdentifier,
|
||||||
|
serviceName: nil,
|
||||||
|
sender: sender,
|
||||||
|
attachments: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let interaction = INInteraction(intent: intent, response: nil)
|
||||||
|
interaction.direction = .incoming
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await interaction.donate()
|
||||||
|
} catch {
|
||||||
|
logger.error("Error donating interaction: \(String(describing: error))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedContent: UNMutableNotificationContent
|
||||||
|
do {
|
||||||
|
let newContent = try content.updating(from: intent)
|
||||||
|
if let newMutableContent = newContent.mutableCopy() as? UNMutableNotificationContent {
|
||||||
|
pendingRequest?.0 = newMutableContent
|
||||||
|
updatedContent = newMutableContent
|
||||||
|
} else {
|
||||||
|
updatedContent = content
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Error updating notification from intent: \(String(describing: error))")
|
||||||
|
updatedContent = content
|
||||||
|
}
|
||||||
|
|
||||||
|
if let localAttachmentURL = await attachmentDataTask?.value,
|
||||||
|
let attachment = try? UNNotificationAttachment(identifier: localAttachmentURL.lastPathComponent, url: localAttachmentURL) {
|
||||||
|
updatedContent.attachments = [
|
||||||
|
attachment
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getSubscription(account: UserAccountInfo) -> PushNotifications.PushSubscription? {
|
private func getSubscription(account: UserAccountInfo) -> PushNotifications.PushSubscription? {
|
||||||
|
@ -148,3 +318,35 @@ private func decodeBase64URL(_ s: String) -> Data? {
|
||||||
}
|
}
|
||||||
return Data(base64Encoded: str)
|
return Data(base64Encoded: str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copied from HTMLConverter.Callbacks, blergh
|
||||||
|
private struct HTMLCallbacks: HTMLConversionCallbacks {
|
||||||
|
static func makeURL(string: String) -> URL? {
|
||||||
|
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
||||||
|
// serializing the WebURL as a string and then having Foundation parse it again),
|
||||||
|
// so, if available, use the system parser which doesn't require another round trip.
|
||||||
|
if #available(iOS 16.0, macOS 13.0, *),
|
||||||
|
let url = try? URL.ParseStrategy().parse(string) {
|
||||||
|
url
|
||||||
|
} else if let web = WebURL(string),
|
||||||
|
let url = URL(web) {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
URL(string: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
|
||||||
|
guard name == "span" else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
let clazz = attributes.attributeValue(for: "class")
|
||||||
|
if clazz == "invisible" {
|
||||||
|
return .skip
|
||||||
|
} else if clazz == "ellipsis" {
|
||||||
|
return .append("…")
|
||||||
|
} else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -341,6 +341,10 @@ public struct Client: Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notifications
|
// MARK: - Notifications
|
||||||
|
public static func getNotification(id: String) -> Request<Notification> {
|
||||||
|
return Request(method: .get, path: "/api/v1/notifications/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
||||||
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
||||||
"types" => allowedTypes.map { $0.rawValue }
|
"types" => allowedTypes.map { $0.rawValue }
|
||||||
|
|
|
@ -46,6 +46,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
public let localOnly: Bool?
|
public let localOnly: Bool?
|
||||||
public let editedAt: Date?
|
public let editedAt: Date?
|
||||||
|
|
||||||
|
public let pleromaExtras: PleromaExtras?
|
||||||
|
|
||||||
public var applicationName: String? { application?.name }
|
public var applicationName: String? { application?.name }
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
|
@ -98,6 +100,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
||||||
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
||||||
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
|
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
|
||||||
|
|
||||||
|
self.pleromaExtras = try container.decodeIfPresent(PleromaExtras.self, forKey: .pleromaExtras)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
||||||
|
@ -212,7 +216,15 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
case poll
|
case poll
|
||||||
case localOnly = "local_only"
|
case localOnly = "local_only"
|
||||||
case editedAt = "edited_at"
|
case editedAt = "edited_at"
|
||||||
|
|
||||||
|
case pleromaExtras = "pleroma"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Status: Identifiable {}
|
extension Status: Identifiable {}
|
||||||
|
|
||||||
|
extension Status {
|
||||||
|
public struct PleromaExtras: Decodable, Sendable {
|
||||||
|
public let context: String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -100,7 +100,8 @@
|
||||||
D630C3DF2BC61C4900208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3DE2BC61C4900208903 /* PushNotifications */; };
|
D630C3DF2BC61C4900208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3DE2BC61C4900208903 /* PushNotifications */; };
|
||||||
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; };
|
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; };
|
||||||
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; };
|
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; };
|
||||||
D630C3E72BC6313F00208903 /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E62BC6313F00208903 /* WebURLFoundationExtras */; };
|
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4222BC7842C00208903 /* HTMLStreamer */; };
|
||||||
|
D630C4252BC7845800208903 /* WebURL in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4242BC7845800208903 /* WebURL */; };
|
||||||
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; };
|
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; };
|
||||||
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; };
|
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; };
|
||||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
|
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
|
||||||
|
@ -800,8 +801,9 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
D630C4252BC7845800208903 /* WebURL in Frameworks */,
|
||||||
|
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */,
|
||||||
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */,
|
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */,
|
||||||
D630C3E72BC6313F00208903 /* WebURLFoundationExtras in Frameworks */,
|
|
||||||
D630C3DF2BC61C4900208903 /* PushNotifications in Frameworks */,
|
D630C3DF2BC61C4900208903 /* PushNotifications in Frameworks */,
|
||||||
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */,
|
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */,
|
||||||
);
|
);
|
||||||
|
@ -1736,7 +1738,8 @@
|
||||||
D630C3DE2BC61C4900208903 /* PushNotifications */,
|
D630C3DE2BC61C4900208903 /* PushNotifications */,
|
||||||
D630C3E02BC61C6700208903 /* UserAccounts */,
|
D630C3E02BC61C6700208903 /* UserAccounts */,
|
||||||
D630C3E42BC6313400208903 /* Pachyderm */,
|
D630C3E42BC6313400208903 /* Pachyderm */,
|
||||||
D630C3E62BC6313F00208903 /* WebURLFoundationExtras */,
|
D630C4222BC7842C00208903 /* HTMLStreamer */,
|
||||||
|
D630C4242BC7845800208903 /* WebURL */,
|
||||||
);
|
);
|
||||||
productName = NotificationExtension;
|
productName = NotificationExtension;
|
||||||
productReference = D630C3D12BC61B6000208903 /* NotificationExtension.appex */;
|
productReference = D630C3D12BC61B6000208903 /* NotificationExtension.appex */;
|
||||||
|
@ -3250,10 +3253,15 @@
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Pachyderm;
|
productName = Pachyderm;
|
||||||
};
|
};
|
||||||
D630C3E62BC6313F00208903 /* WebURLFoundationExtras */ = {
|
D630C4222BC7842C00208903 /* HTMLStreamer */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */;
|
||||||
|
productName = HTMLStreamer;
|
||||||
|
};
|
||||||
|
D630C4242BC7845800208903 /* WebURL */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
|
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
|
||||||
productName = WebURLFoundationExtras;
|
productName = WebURL;
|
||||||
};
|
};
|
||||||
D635237029B78A7D009ED5E7 /* TuskerComponents */ = {
|
D635237029B78A7D009ED5E7 /* TuskerComponents */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
|
|
@ -36,6 +36,7 @@ class HTMLConverter {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension HTMLConverter {
|
extension HTMLConverter {
|
||||||
|
// note: this is duplicated in NotificationExtension
|
||||||
struct Callbacks: HTMLConversionCallbacks {
|
struct Callbacks: HTMLConversionCallbacks {
|
||||||
static func makeURL(string: String) -> URL? {
|
static func makeURL(string: String) -> URL? {
|
||||||
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
||||||
|
|
|
@ -56,11 +56,12 @@
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Post videos from the camera.</string>
|
<string>Post videos from the camera.</string>
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<string>Save photos directly from other people's posts.</string>
|
<string>Save photos directly from other people's posts.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Post photos from the photo library.</string>
|
<string>Post photos from the photo library.</string>
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSUserActivityTypes</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>INSendMessageIntent</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-conversation</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-conversation</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-timeline</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-timeline</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.check-notifications</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.check-notifications</string>
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>apns_enc</key>
|
||||||
|
<string>Encrypted Notification</string>
|
||||||
<key>poll votes count</key>
|
<key>poll votes count</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
<key>NSStringLocalizedFormatKey</key>
|
||||||
|
|
Loading…
Reference in New Issue