diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index c0241f67..89373fb4 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 9F2A5424296AB67A009B2D7C /* Env in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A5423296AB67A009B2D7C /* Env */; }; 9F2A5426296AB67E009B2D7C /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A5425296AB67E009B2D7C /* KeychainSwift */; }; 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 */; }; 9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F9295DA7D700DE16D0 /* AddAccountsView.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 = ""; }; 9F2A541A296AB631009B2D7C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9F2A5422296AB64B009B2D7C /* IceCubesNotifications.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesNotifications.entitlements; sourceTree = ""; }; + 9F2A5429296AF557009B2D7C /* NotificationServiceSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceSupport.swift; sourceTree = ""; }; 9F2B92F5295AE04800DE16D0 /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = ""; }; 9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountsView.swift; sourceTree = ""; }; 9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceInfoView.swift; sourceTree = ""; }; @@ -165,6 +167,7 @@ children = ( 9F2A5422296AB64B009B2D7C /* IceCubesNotifications.entitlements */, 9F2A5418296AB631009B2D7C /* NotificationService.swift */, + 9F2A5429296AF557009B2D7C /* NotificationServiceSupport.swift */, 9F2A541A296AB631009B2D7C /* Info.plist */, ); path = IceCubesNotifications; @@ -414,6 +417,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9F2A542A296AF557009B2D7C /* NotificationServiceSupport.swift in Sources */, 9F2A5419296AB631009B2D7C /* NotificationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/IceCubesApp/App/AppAccounts/AppAccountsManager.swift b/IceCubesApp/App/AppAccounts/AppAccountsManager.swift index 8c7cb2f0..556412ab 100644 --- a/IceCubesApp/App/AppAccounts/AppAccountsManager.swift +++ b/IceCubesApp/App/AppAccounts/AppAccountsManager.swift @@ -15,7 +15,7 @@ class AppAccountsManager: ObservableObject { @Published var availableAccounts: [AppAccount] @Published var currentClient: Client - var pushAccounts: [PushNotifications.PushAccounts] { + var pushAccounts: [PushNotificationsService.PushAccounts] { availableAccounts.filter{ $0.oauthToken != nil} .map{ .init(server: $0.server, token: $0.oauthToken!) } } diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift index 76bc6b43..1182aa03 100644 --- a/IceCubesApp/App/IceCubesApp.swift +++ b/IceCubesApp/App/IceCubesApp.swift @@ -49,7 +49,7 @@ struct IceCubesApp: App { .environmentObject(userPreferences) .environmentObject(theme) .environmentObject(watcher) - .environmentObject(PushNotifications.shared) + .environmentObject(PushNotificationsService.shared) .quickLookPreview($quickLook.url, in: quickLook.urls) } .onChange(of: scenePhase, perform: { scenePhase in @@ -154,7 +154,7 @@ struct IceCubesApp: App { } private func refreshPushSubs() { - PushNotifications.shared.requestPushNotifications() + PushNotificationsService.shared.requestPushNotifications() } } @@ -166,10 +166,10 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - PushNotifications.shared.pushToken = deviceToken + PushNotificationsService.shared.pushToken = deviceToken Task { - await PushNotifications.shared.fetchSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) - await PushNotifications.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) + await PushNotificationsService.shared.fetchSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) + await PushNotificationsService.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) } } diff --git a/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift b/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift index 79d1e541..09fe5f2a 100644 --- a/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift +++ b/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift @@ -13,7 +13,7 @@ struct AddAccountView: View { @EnvironmentObject private var appAccountsManager: AppAccountsManager @EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var currentInstance: CurrentInstance - @EnvironmentObject private var pushNotifications: PushNotifications + @EnvironmentObject private var pushNotifications: PushNotificationsService @EnvironmentObject private var theme: Theme @State private var instanceName: String = "" diff --git a/IceCubesApp/App/Tabs/Settings/PushNotificationsView.swift b/IceCubesApp/App/Tabs/Settings/PushNotificationsView.swift index c4cdcea6..d18c07f6 100644 --- a/IceCubesApp/App/Tabs/Settings/PushNotificationsView.swift +++ b/IceCubesApp/App/Tabs/Settings/PushNotificationsView.swift @@ -9,7 +9,7 @@ import Env struct PushNotificationsView: View { @EnvironmentObject private var theme: Theme @EnvironmentObject private var appAccountsManager: AppAccountsManager - @EnvironmentObject private var pushNotifications: PushNotifications + @EnvironmentObject private var pushNotifications: PushNotificationsService @State private var subscriptions: [PushSubscription] = [] @@ -19,25 +19,30 @@ struct PushNotificationsView: View { Toggle(isOn: $pushNotifications.isPushEnabled) { Text("Push notification") } + } footer: { + Text("Receive push notifications on new activities") } .listRowBackground(theme.primaryBackgroundColor) if pushNotifications.isPushEnabled { Section { + Toggle(isOn: $pushNotifications.isMentionNotificationEnabled) { + Label("Mentions", systemImage: "at") + } Toggle(isOn: $pushNotifications.isFollowNotificationEnabled) { - Text("Follow notification") + Label("Follows", systemImage: "person.badge.plus") } Toggle(isOn: $pushNotifications.isFavoriteNotificationEnabled) { - Text("Favorite notification") + Label("Favorites", systemImage: "star") } Toggle(isOn: $pushNotifications.isReblogNotificationEnabled) { - Text("Boost notification") - } - Toggle(isOn: $pushNotifications.isMentionNotificationEnabled) { - Text("Mention notification") + Label("Boosts", systemImage: "arrow.left.arrow.right.circle") } 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) @@ -77,6 +82,9 @@ struct PushNotificationsView: View { .onChange(of: pushNotifications.isFavoriteNotificationEnabled) { _ in updateSubscriptions() } + .onChange(of: pushNotifications.isNewPostsNotificationEnabled) { _ in + updateSubscriptions() + } } private func updateSubscriptions() { diff --git a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift index 759b2015..1e2920d1 100644 --- a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift +++ b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift @@ -7,6 +7,7 @@ import Models import DesignSystem struct SettingsTabs: View { + @EnvironmentObject private var pushNotifications: PushNotificationsService @EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var client: Client @EnvironmentObject private var currentInstance: CurrentInstance @@ -57,6 +58,11 @@ struct SettingsTabs: View { .onDelete { indexSet in if let index = indexSet.first { 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) } } diff --git a/IceCubesNotifications/NotificationService.swift b/IceCubesNotifications/NotificationService.swift index 0eeaca00..39ed2aac 100644 --- a/IceCubesNotifications/NotificationService.swift +++ b/IceCubesNotifications/NotificationService.swift @@ -14,11 +14,10 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - bestAttemptContent?.title = "A new notification have been received" if let bestAttemptContent { - let privateKey = PushNotifications.shared.notificationsPrivateKeyAsKey - let auth = PushNotifications.shared.notificationsAuthKeyAsKey + let privateKey = PushNotificationsService.shared.notificationsPrivateKeyAsKey + let auth = PushNotificationsService.shared.notificationsAuthKeyAsKey guard let encodedPayload = bestAttemptContent.userInfo["m"] as? String, 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.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16) - - let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation) - let nonce = HKDF.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: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: """, with: "\"") - .replacingOccurrences(of: "'", with: "'") - .replacingOccurrences(of: "'", 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 - } -} - diff --git a/IceCubesNotifications/NotificationServiceSupport.swift b/IceCubesNotifications/NotificationServiceSupport.swift new file mode 100644 index 00000000..63419d44 --- /dev/null +++ b/IceCubesNotifications/NotificationServiceSupport.swift @@ -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.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16) + + let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation) + let nonce = HKDF.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: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: """, with: "\"") + .replacingOccurrences(of: "'", with: "'") + .replacingOccurrences(of: "'", 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 + } +} + diff --git a/Packages/Env/Sources/Env/PushNotifications.swift b/Packages/Env/Sources/Env/PushNotificationsService.swift similarity index 95% rename from Packages/Env/Sources/Env/PushNotifications.swift rename to Packages/Env/Sources/Env/PushNotificationsService.swift index 34a2a67d..80b7842d 100644 --- a/Packages/Env/Sources/Env/PushNotifications.swift +++ b/Packages/Env/Sources/Env/PushNotificationsService.swift @@ -7,7 +7,7 @@ import Models import Network @MainActor -public class PushNotifications: ObservableObject { +public class PushNotificationsService: ObservableObject { enum Constants { static let endpoint = "https://icecubesrelay.fly.dev" 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? @@ -42,6 +42,7 @@ public class PushNotifications: ObservableObject { @Published public var isReblogNotificationEnabled: Bool = true @Published public var isMentionNotificationEnabled: Bool = true @Published public var isPollNotificationEnabled: Bool = true + @Published public var isNewPostsNotificationEnabled: Bool = true private var subscriptions: [PushSubscription] = [] @@ -89,7 +90,7 @@ public class PushNotifications: ObservableObject { p256dh: notificationsPrivateKeyAsKey.publicKey.x963Representation, auth: notificationsAuthKeyAsKey, mentions: isMentionNotificationEnabled, - status: true, + status: isNewPostsNotificationEnabled, reblog: isReblogNotificationEnabled, follow: isFollowNotificationEnabled, favourite: isFavoriteNotificationEnabled, @@ -119,6 +120,7 @@ public class PushNotifications: ObservableObject { isReblogNotificationEnabled = sub.alerts.reblog isMentionNotificationEnabled = sub.alerts.mention isPollNotificationEnabled = sub.alerts.poll + isNewPostsNotificationEnabled = sub.alerts.status } else { isPushEnabled = false } diff --git a/Packages/Models/Sources/Models/PushSubscription.swift b/Packages/Models/Sources/Models/PushSubscription.swift index 36a13a54..62df6668 100644 --- a/Packages/Models/Sources/Models/PushSubscription.swift +++ b/Packages/Models/Sources/Models/PushSubscription.swift @@ -7,6 +7,7 @@ public struct PushSubscription: Identifiable, Decodable { public let reblog: Bool public let mention: Bool public let poll: Bool + public let status: Bool } public let id: Int