Cleanup + Polish push notifications

This commit is contained in:
Thomas Ricouard 2023-01-08 14:16:43 +01:00
parent a68efaef56
commit ed8208857a
10 changed files with 127 additions and 103 deletions

View file

@ -19,6 +19,7 @@
9F2A5424296AB67A009B2D7C /* Env in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A5423296AB67A009B2D7C /* Env */; }; 9F2A5424296AB67A009B2D7C /* Env in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A5423296AB67A009B2D7C /* Env */; };
9F2A5426296AB67E009B2D7C /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A5425296AB67E009B2D7C /* KeychainSwift */; }; 9F2A5426296AB67E009B2D7C /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A5425296AB67E009B2D7C /* KeychainSwift */; };
9F2A5428296AB683009B2D7C /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A5427296AB683009B2D7C /* Models */; }; 9F2A5428296AB683009B2D7C /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A5427296AB683009B2D7C /* Models */; };
9F2A542A296AF557009B2D7C /* NotificationServiceSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2A5429296AF557009B2D7C /* NotificationServiceSupport.swift */; };
9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F5295AE04800DE16D0 /* Tabs.swift */; }; 9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F5295AE04800DE16D0 /* Tabs.swift */; };
9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */; }; 9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */; };
9F2B92FC295DA94500DE16D0 /* InstanceInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */; }; 9F2B92FC295DA94500DE16D0 /* InstanceInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */; };
@ -88,6 +89,7 @@
9F2A5418296AB631009B2D7C /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 9F2A5418296AB631009B2D7C /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
9F2A541A296AB631009B2D7C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 9F2A541A296AB631009B2D7C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9F2A5422296AB64B009B2D7C /* IceCubesNotifications.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesNotifications.entitlements; sourceTree = "<group>"; }; 9F2A5422296AB64B009B2D7C /* IceCubesNotifications.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesNotifications.entitlements; sourceTree = "<group>"; };
9F2A5429296AF557009B2D7C /* NotificationServiceSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceSupport.swift; sourceTree = "<group>"; };
9F2B92F5295AE04800DE16D0 /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = "<group>"; }; 9F2B92F5295AE04800DE16D0 /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = "<group>"; };
9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountsView.swift; sourceTree = "<group>"; }; 9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountsView.swift; sourceTree = "<group>"; };
9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceInfoView.swift; sourceTree = "<group>"; }; 9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceInfoView.swift; sourceTree = "<group>"; };
@ -165,6 +167,7 @@
children = ( children = (
9F2A5422296AB64B009B2D7C /* IceCubesNotifications.entitlements */, 9F2A5422296AB64B009B2D7C /* IceCubesNotifications.entitlements */,
9F2A5418296AB631009B2D7C /* NotificationService.swift */, 9F2A5418296AB631009B2D7C /* NotificationService.swift */,
9F2A5429296AF557009B2D7C /* NotificationServiceSupport.swift */,
9F2A541A296AB631009B2D7C /* Info.plist */, 9F2A541A296AB631009B2D7C /* Info.plist */,
); );
path = IceCubesNotifications; path = IceCubesNotifications;
@ -414,6 +417,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
9F2A542A296AF557009B2D7C /* NotificationServiceSupport.swift in Sources */,
9F2A5419296AB631009B2D7C /* NotificationService.swift in Sources */, 9F2A5419296AB631009B2D7C /* NotificationService.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View file

@ -15,7 +15,7 @@ class AppAccountsManager: ObservableObject {
@Published var availableAccounts: [AppAccount] @Published var availableAccounts: [AppAccount]
@Published var currentClient: Client @Published var currentClient: Client
var pushAccounts: [PushNotifications.PushAccounts] { var pushAccounts: [PushNotificationsService.PushAccounts] {
availableAccounts.filter{ $0.oauthToken != nil} availableAccounts.filter{ $0.oauthToken != nil}
.map{ .init(server: $0.server, token: $0.oauthToken!) } .map{ .init(server: $0.server, token: $0.oauthToken!) }
} }

View file

@ -49,7 +49,7 @@ struct IceCubesApp: App {
.environmentObject(userPreferences) .environmentObject(userPreferences)
.environmentObject(theme) .environmentObject(theme)
.environmentObject(watcher) .environmentObject(watcher)
.environmentObject(PushNotifications.shared) .environmentObject(PushNotificationsService.shared)
.quickLookPreview($quickLook.url, in: quickLook.urls) .quickLookPreview($quickLook.url, in: quickLook.urls)
} }
.onChange(of: scenePhase, perform: { scenePhase in .onChange(of: scenePhase, perform: { scenePhase in
@ -154,7 +154,7 @@ struct IceCubesApp: App {
} }
private func refreshPushSubs() { private func refreshPushSubs() {
PushNotifications.shared.requestPushNotifications() PushNotificationsService.shared.requestPushNotifications()
} }
} }
@ -166,10 +166,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
PushNotifications.shared.pushToken = deviceToken PushNotificationsService.shared.pushToken = deviceToken
Task { Task {
await PushNotifications.shared.fetchSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) await PushNotificationsService.shared.fetchSubscriptions(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotifications.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) await PushNotificationsService.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts)
} }
} }

View file

@ -13,7 +13,7 @@ struct AddAccountView: View {
@EnvironmentObject private var appAccountsManager: AppAccountsManager @EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var currentInstance: CurrentInstance @EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var pushNotifications: PushNotifications @EnvironmentObject private var pushNotifications: PushNotificationsService
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@State private var instanceName: String = "" @State private var instanceName: String = ""

View file

@ -9,7 +9,7 @@ import Env
struct PushNotificationsView: View { struct PushNotificationsView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var appAccountsManager: AppAccountsManager @EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var pushNotifications: PushNotifications @EnvironmentObject private var pushNotifications: PushNotificationsService
@State private var subscriptions: [PushSubscription] = [] @State private var subscriptions: [PushSubscription] = []
@ -19,25 +19,30 @@ struct PushNotificationsView: View {
Toggle(isOn: $pushNotifications.isPushEnabled) { Toggle(isOn: $pushNotifications.isPushEnabled) {
Text("Push notification") Text("Push notification")
} }
} footer: {
Text("Receive push notifications on new activities")
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
if pushNotifications.isPushEnabled { if pushNotifications.isPushEnabled {
Section { Section {
Toggle(isOn: $pushNotifications.isMentionNotificationEnabled) {
Label("Mentions", systemImage: "at")
}
Toggle(isOn: $pushNotifications.isFollowNotificationEnabled) { Toggle(isOn: $pushNotifications.isFollowNotificationEnabled) {
Text("Follow notification") Label("Follows", systemImage: "person.badge.plus")
} }
Toggle(isOn: $pushNotifications.isFavoriteNotificationEnabled) { Toggle(isOn: $pushNotifications.isFavoriteNotificationEnabled) {
Text("Favorite notification") Label("Favorites", systemImage: "star")
} }
Toggle(isOn: $pushNotifications.isReblogNotificationEnabled) { Toggle(isOn: $pushNotifications.isReblogNotificationEnabled) {
Text("Boost notification") Label("Boosts", systemImage: "arrow.left.arrow.right.circle")
}
Toggle(isOn: $pushNotifications.isMentionNotificationEnabled) {
Text("Mention notification")
} }
Toggle(isOn: $pushNotifications.isPollNotificationEnabled) { Toggle(isOn: $pushNotifications.isPollNotificationEnabled) {
Text("Polls notification") Label("Polls Results", systemImage: "chart.bar")
}
Toggle(isOn: $pushNotifications.isNewPostsNotificationEnabled) {
Label("New Posts", systemImage: "bubble.right")
} }
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
@ -77,6 +82,9 @@ struct PushNotificationsView: View {
.onChange(of: pushNotifications.isFavoriteNotificationEnabled) { _ in .onChange(of: pushNotifications.isFavoriteNotificationEnabled) { _ in
updateSubscriptions() updateSubscriptions()
} }
.onChange(of: pushNotifications.isNewPostsNotificationEnabled) { _ in
updateSubscriptions()
}
} }
private func updateSubscriptions() { private func updateSubscriptions() {

View file

@ -7,6 +7,7 @@ import Models
import DesignSystem import DesignSystem
struct SettingsTabs: View { struct SettingsTabs: View {
@EnvironmentObject private var pushNotifications: PushNotificationsService
@EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@EnvironmentObject private var currentInstance: CurrentInstance @EnvironmentObject private var currentInstance: CurrentInstance
@ -57,6 +58,11 @@ struct SettingsTabs: View {
.onDelete { indexSet in .onDelete { indexSet in
if let index = indexSet.first { if let index = indexSet.first {
let account = appAccountsManager.availableAccounts[index] let account = appAccountsManager.availableAccounts[index]
if let token = account.oauthToken {
Task {
await pushNotifications.deleteSubscriptions(accounts: [.init(server: account.server, token: token)])
}
}
appAccountsManager.delete(account: account) appAccountsManager.delete(account: account)
} }
} }

View file

@ -14,11 +14,10 @@ class NotificationService: UNNotificationServiceExtension {
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
bestAttemptContent?.title = "A new notification have been received"
if let bestAttemptContent { if let bestAttemptContent {
let privateKey = PushNotifications.shared.notificationsPrivateKeyAsKey let privateKey = PushNotificationsService.shared.notificationsPrivateKeyAsKey
let auth = PushNotifications.shared.notificationsAuthKeyAsKey let auth = PushNotificationsService.shared.notificationsAuthKeyAsKey
guard let encodedPayload = bestAttemptContent.userInfo["m"] as? String, guard let encodedPayload = bestAttemptContent.userInfo["m"] as? String,
let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64()) else { let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64()) else {
@ -80,86 +79,4 @@ class NotificationService: UNNotificationServiceExtension {
} }
} }
} }
static func decrypt(payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data? {
guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else {
return nil
}
let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8), outputByteCount: 32)
let keyInfo = info(type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
let key = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16)
let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
let nonceData = nonce.withUnsafeBytes(Array.init)
guard let sealedBox = try? AES.GCM.SealedBox(combined: nonceData + payload) else {
return nil
}
var _plaintext: Data?
do {
_plaintext = try AES.GCM.open(sealedBox, using: key)
} catch {
print(error)
}
guard let plaintext = _plaintext else {
return nil
}
let paddingLength = Int(plaintext[0]) * 256 + Int(plaintext[1])
guard plaintext.count >= 2 + paddingLength else {
print("1")
fatalError()
}
let unpadded = plaintext.suffix(from: paddingLength + 2)
return Data(unpadded)
}
static private func info(type: String, clientPublicKey: Data, serverPublicKey: Data) -> Data {
var info = Data()
info.append("Content-Encoding: ".data(using: .utf8)!)
info.append(type.data(using: .utf8)!)
info.append(0)
info.append("P-256".data(using: .utf8)!)
info.append(0)
info.append(0)
info.append(65)
info.append(clientPublicKey)
info.append(0)
info.append(65)
info.append(serverPublicKey)
return info
}
} }
extension String {
func escape() -> String {
return self
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&apos;", with: "'")
.replacingOccurrences(of: "&#39;", with: "")
}
func URLSafeBase64ToBase64() -> String {
var base64 = replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let countMod4 = count % 4
if countMod4 != 0 {
base64.append(String(repeating: "=", count: 4 - countMod4))
}
return base64
}
}

View file

@ -0,0 +1,86 @@
import Foundation
import CryptoKit
extension NotificationService {
static func decrypt(payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data? {
guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else {
return nil
}
let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8), outputByteCount: 32)
let keyInfo = info(type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
let key = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16)
let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
let nonceData = nonce.withUnsafeBytes(Array.init)
guard let sealedBox = try? AES.GCM.SealedBox(combined: nonceData + payload) else {
return nil
}
var _plaintext: Data?
do {
_plaintext = try AES.GCM.open(sealedBox, using: key)
} catch {
print(error)
}
guard let plaintext = _plaintext else {
return nil
}
let paddingLength = Int(plaintext[0]) * 256 + Int(plaintext[1])
guard plaintext.count >= 2 + paddingLength else {
print("1")
fatalError()
}
let unpadded = plaintext.suffix(from: paddingLength + 2)
return Data(unpadded)
}
static private func info(type: String, clientPublicKey: Data, serverPublicKey: Data) -> Data {
var info = Data()
info.append("Content-Encoding: ".data(using: .utf8)!)
info.append(type.data(using: .utf8)!)
info.append(0)
info.append("P-256".data(using: .utf8)!)
info.append(0)
info.append(0)
info.append(65)
info.append(clientPublicKey)
info.append(0)
info.append(65)
info.append(serverPublicKey)
return info
}
}
extension String {
func escape() -> String {
return self
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&apos;", with: "'")
.replacingOccurrences(of: "&#39;", with: "")
}
func URLSafeBase64ToBase64() -> String {
var base64 = replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let countMod4 = count % 4
if countMod4 != 0 {
base64.append(String(repeating: "=", count: 4 - countMod4))
}
return base64
}
}

View file

@ -7,7 +7,7 @@ import Models
import Network import Network
@MainActor @MainActor
public class PushNotifications: ObservableObject { public class PushNotificationsService: ObservableObject {
enum Constants { enum Constants {
static let endpoint = "https://icecubesrelay.fly.dev" static let endpoint = "https://icecubesrelay.fly.dev"
static let keychainGroup = "346J38YKE3.com.thomasricouard.IceCubesApp" static let keychainGroup = "346J38YKE3.com.thomasricouard.IceCubesApp"
@ -25,7 +25,7 @@ public class PushNotifications: ObservableObject {
} }
} }
public static let shared = PushNotifications() public static let shared = PushNotificationsService()
@Published public var pushToken: Data? @Published public var pushToken: Data?
@ -42,6 +42,7 @@ public class PushNotifications: ObservableObject {
@Published public var isReblogNotificationEnabled: Bool = true @Published public var isReblogNotificationEnabled: Bool = true
@Published public var isMentionNotificationEnabled: Bool = true @Published public var isMentionNotificationEnabled: Bool = true
@Published public var isPollNotificationEnabled: Bool = true @Published public var isPollNotificationEnabled: Bool = true
@Published public var isNewPostsNotificationEnabled: Bool = true
private var subscriptions: [PushSubscription] = [] private var subscriptions: [PushSubscription] = []
@ -89,7 +90,7 @@ public class PushNotifications: ObservableObject {
p256dh: notificationsPrivateKeyAsKey.publicKey.x963Representation, p256dh: notificationsPrivateKeyAsKey.publicKey.x963Representation,
auth: notificationsAuthKeyAsKey, auth: notificationsAuthKeyAsKey,
mentions: isMentionNotificationEnabled, mentions: isMentionNotificationEnabled,
status: true, status: isNewPostsNotificationEnabled,
reblog: isReblogNotificationEnabled, reblog: isReblogNotificationEnabled,
follow: isFollowNotificationEnabled, follow: isFollowNotificationEnabled,
favourite: isFavoriteNotificationEnabled, favourite: isFavoriteNotificationEnabled,
@ -119,6 +120,7 @@ public class PushNotifications: ObservableObject {
isReblogNotificationEnabled = sub.alerts.reblog isReblogNotificationEnabled = sub.alerts.reblog
isMentionNotificationEnabled = sub.alerts.mention isMentionNotificationEnabled = sub.alerts.mention
isPollNotificationEnabled = sub.alerts.poll isPollNotificationEnabled = sub.alerts.poll
isNewPostsNotificationEnabled = sub.alerts.status
} else { } else {
isPushEnabled = false isPushEnabled = false
} }

View file

@ -7,6 +7,7 @@ public struct PushSubscription: Identifiable, Decodable {
public let reblog: Bool public let reblog: Bool
public let mention: Bool public let mention: Bool
public let poll: Bool public let poll: Bool
public let status: Bool
} }
public let id: Int public let id: Int