diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index df8d0a28..c0241f67 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -13,6 +13,12 @@ 9F2A540A29699705009B2D7C /* ReceiptParser in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A540929699705009B2D7C /* ReceiptParser */; }; 9F2A540C29699705009B2D7C /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A540B29699705009B2D7C /* RevenueCat */; }; 9F2A540E2969A0B0009B2D7C /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F2A540D2969A0B0009B2D7C /* StoreKit.framework */; }; + 9F2A5411296A1429009B2D7C /* PushNotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2A5410296A1429009B2D7C /* PushNotificationsView.swift */; }; + 9F2A5419296AB631009B2D7C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2A5418296AB631009B2D7C /* NotificationService.swift */; }; + 9F2A541D296AB631009B2D7C /* IceCubesNotifications.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9F2A5416296AB631009B2D7C /* IceCubesNotifications.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 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 */; }; 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 */; }; @@ -46,6 +52,30 @@ 9FE151A6293C90F900E9683D /* IconSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE151A5293C90F900E9683D /* IconSelectorView.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 9F2A541B296AB631009B2D7C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9FBFE631292A715500C250E9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9F2A5415296AB631009B2D7C; + remoteInfo = IceCubesNotifications; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9F2A5421296AB631009B2D7C /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 9F2A541D296AB631009B2D7C /* IceCubesNotifications.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 9F24EEB729360C330042359D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 9F29553D292B67B600E0E81B /* Network */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Network; path = Packages/Network; sourceTree = ""; }; @@ -53,6 +83,11 @@ 9F2A5404296995FB009B2D7C /* QuickLookUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLookUI.framework; path = System/Library/Frameworks/QuickLookUI.framework; sourceTree = SDKROOT; }; 9F2A540629699698009B2D7C /* SupportAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportAppView.swift; sourceTree = ""; }; 9F2A540D2969A0B0009B2D7C /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; + 9F2A5410296A1429009B2D7C /* PushNotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationsView.swift; sourceTree = ""; }; + 9F2A5416296AB631009B2D7C /* IceCubesNotifications.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = IceCubesNotifications.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; 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 = ""; }; @@ -89,6 +124,16 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 9F2A5413296AB631009B2D7C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9F2A5428296AB683009B2D7C /* Models in Frameworks */, + 9F2A5426296AB67E009B2D7C /* KeychainSwift in Frameworks */, + 9F2A5424296AB67A009B2D7C /* Env in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9FBFE636292A715500C250E9 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -115,6 +160,16 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9F2A5417296AB631009B2D7C /* IceCubesNotifications */ = { + isa = PBXGroup; + children = ( + 9F2A5422296AB64B009B2D7C /* IceCubesNotifications.entitlements */, + 9F2A5418296AB631009B2D7C /* NotificationService.swift */, + 9F2A541A296AB631009B2D7C /* Info.plist */, + ); + path = IceCubesNotifications; + sourceTree = ""; + }; 9F398AB429360A5800A889F2 /* App */ = { isa = PBXGroup; children = ( @@ -172,6 +227,7 @@ isa = PBXGroup; children = ( 9FBFE63B292A715500C250E9 /* IceCubesApp */, + 9F2A5417296AB631009B2D7C /* IceCubesNotifications */, 9FBFE63A292A715500C250E9 /* Products */, 9FBFE64C292A72BD00C250E9 /* Frameworks */, 9F398AAC2936005300A889F2 /* Account */, @@ -192,6 +248,7 @@ isa = PBXGroup; children = ( 9FBFE639292A715500C250E9 /* IceCubesApp.app */, + 9F2A5416296AB631009B2D7C /* IceCubesNotifications.appex */, ); name = Products; sourceTree = ""; @@ -228,6 +285,7 @@ 9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */, 9F7335F82968576500AFF0BA /* DisplaySettingsView.swift */, 9F2A540629699698009B2D7C /* SupportAppView.swift */, + 9F2A5410296A1429009B2D7C /* PushNotificationsView.swift */, ); path = Settings; sourceTree = ""; @@ -235,6 +293,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 9F2A5415296AB631009B2D7C /* IceCubesNotifications */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9F2A541E296AB631009B2D7C /* Build configuration list for PBXNativeTarget "IceCubesNotifications" */; + buildPhases = ( + 9F2A5412296AB631009B2D7C /* Sources */, + 9F2A5413296AB631009B2D7C /* Frameworks */, + 9F2A5414296AB631009B2D7C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = IceCubesNotifications; + packageProductDependencies = ( + 9F2A5423296AB67A009B2D7C /* Env */, + 9F2A5425296AB67E009B2D7C /* KeychainSwift */, + 9F2A5427296AB683009B2D7C /* Models */, + ); + productName = IceCubesNotifications; + productReference = 9F2A5416296AB631009B2D7C /* IceCubesNotifications.appex */; + productType = "com.apple.product-type.app-extension"; + }; 9FBFE638292A715500C250E9 /* IceCubesApp */ = { isa = PBXNativeTarget; buildConfigurationList = 9FBFE648292A715600C250E9 /* Build configuration list for PBXNativeTarget "IceCubesApp" */; @@ -242,10 +322,12 @@ 9FBFE635292A715500C250E9 /* Sources */, 9FBFE636292A715500C250E9 /* Frameworks */, 9FBFE637292A715500C250E9 /* Resources */, + 9F2A5421296AB631009B2D7C /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 9F2A541C296AB631009B2D7C /* PBXTargetDependency */, ); name = IceCubesApp; packageProductDependencies = ( @@ -274,9 +356,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1410; + LastSwiftUpdateCheck = 1420; LastUpgradeCheck = 1420; TargetAttributes = { + 9F2A5415296AB631009B2D7C = { + CreatedOnToolsVersion = 14.2; + }; 9FBFE638292A715500C250E9 = { CreatedOnToolsVersion = 14.1; }; @@ -300,11 +385,19 @@ projectRoot = ""; targets = ( 9FBFE638292A715500C250E9 /* IceCubesApp */, + 9F2A5415296AB631009B2D7C /* IceCubesNotifications */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 9F2A5414296AB631009B2D7C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9FBFE637292A715500C250E9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -317,6 +410,14 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 9F2A5412296AB631009B2D7C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9F2A5419296AB631009B2D7C /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9FBFE635292A715500C250E9 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -340,12 +441,82 @@ 9F7335F72968274500AFF0BA /* AppAccountsSelectorView.swift in Sources */, 9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */, 9F55C68D2955968700F94077 /* ExploreTab.swift in Sources */, + 9F2A5411296A1429009B2D7C /* PushNotificationsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 9F2A541C296AB631009B2D7C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9F2A5415296AB631009B2D7C /* IceCubesNotifications */; + targetProxy = 9F2A541B296AB631009B2D7C /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 9F2A541F296AB631009B2D7C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = IceCubesNotifications/IceCubesNotifications.entitlements; + CODE_SIGN_IDENTITY = "-"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 730; + DEVELOPMENT_TEAM = Z6P74P6T99; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = IceCubesNotifications/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = IceCubesNotifications; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.7; + PRODUCT_BUNDLE_IDENTIFIER = com.thomasricouard.IceCubesApp.IceCubesNotifications; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9F2A5420296AB631009B2D7C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = IceCubesNotifications/IceCubesNotifications.entitlements; + CODE_SIGN_IDENTITY = "-"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 730; + DEVELOPMENT_TEAM = Z6P74P6T99; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = IceCubesNotifications/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = IceCubesNotifications; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.7; + PRODUCT_BUNDLE_IDENTIFIER = com.thomasricouard.IceCubesApp.IceCubesNotifications; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 9FBFE646292A715600C250E9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -460,6 +631,7 @@ 9FBFE649292A715600C250E9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIconAlternate1 AppIconAlternate2 AppIconAlternate3 AppIconAlternate4 AppIconAlternate5 AppIconAlternate6 AppIconAlternate7 AppIconAlternate8 AppIconAlternate9 AppIconAlternate10 AppIconAlternate11 AppIconAlternate12 AppIconAlternate13 AppIconAlternate14 AppIconAlternate15 AppIconAlternate16 AppIconAlternate17 AppIconAlternate19 AppIconAlternate18"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -509,6 +681,7 @@ 9FBFE64A292A715600C250E9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIconAlternate1 AppIconAlternate2 AppIconAlternate3 AppIconAlternate4 AppIconAlternate5 AppIconAlternate6 AppIconAlternate7 AppIconAlternate8 AppIconAlternate9 AppIconAlternate10 AppIconAlternate11 AppIconAlternate12 AppIconAlternate13 AppIconAlternate14 AppIconAlternate15 AppIconAlternate16 AppIconAlternate17 AppIconAlternate19 AppIconAlternate18"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -558,6 +731,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 9F2A541E296AB631009B2D7C /* Build configuration list for PBXNativeTarget "IceCubesNotifications" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9F2A541F296AB631009B2D7C /* Debug */, + 9F2A5420296AB631009B2D7C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 9FBFE634292A715500C250E9 /* Build configuration list for PBXProject "IceCubesApp" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -612,6 +794,19 @@ package = 9F2A540829699705009B2D7C /* XCRemoteSwiftPackageReference "purchases-ios" */; productName = RevenueCat; }; + 9F2A5423296AB67A009B2D7C /* Env */ = { + isa = XCSwiftPackageProductDependency; + productName = Env; + }; + 9F2A5425296AB67E009B2D7C /* KeychainSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 9FAE4ACC29379A5A00772766 /* XCRemoteSwiftPackageReference "keychain-swift" */; + productName = KeychainSwift; + }; + 9F2A5427296AB683009B2D7C /* Models */ = { + isa = XCSwiftPackageProductDependency; + productName = Models; + }; 9F35DB43294F9A7D00B3281A /* Status */ = { isa = XCSwiftPackageProductDependency; productName = Status; diff --git a/IceCubesApp/App/AppAccounts/AppAccount.swift b/IceCubesApp/App/AppAccounts/AppAccount.swift index 58b8eb97..159477fb 100644 --- a/IceCubesApp/App/AppAccounts/AppAccount.swift +++ b/IceCubesApp/App/AppAccounts/AppAccount.swift @@ -3,6 +3,7 @@ import Timeline import Network import KeychainSwift import Models +import CryptoKit struct AppAccount: Codable, Identifiable { let server: String @@ -20,6 +21,11 @@ struct AppAccount: Codable, Identifiable { } } + internal init(server: String, oauthToken: OauthToken? = nil) { + self.server = server + self.oauthToken = oauthToken + } + func save() throws { let encoder = JSONEncoder() let data = try encoder.encode(self) @@ -31,15 +37,16 @@ struct AppAccount: Codable, Identifiable { KeychainSwift().delete(key) } - static func retrieveAll() throws -> [AppAccount] { + static func retrieveAll() -> [AppAccount] { let keychain = KeychainSwift() let decoder = JSONDecoder() let keys = keychain.allKeys var accounts: [AppAccount] = [] for key in keys { if let data = keychain.getData(key) { - let account = try decoder.decode(AppAccount.self, from: data) - accounts.append(account) + if let account = try? decoder.decode(AppAccount.self, from: data) { + accounts.append(account) + } } } return accounts diff --git a/IceCubesApp/App/AppAccounts/AppAccountsManager.swift b/IceCubesApp/App/AppAccounts/AppAccountsManager.swift index 4b96746d..8c7cb2f0 100644 --- a/IceCubesApp/App/AppAccounts/AppAccountsManager.swift +++ b/IceCubesApp/App/AppAccounts/AppAccountsManager.swift @@ -1,5 +1,6 @@ import SwiftUI import Network +import Env class AppAccountsManager: ObservableObject { @AppStorage("latestCurrentAccountKey") static public var latestCurrentAccountKey: String = "" @@ -14,18 +15,21 @@ class AppAccountsManager: ObservableObject { @Published var availableAccounts: [AppAccount] @Published var currentClient: Client - init() { + var pushAccounts: [PushNotifications.PushAccounts] { + availableAccounts.filter{ $0.oauthToken != nil} + .map{ .init(server: $0.server, token: $0.oauthToken!) } + } + + static var shared = AppAccountsManager() + + private init() { var defaultAccount = AppAccount(server: IceCubesApp.defaultServer, oauthToken: nil) - do { - let keychainAccounts = try AppAccount.retrieveAll() - availableAccounts = keychainAccounts - if let currentAccount = keychainAccounts.first(where: { $0.id == Self.latestCurrentAccountKey }) { - defaultAccount = currentAccount - } else { - defaultAccount = keychainAccounts.last ?? defaultAccount - } - } catch { - availableAccounts = [defaultAccount] + let keychainAccounts = AppAccount.retrieveAll() + availableAccounts = keychainAccounts + if let currentAccount = keychainAccounts.first(where: { $0.id == Self.latestCurrentAccountKey }) { + defaultAccount = currentAccount + } else { + defaultAccount = keychainAccounts.last ?? defaultAccount } currentAccount = defaultAccount currentClient = .init(server: defaultAccount.server, oauthToken: defaultAccount.oauthToken) diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift index ab0874b0..9ba955e5 100644 --- a/IceCubesApp/App/IceCubesApp.swift +++ b/IceCubesApp/App/IceCubesApp.swift @@ -11,8 +11,10 @@ import RevenueCat struct IceCubesApp: App { public static let defaultServer = "mastodon.social" + @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + @Environment(\.scenePhase) private var scenePhase - @StateObject private var appAccountsManager = AppAccountsManager() + @StateObject private var appAccountsManager = AppAccountsManager.shared @StateObject private var currentInstance = CurrentInstance() @StateObject private var currentAccount = CurrentAccount() @StateObject private var userPreferences = UserPreferences() @@ -36,6 +38,7 @@ struct IceCubesApp: App { setNewClientsInEnv(client: appAccountsManager.currentClient) setBarsColor(color: theme.primaryBackgroundColor) setupRevenueCat() + refreshPushSubs() } .preferredColorScheme(theme.selectedScheme == ColorScheme.dark ? .dark : .light) .environmentObject(appAccountsManager) @@ -46,6 +49,7 @@ struct IceCubesApp: App { .environmentObject(userPreferences) .environmentObject(theme) .environmentObject(watcher) + .environmentObject(PushNotifications.shared) .quickLookPreview($quickLook.url, in: quickLook.urls) } .onChange(of: scenePhase, perform: { scenePhase in @@ -148,4 +152,30 @@ struct IceCubesApp: App { Purchases.logLevel = .error Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi") } + + private func refreshPushSubs() { + PushNotifications.shared.requestPushNotifications() + Task { + await PushNotifications.shared.fetchSubscriptions(accounts: appAccountsManager.pushAccounts) + } + } +} + +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + return true + } + + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + PushNotifications.shared.pushToken = deviceToken + Task { + await PushNotifications.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) + } + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + print(error) + } } diff --git a/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift b/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift index 7ea8b5f4..79d1e541 100644 --- a/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift +++ b/IceCubesApp/App/Tabs/Settings/AddAccountsView.swift @@ -13,6 +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 theme: Theme @State private var instanceName: String = "" @@ -144,6 +145,9 @@ struct AddAccountView: View { do { let oauthToken = try await client.continueOauthFlow(url: url) appAccountsManager.add(account: AppAccount(server: client.server, oauthToken: oauthToken)) + Task { + await pushNotifications.updateSubscriptions(accounts: appAccountsManager.pushAccounts) + } isSigninIn = false dismiss() } catch { diff --git a/IceCubesApp/App/Tabs/Settings/PushNotificationsView.swift b/IceCubesApp/App/Tabs/Settings/PushNotificationsView.swift new file mode 100644 index 00000000..ea8f95fd --- /dev/null +++ b/IceCubesApp/App/Tabs/Settings/PushNotificationsView.swift @@ -0,0 +1,82 @@ +import SwiftUI +import Models +import DesignSystem +import NukeUI +import Network +import UserNotifications +import Env + +struct PushNotificationsView: View { + @EnvironmentObject private var theme: Theme + @EnvironmentObject private var appAccountsManager: AppAccountsManager + @EnvironmentObject private var pushNotifications: PushNotifications + + @State private var subscriptions: [PushSubscription] = [] + + var body: some View { + Form { + Section { + Toggle(isOn: $pushNotifications.isPushEnabled) { + Text("Push notification") + } + Group { + Toggle(isOn: $pushNotifications.isFollowNotificationEnabled) { + Text("Follow notification") + } + Toggle(isOn: $pushNotifications.isFavoriteNotificationEnabled) { + Text("Favorite notification") + } + Toggle(isOn: $pushNotifications.isReblogNotificationEnabled) { + Text("Boost notification") + } + Toggle(isOn: $pushNotifications.isMentionNotificationEnabled) { + Text("Mention notification") + } + Toggle(isOn: $pushNotifications.isPollNotificationEnabled) { + Text("Polls notification") + } + }.disabled(!pushNotifications.isPushEnabled) + } + .listRowBackground(theme.primaryBackgroundColor) + } + .navigationTitle("Push Notifications") + .scrollContentBackground(.hidden) + .background(theme.secondaryBackgroundColor) + .onAppear { + Task { + await pushNotifications.fetchSubscriptions(accounts: appAccountsManager.pushAccounts) + } + } + .onChange(of: pushNotifications.isPushEnabled) { newValue in + pushNotifications.isUserPushEnabled = newValue + if !newValue { + Task { + await pushNotifications.deleteSubscriptions(accounts: appAccountsManager.pushAccounts) + } + } else { + updateSubscriptions() + } + } + .onChange(of: pushNotifications.isFollowNotificationEnabled) { _ in + updateSubscriptions() + } + .onChange(of: pushNotifications.isPollNotificationEnabled) { _ in + updateSubscriptions() + } + .onChange(of: pushNotifications.isReblogNotificationEnabled) { _ in + updateSubscriptions() + } + .onChange(of: pushNotifications.isMentionNotificationEnabled) { _ in + updateSubscriptions() + } + .onChange(of: pushNotifications.isFavoriteNotificationEnabled) { _ in + updateSubscriptions() + } + } + + private func updateSubscriptions() { + Task { + await pushNotifications.updateSubscriptions(accounts: appAccountsManager.pushAccounts) + } + } +} diff --git a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift index 0ba5ce63..759b2015 100644 --- a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift +++ b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift @@ -68,6 +68,9 @@ struct SettingsTabs: View { @ViewBuilder private var generalSection: some View { Section("General") { + NavigationLink(destination: PushNotificationsView()) { + Label("Push notifications", systemImage: "bell.and.waves.left.and.right") + } if let instanceData = currentInstance.instance { NavigationLink(destination: InstanceInfoView(instance: instanceData)) { Label("Instance Information", systemImage: "server.rack") diff --git a/IceCubesApp/IceCubesApp.entitlements b/IceCubesApp/IceCubesApp.entitlements index 1703fcdc..a18821af 100644 --- a/IceCubesApp/IceCubesApp.entitlements +++ b/IceCubesApp/IceCubesApp.entitlements @@ -2,6 +2,8 @@ + aps-environment + development com.apple.security.app-sandbox com.apple.security.files.user-selected.read-only diff --git a/IceCubesNotifications/IceCubesNotifications.entitlements b/IceCubesNotifications/IceCubesNotifications.entitlements new file mode 100644 index 00000000..e04b3fac --- /dev/null +++ b/IceCubesNotifications/IceCubesNotifications.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.thomasricouard.IceCubesApp + + + diff --git a/IceCubesNotifications/Info.plist b/IceCubesNotifications/Info.plist new file mode 100644 index 00000000..57421ebf --- /dev/null +++ b/IceCubesNotifications/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/IceCubesNotifications/NotificationService.swift b/IceCubesNotifications/NotificationService.swift new file mode 100644 index 00000000..24ab73e0 --- /dev/null +++ b/IceCubesNotifications/NotificationService.swift @@ -0,0 +1,141 @@ +import UserNotifications +import KeychainSwift +import Env +import CryptoKit +import Models + +@MainActor +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + 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 + + guard let encodedPayload = bestAttemptContent.userInfo["m"] as? String, + let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64()) else { + contentHandler(bestAttemptContent) + return + } + + guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String, + let publicKeyData = Data(base64Encoded: encodedPublicKey.URLSafeBase64ToBase64()), + let publicKey = try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData) else { + contentHandler(bestAttemptContent) + return + } + + guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String, + let salt = Data(base64Encoded: encodedSalt.URLSafeBase64ToBase64()) else { + contentHandler(bestAttemptContent) + return + } + + guard let plaintextData = NotificationService.decrypt(payload: payload, + salt: salt, + auth: auth, + privateKey: privateKey, + publicKey: publicKey), + let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData) else { + contentHandler(bestAttemptContent) + return + } + + bestAttemptContent.title = notification.title + bestAttemptContent.subtitle = "" + bestAttemptContent.body = notification.body.escape() + bestAttemptContent.userInfo["plaintext"] = plaintextData + + contentHandler(bestAttemptContent) + } + } + + 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/PushNotifications.swift new file mode 100644 index 00000000..1d168658 --- /dev/null +++ b/Packages/Env/Sources/Env/PushNotifications.swift @@ -0,0 +1,170 @@ +import Foundation +import UserNotifications +import SwiftUI +import KeychainSwift +import CryptoKit +import Models +import Network + +@MainActor +public class PushNotifications: ObservableObject { + enum Constants { + static let endpoint = "https://icecubesrelay.fly.dev" + static let keychainGroup = "Z6P74P6T99.com.thomasricouard.IceCubesApp" + static let keychainAuthKey = "notifications_auth_key" + static let keychainPrivateKey = "notifications_private_key" + } + + public struct PushAccounts { + public let server: String + public let token: OauthToken + + public init(server: String, token: OauthToken) { + self.server = server + self.token = token + } + } + + public static let shared = PushNotifications() + + @Published public var pushToken: Data? + + @AppStorage("user_push_is_on") public var isUserPushEnabled: Bool = true + @Published public var isPushEnabled: Bool = false { + didSet { + if !oldValue && isPushEnabled { + requestPushNotifications() + } + } + } + @Published public var isFollowNotificationEnabled: Bool = true + @Published public var isFavoriteNotificationEnabled: Bool = true + @Published public var isReblogNotificationEnabled: Bool = true + @Published public var isMentionNotificationEnabled: Bool = true + @Published public var isPollNotificationEnabled: Bool = true + + private var subscriptions: [PushSubscription] = [] + + private var keychain: KeychainSwift { + let keychain = KeychainSwift() + keychain.accessGroup = Constants.keychainGroup + return keychain + } + + public func requestPushNotifications() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { (_, _) in + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + + public func fetchSubscriptions(accounts: [PushAccounts]) async { + subscriptions = [] + for account in accounts { + let client = Client(server: account.server, oauthToken: account.token) + do { + let sub: PushSubscription = try await client.get(endpoint: Push.subscription) + subscriptions.append(sub) + } catch { } + } + refreshSubscriptionsUI() + } + + public func updateSubscriptions(accounts: [PushAccounts]) async { + subscriptions = [] + guard let pushToken = pushToken, isUserPushEnabled else { return } + for account in accounts { + let client = Client(server: account.server, oauthToken: account.token) + do { + var listenerURL = Constants.endpoint + listenerURL += "/push/" + listenerURL += pushToken.hexString + listenerURL += "/\(account.server)" + #if DEBUG + listenerURL += "?sandbox=true" + #endif + let sub: PushSubscription = + try await client.post(endpoint: Push.createSub(endpoint: listenerURL, + p256dh: notificationsPrivateKeyAsKey.publicKey.x963Representation, + auth: notificationsAuthKeyAsKey, + mentions: isMentionNotificationEnabled, + status: true, + reblog: isReblogNotificationEnabled, + follow: isFollowNotificationEnabled, + favourite: isFavoriteNotificationEnabled, + poll: isPollNotificationEnabled)) + subscriptions.append(sub) + } catch { } + } + refreshSubscriptionsUI() + } + + public func deleteSubscriptions(accounts: [PushAccounts]) async { + for account in accounts { + let client = Client(server: account.server, oauthToken: account.token) + do { + _ = try await client.delete(endpoint: Push.subscription) + } catch { } + } + await fetchSubscriptions(accounts: accounts) + refreshSubscriptionsUI() + } + + private func refreshSubscriptionsUI() { + if let sub = subscriptions.first { + isPushEnabled = true + isFollowNotificationEnabled = sub.alerts.follow + isFavoriteNotificationEnabled = sub.alerts.favourite + isReblogNotificationEnabled = sub.alerts.reblog + isMentionNotificationEnabled = sub.alerts.mention + isPollNotificationEnabled = sub.alerts.poll + } else { + isPushEnabled = false + } + } + + // MARK: - Key management + + public var notificationsPrivateKeyAsKey: P256.KeyAgreement.PrivateKey { + if let key = keychain.get(Constants.keychainPrivateKey), + let data = Data(base64Encoded: key) { + do { + return try P256.KeyAgreement.PrivateKey(rawRepresentation: data) + } catch { + let key = P256.KeyAgreement.PrivateKey() + keychain.set(key.rawRepresentation.base64EncodedString(), forKey: Constants.keychainPrivateKey) + return key + } + } else { + let key = P256.KeyAgreement.PrivateKey() + keychain.set(key.rawRepresentation.base64EncodedString(), forKey: Constants.keychainPrivateKey) + return key + } + } + + public var notificationsAuthKeyAsKey: Data { + if let key = keychain.get(Constants.keychainAuthKey), + let data = Data(base64Encoded: key) { + return data + } else { + let key = Self.makeRandomeNotificationsAuthKey() + keychain.set(key.base64EncodedString(), forKey: Constants.keychainAuthKey) + return key + } + } + + static private func makeRandomeNotificationsAuthKey() -> Data { + let byteCount = 16 + var bytes = Data(count: byteCount) + _ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) } + return bytes + } +} + +extension Data { + var hexString: String { + return map { String(format: "%02.2hhx", arguments: [$0]) }.joined() + } +} + diff --git a/Packages/Models/Sources/Models/MastodonPushNotification.swift b/Packages/Models/Sources/Models/MastodonPushNotification.swift new file mode 100644 index 00000000..0dc8e59a --- /dev/null +++ b/Packages/Models/Sources/Models/MastodonPushNotification.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct MastodonPushNotification: Codable { + + public let accessToken: String + + public let notificationID: Int + public let notificationType: String + + public let preferredLocale: String? + public let icon: String? + public let title: String + public let body: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case notificationID = "notification_id" + case notificationType = "notification_type" + case preferredLocale = "preferred_locale" + case icon + case title + case body + } +} diff --git a/Packages/Models/Sources/Models/PushSubscription.swift b/Packages/Models/Sources/Models/PushSubscription.swift new file mode 100644 index 00000000..36a13a54 --- /dev/null +++ b/Packages/Models/Sources/Models/PushSubscription.swift @@ -0,0 +1,16 @@ +import Foundation + +public struct PushSubscription: Identifiable, Decodable { + public struct Alerts: Decodable { + public let follow: Bool + public let favourite: Bool + public let reblog: Bool + public let mention: Bool + public let poll: Bool + } + + public let id: Int + public let endpoint: URL + public let serverKey: String + public let alerts: Alerts +} diff --git a/Packages/Network/Sources/Network/Endpoint/Push.swift b/Packages/Network/Sources/Network/Endpoint/Push.swift new file mode 100644 index 00000000..2d6a6fd2 --- /dev/null +++ b/Packages/Network/Sources/Network/Endpoint/Push.swift @@ -0,0 +1,41 @@ +import Foundation + +public enum Push: Endpoint { + case subscription + case createSub(endpoint: String, + p256dh: Data, + auth: Data, + mentions: Bool, + status: Bool, + reblog: Bool, + follow: Bool, + favourite: Bool, + poll: Bool) + + public func path() -> String { + switch self { + case .subscription, .createSub: + return "push/subscription" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case let .createSub(endpoint, p256dh, auth, mentions, status, reblog, follow, favourite, poll): + var params: [URLQueryItem] = [] + params.append(.init(name: "subscription[endpoint]", value: endpoint)) + params.append(.init(name: "subscription[keys][p256dh]", value: p256dh.base64UrlEncodedString())) + params.append(.init(name: "subscription[keys][auth]", value: auth.base64UrlEncodedString())) + params.append(.init(name: "data[alerts][mention]", value: mentions ? "true" : "false")) + params.append(.init(name: "data[alerts][status]", value: status ? "true" : "false")) + params.append(.init(name: "data[alerts][follow]", value: follow ? "true" : "false")) + params.append(.init(name: "data[alerts][reblog]", value: reblog ? "true" : "false")) + params.append(.init(name: "data[alerts][favourite]", value: favourite ? "true" : "false")) + params.append(.init(name: "data[alerts][poll]", value: poll ? "true" : "false")) + params.append(.init(name: "policy", value: "all")) + return params + default: + return nil + } + } +} diff --git a/Packages/Network/Sources/Network/URLData.swift b/Packages/Network/Sources/Network/URLData.swift new file mode 100644 index 00000000..1ff0204b --- /dev/null +++ b/Packages/Network/Sources/Network/URLData.swift @@ -0,0 +1,10 @@ +import Foundation + +extension Data { + func base64UrlEncodedString() -> String { + return base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +}