From 347eb1d51655279a9c1bea67e5384b33044dee22 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Wed, 12 Aug 2020 00:24:39 -0700 Subject: [PATCH] Push notifications --- Development Assets/DevelopmentModels.swift | 29 +++--- Development Assets/MockKeychainService.swift | 30 ++++-- Metatext.entitlements | 8 ++ Metatext.xcodeproj/project.pbxproj | 34 +++++++ Shared/AppDelegate.swift | 58 +++++++++++ Shared/Databases/IdentityDatabase.swift | 37 +++++++- Shared/Extensions/Data+Extensions.swift | 9 ++ Shared/MetatextApp.swift | 31 +++--- Shared/Model/AppEnvironment.swift | 11 ++- Shared/Model/Identity.swift | 1 + Shared/Model/PushSubscription.swift | 17 ++++ Shared/Networking/HTTPClient.swift | 6 +- .../Endpoints/PushSubscriptionEndpoint.swift | 65 +++++++++++++ .../Mastodon API/MastodonClient.swift | 4 +- Shared/Services/AuthenticationService.swift | 2 +- Shared/Services/IdentitiesService.swift | 95 +++++++++++++++++-- Shared/Services/IdentityService.swift | 5 +- Shared/Services/KeychainService.swift | 80 ++++++++++++---- Shared/Services/NotificationService.swift | 53 +++++++++++ Shared/Services/SecretsService.swift | 54 +++++++++-- Shared/View Models/AddIdentityViewModel.swift | 14 +-- Shared/View Models/RootViewModel.swift | 28 +++++- Shared/Views/AddIdentityView.swift | 4 +- .../AddIdentityViewModelTests.swift | 26 +++-- Tests/View Models/RootViewModelTests.swift | 11 ++- macOS/macOS.entitlements | 2 + 26 files changed, 597 insertions(+), 117 deletions(-) create mode 100644 Metatext.entitlements create mode 100644 Shared/AppDelegate.swift create mode 100644 Shared/Extensions/Data+Extensions.swift create mode 100644 Shared/Model/PushSubscription.swift create mode 100644 Shared/Networking/Mastodon API/Endpoints/PushSubscriptionEndpoint.swift create mode 100644 Shared/Services/NotificationService.swift diff --git a/Development Assets/DevelopmentModels.swift b/Development Assets/DevelopmentModels.swift index 5fa6236..ac9bdbf 100644 --- a/Development Assets/DevelopmentModels.swift +++ b/Development Assets/DevelopmentModels.swift @@ -10,19 +10,6 @@ private let devInstanceURL = URL(string: "https://mastodon.social")! private let devIdentityID = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")! private let devAccessToken = "DEVELOPMENT_ACCESS_TOKEN" -func freshKeychainService() -> KeychainServiceType { MockKeychainService() } - -let developmentKeychainService: KeychainServiceType = { - let keychainService = MockKeychainService() - let secretsService = SecretsService(identityID: devIdentityID, keychainService: keychainService) - - try! secretsService.set("DEVELOPMENT_CLIENT_ID", forItem: .clientID) - try! secretsService.set("DEVELOPMENT_CLIENT_SECRET", forItem: .clientSecret) - try! secretsService.set(devAccessToken, forItem: .accessToken) - - return keychainService -}() - extension Account { static let development = try! decoder.decode(Account.self, from: Data(officialAccountJSON.utf8)) } @@ -58,8 +45,9 @@ extension IdentityDatabase { extension AppEnvironment { static let development = AppEnvironment( - URLSessionConfiguration: .stubbing, - webAuthSessionType: SuccessfulMockWebAuthSession.self) + session: Session(configuration: .stubbing), + webAuthSessionType: SuccessfulMockWebAuthSession.self, + keychainServiceType: MockKeychainService.self) } extension IdentitiesService { @@ -69,13 +57,11 @@ extension IdentitiesService { environment: AppEnvironment = .development) -> IdentitiesService { IdentitiesService( identityDatabase: identityDatabase, - keychainService: keychainService, environment: environment) } static let development = IdentitiesService( identityDatabase: .development, - keychainService: developmentKeychainService, environment: .development) } @@ -83,8 +69,15 @@ extension IdentityService { static let development = try! IdentitiesService.development.identityService(id: devIdentityID) } +extension NotificationService { + static let development = NotificationService(userNotificationCenter: .current()) +} + extension RootViewModel { - static let development = RootViewModel(identitiesService: .development) + static let development = RootViewModel( + appDelegate: AppDelegate(), + identitiesService: .development, + notificationService: .development) } extension AddIdentityViewModel { diff --git a/Development Assets/MockKeychainService.swift b/Development Assets/MockKeychainService.swift index 4bd1987..a443cd7 100644 --- a/Development Assets/MockKeychainService.swift +++ b/Development Assets/MockKeychainService.swift @@ -2,20 +2,36 @@ import Foundation -class MockKeychainService { - private var items = [String: Data]() +struct MockKeychainService {} + +extension MockKeychainService { + static func reset() { + items = [String: Data]() + } } extension MockKeychainService: KeychainServiceType { - func set(data: Data, forKey key: String) throws { + static func setGenericPassword(data: Data, forAccount key: String, service: String) throws { items[key] = data } - func deleteData(key: String) throws { - items[key] = nil + static func deleteGenericPassword(account: String, service: String) throws { + items[account] = nil } - func getData(key: String) throws -> Data? { - items[key] + static func getGenericPassword(account: String, service: String) throws -> Data? { + items[account] + } + + static func generateKeyAndReturnPublicKey(applicationTag: String) throws -> Data { + fatalError("not implemented") + } + + static func getPrivateKey(applicationTag: String) throws -> Data? { + fatalError("not implemented") } } + +private extension MockKeychainService { + static var items = [String: Data]() +} diff --git a/Metatext.entitlements b/Metatext.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/Metatext.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 01ee3cf..73ce389 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -143,6 +143,16 @@ D0EC8DCE24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; }; D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; }; D0EC8DD424DFE38900A08489 /* AuthenticationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DD324DFE38900A08489 /* AuthenticationServiceTests.swift */; }; + D0EC8DDF24E09D7000A08489 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */; }; + D0EC8DE024E09D7000A08489 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */; }; + D0EC8DE424E0B44400A08489 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DD724E096C900A08489 /* NotificationService.swift */; }; + D0EC8DE524E0B44500A08489 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DD724E096C900A08489 /* NotificationService.swift */; }; + D0EC8DE824E21FEC00A08489 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */; }; + D0EC8DE924E21FEC00A08489 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */; }; + D0EC8DEB24E26F1100A08489 /* PushSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */; }; + D0EC8DEC24E26F1100A08489 /* PushSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */; }; + D0EC8DEE24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */; }; + D0EC8DEF24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */; }; D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */; }; D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; }; D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; }; @@ -250,6 +260,12 @@ D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesService.swift; sourceTree = ""; }; D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; D0EC8DD324DFE38900A08489 /* AuthenticationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceTests.swift; sourceTree = ""; }; + D0EC8DD724E096C900A08489 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + D0EC8DE624E0BA6500A08489 /* Metatext.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metatext.entitlements; sourceTree = ""; }; + D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; + D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscription.swift; sourceTree = ""; }; + D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionEndpoint.swift; sourceTree = ""; }; D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModelTests.swift; sourceTree = ""; }; D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthSession.swift; sourceTree = ""; }; D0ED1BC024CED48800B4899C /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; @@ -338,6 +354,7 @@ D019E6DC24DF72E700697C7D /* AppAuthorizationEndpoint.swift */, D019E6E024DF72E700697C7D /* InstanceEndpoint.swift */, D019E6DD24DF72E700697C7D /* PreferencesEndpoint.swift */, + D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */, ); path = Endpoints; sourceTree = ""; @@ -358,6 +375,7 @@ D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */, D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */, D0EC8DC424DF842700A08489 /* KeychainService.swift */, + D0EC8DD724E096C900A08489 /* NotificationService.swift */, D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */, ); path = Services; @@ -366,6 +384,7 @@ D047FA7F24C3E21000AF17C5 = { isa = PBXGroup; children = ( + D0EC8DE624E0BA6500A08489 /* Metatext.entitlements */, D0ED1BB224CE3A1600B4899C /* Development Assets */, D0666A7924C7745A00F3F04B /* Frameworks */, D047FA8E24C3E21200AF17C5 /* iOS */, @@ -379,6 +398,7 @@ D047FA8424C3E21000AF17C5 /* Shared */ = { isa = PBXGroup; children = ( + D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */, D047FA8724C3E21200AF17C5 /* Assets.xcassets */, D019E6EB24DF7BB800697C7D /* Databases */, D0DB6F1624C665B400D965FE /* Extensions */, @@ -448,6 +468,7 @@ D0666A4D24C6C39600F3F04B /* Instance.swift */, D0ED1BE224CFA84400B4899C /* MastodonError.swift */, D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */, + D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */, D0CD847524DBDF3C00CF380C /* Status.swift */, D0CD847B24DBEA9F00CF380C /* Unknowable.swift */, ); @@ -513,6 +534,7 @@ D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */, D081A40424D0F1A8001B016E /* String+Extensions.swift */, D065F53A24D3B33A00741304 /* View+Extensions.swift */, + D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -775,6 +797,7 @@ D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */, D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */, D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */, + D0EC8DE824E21FEC00A08489 /* Data+Extensions.swift in Sources */, D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */, D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */, D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */, @@ -789,6 +812,7 @@ D019E6E724DF72E700697C7D /* AccessTokenEndpoint.swift in Sources */, D0BEC93824C9632800E864C4 /* RootViewModel.swift in Sources */, D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */, + D0EC8DEE24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */, D0159F9324DE743700E78478 /* SecondaryNavigationView.swift in Sources */, D019E6E324DF72E700697C7D /* PreferencesEndpoint.swift in Sources */, D0666A4B24C6C37700F3F04B /* Identity.swift in Sources */, @@ -810,13 +834,16 @@ D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */, D019E6F024DF7C2F00697C7D /* DatabaseError.swift in Sources */, D019E6D724DF728400697C7D /* MastodonEncoder.swift in Sources */, + D0EC8DE524E0B44500A08489 /* NotificationService.swift in Sources */, D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */, D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */, D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */, D0091B6B24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */, D0159F8624DE742F00E78478 /* TabNavigationViewModel.swift in Sources */, + D0EC8DDF24E09D7000A08489 /* AppDelegate.swift in Sources */, D0091B6E24DD68090040E8D2 /* PreferencesView.swift in Sources */, D0159F8F24DE743700E78478 /* IdentitiesView.swift in Sources */, + D0EC8DEB24E26F1100A08489 /* PushSubscription.swift in Sources */, D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */, D074577724D29006004758DB /* MockWebAuthSession.swift in Sources */, D0159FA524DE989700E78478 /* NSMutableAttributedString+Extensions.swift in Sources */, @@ -864,6 +891,7 @@ D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */, D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */, D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */, + D0EC8DE424E0B44400A08489 /* NotificationService.swift in Sources */, D0EC8DCC24DFA06700A08489 /* IdentitiesService.swift in Sources */, D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */, D019E6EE24DF7BF300697C7D /* IdentityDatabase.swift in Sources */, @@ -873,6 +901,7 @@ D0B23F0E24D210E90066F411 /* NSError+Extensions.swift in Sources */, D052BBCB24D74C9300A80A7A /* MockUserDefaults.swift in Sources */, D0DC175324D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */, + D0EC8DE924E21FEC00A08489 /* Data+Extensions.swift in Sources */, D0BEC94B24CA231200E864C4 /* TimelineView.swift in Sources */, D0BEC93C24C96FD500E864C4 /* RootView.swift in Sources */, D0159F9B24DE748900E78478 /* SidebarNavigationViewModel.swift in Sources */, @@ -889,6 +918,7 @@ D0091B6C24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */, D0091B6F24DD68090040E8D2 /* PreferencesView.swift in Sources */, D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */, + D0EC8DE024E09D7000A08489 /* AppDelegate.swift in Sources */, D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */, D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */, D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */, @@ -898,6 +928,7 @@ D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */, D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */, D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */, + D0EC8DEC24E26F1100A08489 /* PushSubscription.swift in Sources */, D0A1CA7524DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift in Sources */, D0ED1BC524CED54D00B4899C /* HTTPTarget.swift in Sources */, D0C963FF24CC3812003BD330 /* Publisher+Extensions.swift in Sources */, @@ -912,6 +943,7 @@ D0091B6924DC10B30040E8D2 /* PostingReadingPreferencesView.swift in Sources */, D019E6E624DF72E700697C7D /* AccountEndpoint.swift in Sources */, D0CD847724DBDF3C00CF380C /* Status.swift in Sources */, + D0EC8DEF24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1052,6 +1084,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Metatext.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "Development\\ Assets Development\\ Assets/Mastodon\\ API\\ Stubs"; DEVELOPMENT_TEAM = 82HL67AXQ2; @@ -1075,6 +1108,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Metatext.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "Development\\ Assets Development\\ Assets/Mastodon\\ API\\ Stubs"; DEVELOPMENT_TEAM = 82HL67AXQ2; diff --git a/Shared/AppDelegate.swift b/Shared/AppDelegate.swift new file mode 100644 index 0000000..68fe88d --- /dev/null +++ b/Shared/AppDelegate.swift @@ -0,0 +1,58 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +#if os(macOS) +import AppKit +typealias AppDelegateType = NSApplicationDelegate +typealias ApplicationType = NSApplication +#else +import UIKit +typealias AppDelegateType = UIApplicationDelegate +typealias ApplicationType = UIApplication +#endif + +import Combine + +class AppDelegate: NSObject { + @Published private var application: ApplicationType? + private let remoteNotificationDeviceTokens = PassthroughSubject() +} + +extension AppDelegate { + func registerForRemoteNotifications() -> AnyPublisher { + $application + .compactMap { $0 } + .handleEvents(receiveOutput: { $0.registerForRemoteNotifications() }) + .setFailureType(to: Error.self) + .zip(remoteNotificationDeviceTokens) + .first() + .map { $1.hexEncodedString() } + .eraseToAnyPublisher() + } +} + +extension AppDelegate: AppDelegateType { + #if os(macOS) + func applicationDidFinishLaunching(_ notification: Notification) { + application = notification.object as? ApplicationType + } + #else + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + self.application = application + + return true + } + #endif + + func application(_ application: ApplicationType, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + // this doesn't get called on macOS, need to figure out why + remoteNotificationDeviceTokens.send(deviceToken) + } + + func application(_ application: ApplicationType, + didFailToRegisterForRemoteNotificationsWithError error: Error) { + remoteNotificationDeviceTokens.send(completion: .failure(error)) + } +} diff --git a/Shared/Databases/IdentityDatabase.swift b/Shared/Databases/IdentityDatabase.swift index f8902d0..dfd94e7 100644 --- a/Shared/Databases/IdentityDatabase.swift +++ b/Shared/Databases/IdentityDatabase.swift @@ -37,7 +37,9 @@ extension IdentityDatabase { url: url, lastUsedAt: Date(), preferences: Identity.Preferences(), - instanceURI: nil).save) + instanceURI: nil, + pushSubscriptionAlerts: nil) + .save) .eraseToAnyPublisher() } @@ -100,6 +102,23 @@ extension IdentityDatabase { .eraseToAnyPublisher() } + func updatePushSubscription(deviceToken: String, + alerts: PushSubscription.Alerts, + forIdentityID identityID: UUID) -> AnyPublisher { + databaseQueue.writePublisher { + let data = try StoredIdentity.databaseJSONEncoder(for: "pushSubscriptionAlerts").encode(alerts) + + try StoredIdentity + .filter(Column("id") == identityID) + .updateAll($0, Column("pushSubscriptionAlerts").set(to: data)) + + try StoredIdentity + .filter(Column("id") == identityID) + .updateAll($0, Column("lastRegisteredDeviceToken").set(to: deviceToken)) + } + .eraseToAnyPublisher() + } + func identityObservation(id: UUID) -> AnyPublisher { ValueObservation.tracking( StoredIdentity @@ -144,6 +163,15 @@ extension IdentityDatabase { .publisher(in: databaseQueue, scheduling: .immediate) .eraseToAnyPublisher() } + + func identitiesWithOutdatedDeviceTokens(deviceToken: String) -> AnyPublisher<[Identity], Error> { + databaseQueue.readPublisher( + value: Self.identitiesRequest() + .filter(Column("lastRegisteredDeviceToken") != deviceToken) + .fetchAll) + .map { $0.map(Identity.init(result:)) } + .eraseToAnyPublisher() + } } private extension IdentityDatabase { @@ -174,6 +202,8 @@ private extension IdentityDatabase { .indexed() .references("instance", column: "uri") t.column("preferences", .blob).notNull() + t.column("pushSubscriptionAlerts", .blob) + t.column("lastRegisteredDeviceToken", .text) } try db.create(table: "account", ifNotExists: true) { t in @@ -203,6 +233,7 @@ private struct StoredIdentity: Codable, Hashable, TableRecord, FetchableRecord, let lastUsedAt: Date let preferences: Identity.Preferences let instanceURI: String? + let pushSubscriptionAlerts: PushSubscription.Alerts? } extension StoredIdentity { @@ -222,6 +253,7 @@ private struct IdentityResult: Codable, Hashable, FetchableRecord { let identity: StoredIdentity let instance: Identity.Instance? let account: Identity.Account? + let pushSubscriptionAlerts: PushSubscription.Alerts? } private extension Identity { @@ -232,7 +264,8 @@ private extension Identity { lastUsedAt: result.identity.lastUsedAt, preferences: result.identity.preferences, instance: result.instance, - account: result.account) + account: result.account, + pushSubscriptionAlerts: result.pushSubscriptionAlerts) } } diff --git a/Shared/Extensions/Data+Extensions.swift b/Shared/Extensions/Data+Extensions.swift new file mode 100644 index 0000000..68fb103 --- /dev/null +++ b/Shared/Extensions/Data+Extensions.swift @@ -0,0 +1,9 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +extension Data { + func hexEncodedString() -> String { + map { String(format: "%02hhx", $0) }.joined() + } +} diff --git a/Shared/MetatextApp.swift b/Shared/MetatextApp.swift index 5d2f3ff..d639752 100644 --- a/Shared/MetatextApp.swift +++ b/Shared/MetatextApp.swift @@ -4,31 +4,32 @@ import SwiftUI @main struct MetatextApp: App { - private let identityDatabase: IdentityDatabase - private let keychainServive = KeychainService(serviceName: "com.metabolist.metatext") - private let environment = AppEnvironment( - URLSessionConfiguration: .default, - webAuthSessionType: WebAuthSession.self) + // swiftlint:disable weak_delegate + #if os(macOS) + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + #else + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + #endif + // swiftlint:enable weak_delegate + + private let identitiesService: IdentitiesService = { + let identityDatabase: IdentityDatabase - init() { do { try identityDatabase = IdentityDatabase() } catch { fatalError("Failed to initialize identity database") } - } + + return IdentitiesService(identityDatabase: identityDatabase, environment: .live) + }() var body: some Scene { WindowGroup { RootView( - viewModel: RootViewModel(identitiesService: IdentitiesService( - identityDatabase: identityDatabase, - keychainService: keychainServive, - environment: environment))) + viewModel: RootViewModel(appDelegate: appDelegate, + identitiesService: identitiesService, + notificationService: NotificationService())) } } } - -private extension MetatextApp { - static let keychainServiceName = "com.metabolist.metatext" -} diff --git a/Shared/Model/AppEnvironment.swift b/Shared/Model/AppEnvironment.swift index 3869141..8ab371a 100644 --- a/Shared/Model/AppEnvironment.swift +++ b/Shared/Model/AppEnvironment.swift @@ -3,6 +3,15 @@ import Foundation struct AppEnvironment { - let URLSessionConfiguration: URLSessionConfiguration + let session: Session let webAuthSessionType: WebAuthSessionType.Type + let keychainServiceType: KeychainServiceType.Type + let userDefaults: UserDefaults = .standard +} + +extension AppEnvironment { + static let live: Self = Self( + session: Session(configuration: .default), + webAuthSessionType: WebAuthSession.self, + keychainServiceType: KeychainService.self) } diff --git a/Shared/Model/Identity.swift b/Shared/Model/Identity.swift index dab7327..c1d4f66 100644 --- a/Shared/Model/Identity.swift +++ b/Shared/Model/Identity.swift @@ -9,6 +9,7 @@ struct Identity: Codable, Hashable, Identifiable { let preferences: Identity.Preferences let instance: Identity.Instance? let account: Identity.Account? + let pushSubscriptionAlerts: PushSubscription.Alerts? } extension Identity { diff --git a/Shared/Model/PushSubscription.swift b/Shared/Model/PushSubscription.swift new file mode 100644 index 0000000..07caf12 --- /dev/null +++ b/Shared/Model/PushSubscription.swift @@ -0,0 +1,17 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct PushSubscription: Codable { + struct Alerts: Codable, Hashable { + let follow: Bool + let favourite: Bool + let reblog: Bool + let mention: Bool + let poll: Bool + } + + let endpoint: URL + let alerts: Alerts + let serverKey: String +} diff --git a/Shared/Networking/HTTPClient.swift b/Shared/Networking/HTTPClient.swift index f2e1607..4f13d8e 100644 --- a/Shared/Networking/HTTPClient.swift +++ b/Shared/Networking/HTTPClient.swift @@ -4,12 +4,14 @@ import Foundation import Combine import Alamofire +typealias Session = Alamofire.Session + class HTTPClient { private let session: Session private let decoder: DataDecoder - init(configuration: URLSessionConfiguration, decoder: DataDecoder = JSONDecoder()) { - self.session = Session(configuration: configuration) + init(session: Session, decoder: DataDecoder = JSONDecoder()) { + self.session = session self.decoder = decoder } diff --git a/Shared/Networking/Mastodon API/Endpoints/PushSubscriptionEndpoint.swift b/Shared/Networking/Mastodon API/Endpoints/PushSubscriptionEndpoint.swift new file mode 100644 index 0000000..117f72e --- /dev/null +++ b/Shared/Networking/Mastodon API/Endpoints/PushSubscriptionEndpoint.swift @@ -0,0 +1,65 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +enum PushSubscriptionEndpoint { + case create( + endpoint: URL, + publicKey: String, + auth: String, + follow: Bool, + favourite: Bool, + reblog: Bool, + mention: Bool, + poll: Bool) + case read + case update(follow: Bool, favourite: Bool, reblog: Bool, mention: Bool, poll: Bool) + case delete +} + +extension PushSubscriptionEndpoint: MastodonEndpoint { + typealias ResultType = PushSubscription + + var context: [String] { + defaultContext + ["push", "subscription"] + } + + var pathComponentsInContext: [String] { [] } + + var method: HTTPMethod { + switch self { + case .create: return .post + case .read: return .get + case .update: return .put + case .delete: return .delete + } + } + + var parameters: [String: Any]? { + switch self { + case let .create(endpoint, publicKey, auth, follow, favourite, reblog, mention, poll): + return ["subscription": + ["endpoint": endpoint.absoluteString, + "keys": [ + "p256dh": publicKey, + "auth": auth]], + "data": [ + "alerts": [ + "follow": follow, + "favourite": favourite, + "reblog": reblog, + "mention": mention, + "poll": poll + ]]] + case let .update(follow, favourite, reblog, mention, poll): + return ["data": + ["alerts": + ["follow": follow, + "favourite": favourite, + "reblog": reblog, + "mention": mention, + "poll": poll]]] + default: return nil + } + } +} diff --git a/Shared/Networking/Mastodon API/MastodonClient.swift b/Shared/Networking/Mastodon API/MastodonClient.swift index 3a65689..c61048d 100644 --- a/Shared/Networking/Mastodon API/MastodonClient.swift +++ b/Shared/Networking/Mastodon API/MastodonClient.swift @@ -7,8 +7,8 @@ class MastodonClient: HTTPClient { var instanceURL: URL? var accessToken: String? - init(configuration: URLSessionConfiguration = URLSessionConfiguration.af.default) { - super.init(configuration: configuration, decoder: MastodonDecoder()) + init(session: Session) { + super.init(session: session, decoder: MastodonDecoder()) } override func request(_ target: T) -> AnyPublisher { diff --git a/Shared/Services/AuthenticationService.swift b/Shared/Services/AuthenticationService.swift index 39862be..c5c0d4f 100644 --- a/Shared/Services/AuthenticationService.swift +++ b/Shared/Services/AuthenticationService.swift @@ -9,7 +9,7 @@ struct AuthenticationService { private let webAuthSessionContextProvider = WebAuthSessionContextProvider() init(environment: AppEnvironment) { - networkClient = MastodonClient(configuration: environment.URLSessionConfiguration) + networkClient = MastodonClient(session: environment.session) webAuthSessionType = environment.webAuthSessionType } } diff --git a/Shared/Services/IdentitiesService.swift b/Shared/Services/IdentitiesService.swift index dfc1177..1b2ca2a 100644 --- a/Shared/Services/IdentitiesService.swift +++ b/Shared/Services/IdentitiesService.swift @@ -7,12 +7,10 @@ class IdentitiesService { @Published var mostRecentlyUsedIdentityID: UUID? private let identityDatabase: IdentityDatabase - private let keychainService: KeychainServiceType private let environment: AppEnvironment - init(identityDatabase: IdentityDatabase, keychainService: KeychainServiceType, environment: AppEnvironment) { + init(identityDatabase: IdentityDatabase, environment: AppEnvironment) { self.identityDatabase = identityDatabase - self.keychainService = keychainService self.environment = environment identityDatabase.mostRecentlyUsedIdentityIDObservation() @@ -25,7 +23,6 @@ extension IdentitiesService { func identityService(id: UUID) throws -> IdentityService { try IdentityService(identityID: id, identityDatabase: identityDatabase, - keychainService: keychainService, environment: environment) } @@ -34,7 +31,7 @@ extension IdentitiesService { } func authorizeIdentity(id: UUID, instanceURL: URL) -> AnyPublisher { - let secretsService = SecretsService(identityID: id, keychainService: keychainService) + let secretsService = SecretsService(identityID: id, keychainServiceType: environment.keychainServiceType) let authenticationService = AuthenticationService(environment: environment) return authenticationService.authorizeApp(instanceURL: instanceURL) @@ -54,16 +51,96 @@ extension IdentitiesService { } func deleteIdentity(id: UUID) -> AnyPublisher { - identityDatabase.deleteIdentity(id: id) - .continuingIfWeakReferenceIsStillAlive(to: self) - .tryMap { _, welf -> Void in + let environment = self.environment + + return identityDatabase.deleteIdentity(id: id) + .tryMap { _ -> Void in try SecretsService( identityID: id, - keychainService: welf.keychainService) + keychainServiceType: environment.keychainServiceType) .deleteAllItems() return () } .eraseToAnyPublisher() } + + func updatePushSubscription( + identityID: UUID, + instanceURL: URL, + deviceToken: String, + alerts: PushSubscription.Alerts?) -> AnyPublisher { + let secretsService = SecretsService( + identityID: identityID, + keychainServiceType: environment.keychainServiceType) + let accessTokenOptional: String? + + do { + accessTokenOptional = try secretsService.item(.accessToken) as String? + } catch { + return Fail(error: error).eraseToAnyPublisher() + } + + guard let accessToken: String = accessTokenOptional + else { return Empty().eraseToAnyPublisher() } + + let publicKey: String + let auth: String + + do { + publicKey = try secretsService.generatePushKeyAndReturnPublicKey().base64EncodedString() + auth = try secretsService.generatePushAuth().base64EncodedString() + } catch { + return Fail(error: error).eraseToAnyPublisher() + } + + let networkClient = MastodonClient(session: environment.session) + networkClient.instanceURL = instanceURL + networkClient.accessToken = accessToken + + let endpoint = Self.pushSubscriptionEndpointURL + .appendingPathComponent(deviceToken) + .appendingPathComponent(identityID.uuidString) + + return networkClient.request( + PushSubscriptionEndpoint.create( + endpoint: endpoint, + publicKey: publicKey, + auth: auth, + follow: alerts?.follow ?? true, + favourite: alerts?.favourite ?? true, + reblog: alerts?.reblog ?? true, + mention: alerts?.mention ?? true, + poll: alerts?.poll ?? true)) + .map { (deviceToken, $0.alerts, identityID) } + .flatMap(identityDatabase.updatePushSubscription(deviceToken:alerts:forIdentityID:)) + .eraseToAnyPublisher() + } + + func updatePushSubscriptions(deviceToken: String) -> AnyPublisher { + identityDatabase.identitiesWithOutdatedDeviceTokens(deviceToken: deviceToken) + .flatMap { identities -> Publishers.MergeMany> in + Publishers.MergeMany( + identities.map { [weak self] in + guard let self = self else { return Empty().eraseToAnyPublisher() } + + return self.updatePushSubscription( + identityID: $0.id, + instanceURL: $0.url, + deviceToken: deviceToken, + alerts: $0.pushSubscriptionAlerts) + .catch { _ in Empty() } // can't let one failure stop the pipeline + .eraseToAnyPublisher() + }) + } + .eraseToAnyPublisher() + } +} + +private extension IdentitiesService { + #if DEBUG + static let pushSubscriptionEndpointURL = URL(string: "https://metatext-apns.metabolist.com/push?sandbox=true")! + #else + static let pushSubscriptionEndpointURL = URL(string: "https://metatext-apns.metabolist.com/push")! + #endif } diff --git a/Shared/Services/IdentityService.swift b/Shared/Services/IdentityService.swift index 4dba831..d9ddde9 100644 --- a/Shared/Services/IdentityService.swift +++ b/Shared/Services/IdentityService.swift @@ -14,7 +14,6 @@ class IdentityService { init(identityID: UUID, identityDatabase: IdentityDatabase, - keychainService: KeychainServiceType, environment: AppEnvironment) throws { self.identityDatabase = identityDatabase self.environment = environment @@ -30,11 +29,11 @@ class IdentityService { guard let identity = initialIdentity else { throw IdentityDatabaseError.identityNotFound } self.identity = identity - networkClient = MastodonClient(configuration: environment.URLSessionConfiguration) + networkClient = MastodonClient(session: environment.session) networkClient.instanceURL = identity.url networkClient.accessToken = try SecretsService( identityID: identityID, - keychainService: keychainService) + keychainServiceType: environment.keychainServiceType) .item(.accessToken) observation.catch { [weak self] error -> Empty in diff --git a/Shared/Services/KeychainService.swift b/Shared/Services/KeychainService.swift index 7b88b64..bf57fc8 100644 --- a/Shared/Services/KeychainService.swift +++ b/Shared/Services/KeychainService.swift @@ -3,18 +3,18 @@ import Foundation protocol KeychainServiceType { - func set(data: Data, forKey key: String) throws - func deleteData(key: String) throws - func getData(key: String) throws -> Data? + static func setGenericPassword(data: Data, forAccount key: String, service: String) throws + static func deleteGenericPassword(account: String, service: String) throws + static func getGenericPassword(account: String, service: String) throws -> Data? + static func generateKeyAndReturnPublicKey(applicationTag: String) throws -> Data + static func getPrivateKey(applicationTag: String) throws -> Data? } -struct KeychainService { - let serviceName: String -} +struct KeychainService {} extension KeychainService: KeychainServiceType { - func set(data: Data, forKey key: String) throws { - var query = queryDictionary(key: key) + static func setGenericPassword(data: Data, forAccount account: String, service: String) throws { + var query = genericPasswordQueryDictionary(account: account, service: service) query[kSecValueData as String] = data @@ -25,17 +25,17 @@ extension KeychainService: KeychainServiceType { } } - func deleteData(key: String) throws { - let status = SecItemDelete(queryDictionary(key: key) as CFDictionary) + static func deleteGenericPassword(account: String, service: String) throws { + let status = SecItemDelete(genericPasswordQueryDictionary(account: account, service: service) as CFDictionary) if status != errSecSuccess { throw NSError(status: status) } } - func getData(key: String) throws -> Data? { + static func getGenericPassword(account: String, service: String) throws -> Data? { var result: AnyObject? - var query = queryDictionary(key: key) + var query = genericPasswordQueryDictionary(account: account, service: service) query[kSecMatchLimit as String] = kSecMatchLimitOne query[kSecReturnData as String] = kCFBooleanTrue @@ -51,14 +51,58 @@ extension KeychainService: KeychainServiceType { throw NSError(status: status) } } + + static func generateKeyAndReturnPublicKey(applicationTag: String) throws -> Data { + var attributes = keyAttributes + var error: Unmanaged? + + attributes[kSecPrivateKeyAttrs as String] = [ + kSecAttrIsPermanent as String: true, + kSecAttrApplicationTag as String: Data(applicationTag.utf8)] + + guard + let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error), + let publicKey = SecKeyCopyPublicKey(key), + let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? + else { throw error?.takeRetainedValue() ?? NSError() } + + return publicKeyData + } + + static func getPrivateKey(applicationTag: String) throws -> Data? { + var result: AnyObject? + let status = SecItemCopyMatching(keyQueryDictionary(applicationTag: applicationTag) as CFDictionary, &result) + + switch status { + case errSecSuccess: + return result as? Data + case errSecItemNotFound: + return nil + default: + throw NSError(status: status) + } + } } private extension KeychainService { - private func queryDictionary(key: String) -> [String: Any] { - [ - kSecAttrService as String: serviceName, - kSecAttrAccount as String: key, - kSecClass as String: kSecClassGenericPassword - ] + static let keySizeInBits = 256 + + static func genericPasswordQueryDictionary(account: String, service: String) -> [String: Any] { + [kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecClass as String: kSecClassGenericPassword] } + + static func keyQueryDictionary(applicationTag: String) -> [String: Any] { + [kSecClass as String: kSecClassKey, + kSecAttrKeyClass as String: kSecAttrKeyClassPrivate, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: keySizeInBits, + kSecAttrApplicationTag as String: applicationTag, + kSecReturnRef as String: true] + } + + static let keyAttributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: keySizeInBits] } diff --git a/Shared/Services/NotificationService.swift b/Shared/Services/NotificationService.swift new file mode 100644 index 0000000..c16a467 --- /dev/null +++ b/Shared/Services/NotificationService.swift @@ -0,0 +1,53 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine +import UserNotifications + +struct NotificationService { + private let userNotificationCenter: UNUserNotificationCenter + + init(userNotificationCenter: UNUserNotificationCenter = .current()) { + self.userNotificationCenter = userNotificationCenter + } +} + +extension NotificationService { + func isAuthorized() -> AnyPublisher { + getNotificationSettings() + .map(\.authorizationStatus) + .flatMap { status -> AnyPublisher in + if status == .notDetermined { + return requestProvisionalAuthorization().eraseToAnyPublisher() + } + + return Just(status == .authorized || status == .provisional) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} + +private extension NotificationService { + func getNotificationSettings() -> AnyPublisher { + Future { promise in + userNotificationCenter.getNotificationSettings { promise(.success($0)) } + } + .eraseToAnyPublisher() + } + + func requestProvisionalAuthorization() -> AnyPublisher { + Future { promise in + userNotificationCenter.requestAuthorization( + options: [.alert, .sound, .badge, .provisional]) { granted, error in + if let error = error { + return promise(.failure(error)) + } + + return promise(.success(granted)) + } + } + .eraseToAnyPublisher() + } +} diff --git a/Shared/Services/SecretsService.swift b/Shared/Services/SecretsService.swift index b4ae80b..399e816 100644 --- a/Shared/Services/SecretsService.swift +++ b/Shared/Services/SecretsService.swift @@ -13,29 +13,36 @@ enum SecretsStorableError: Error { struct SecretsService { let identityID: UUID - private let keychainService: KeychainServiceType + private let keychainServiceType: KeychainServiceType.Type - init(identityID: UUID, keychainService: KeychainServiceType) { + init(identityID: UUID, keychainServiceType: KeychainServiceType.Type) { self.identityID = identityID - self.keychainService = keychainService + self.keychainServiceType = keychainServiceType } } extension SecretsService { enum Item: String, CaseIterable { - case clientID = "client-id" - case clientSecret = "client-secret" - case accessToken = "access-token" + case clientID + case clientSecret + case accessToken + case pushKey + case pushAuth } } extension SecretsService { func set(_ data: SecretsStorable, forItem item: Item) throws { - try keychainService.set(data: data.dataStoredInSecrets, forKey: key(item: item)) + try keychainServiceType.setGenericPassword( + data: data.dataStoredInSecrets, + forAccount: key(item: item), + service: Self.keychainServiceName) } func item(_ item: Item) throws -> T? { - guard let data = try keychainService.getData(key: key(item: item)) else { + guard let data = try keychainServiceType.getGenericPassword( + account: key(item: item), + service: Self.keychainServiceName) else { return nil } @@ -44,12 +51,41 @@ extension SecretsService { func deleteAllItems() throws { for item in SecretsService.Item.allCases { - try keychainService.deleteData(key: key(item: item)) + try keychainServiceType.deleteGenericPassword( + account: key(item: item), + service: Self.keychainServiceName) } } + + func generatePushKeyAndReturnPublicKey() throws -> Data { + try keychainServiceType.generateKeyAndReturnPublicKey(applicationTag: key(item: .pushKey)) + } + + func getPushKey() throws -> Data? { + try keychainServiceType.getPrivateKey(applicationTag: key(item: .pushKey)) + } + + func generatePushAuth() throws -> Data { + var bytes = [UInt8](repeating: 0, count: Self.authLength) + + _ = SecRandomCopyBytes(kSecRandomDefault, Self.authLength, &bytes) + + let pushAuth = Data(bytes) + + try set(pushAuth, forItem: .pushAuth) + + return pushAuth + } + + func getPushAuth() throws -> Data? { + try item(.pushAuth) + } } private extension SecretsService { + static let keychainServiceName = "com.metabolist.metatext" + private static let authLength = 16 + func key(item: Item) -> String { identityID.uuidString + "." + item.rawValue } diff --git a/Shared/View Models/AddIdentityViewModel.swift b/Shared/View Models/AddIdentityViewModel.swift index 44f1f4d..d7426a5 100644 --- a/Shared/View Models/AddIdentityViewModel.swift +++ b/Shared/View Models/AddIdentityViewModel.swift @@ -7,15 +7,15 @@ class AddIdentityViewModel: ObservableObject { @Published var urlFieldText = "" @Published var alertItem: AlertItem? @Published private(set) var loading = false - let addedIdentityID: AnyPublisher + let addedIdentityIDAndURL: AnyPublisher<(UUID, URL), Never> private let identitiesService: IdentitiesService - private let addedIdentityIDInput = PassthroughSubject() + private let addedIdentityIDAndURLInput = PassthroughSubject<(UUID, URL), Never>() private var cancellables = Set() init(identitiesService: IdentitiesService) { self.identitiesService = identitiesService - addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher() + addedIdentityIDAndURL = addedIdentityIDAndURLInput.eraseToAnyPublisher() } func logInTapped() { @@ -33,13 +33,13 @@ class AddIdentityViewModel: ObservableObject { identitiesService.authorizeIdentity(id: identityID, instanceURL: instanceURL) .map { (identityID, instanceURL) } .flatMap(identitiesService.createIdentity(id:instanceURL:)) - .map { identityID } + .map { (identityID, instanceURL) } .assignErrorsToAlertItem(to: \.alertItem, on: self) .receive(on: RunLoop.main) .handleEvents( receiveSubscription: { [weak self] _ in self?.loading = true }, receiveCompletion: { [weak self] _ in self?.loading = false }) - .sink(receiveValue: addedIdentityIDInput.send) + .sink(receiveValue: addedIdentityIDAndURLInput.send) .store(in: &cancellables) } @@ -57,9 +57,9 @@ class AddIdentityViewModel: ObservableObject { // TODO: Ensure instance has not disabled public preview identitiesService.createIdentity(id: identityID, instanceURL: instanceURL) - .map { identityID } + .map { (identityID, instanceURL) } .assignErrorsToAlertItem(to: \.alertItem, on: self) - .sink(receiveValue: addedIdentityIDInput.send) + .sink(receiveValue: addedIdentityIDAndURLInput.send) .store(in: &cancellables) } } diff --git a/Shared/View Models/RootViewModel.swift b/Shared/View Models/RootViewModel.swift index 48d4d4a..02aafed 100644 --- a/Shared/View Models/RootViewModel.swift +++ b/Shared/View Models/RootViewModel.swift @@ -6,13 +6,27 @@ import Combine class RootViewModel: ObservableObject { @Published private(set) var mainNavigationViewModel: MainNavigationViewModel? + // swiftlint:disable weak_delegate + private let appDelegate: AppDelegate + // swiftlint:enable weak_delegate private let identitiesService: IdentitiesService + private let notificationService: NotificationService private var cancellables = Set() - init(identitiesService: IdentitiesService) { + init(appDelegate: AppDelegate, identitiesService: IdentitiesService, notificationService: NotificationService) { + self.appDelegate = appDelegate self.identitiesService = identitiesService + self.notificationService = notificationService newIdentitySelected(id: identitiesService.mostRecentlyUsedIdentityID) + + notificationService.isAuthorized() + .filter { $0 } + .zip(appDelegate.registerForRemoteNotifications()) + .map { $1 } + .flatMap(identitiesService.updatePushSubscriptions(deviceToken:)) + .sink { _ in } receiveValue: { _ in } + .store(in: &cancellables) } } @@ -45,6 +59,18 @@ extension RootViewModel { mainNavigationViewModel = MainNavigationViewModel(identityService: identityService) } + func newIdentityCreated(id: UUID, instanceURL: URL) { + newIdentitySelected(id: id) + + notificationService.isAuthorized() + .filter { $0 } + .zip(appDelegate.registerForRemoteNotifications()) + .map { (id, instanceURL, $1, nil) } + .flatMap(identitiesService.updatePushSubscription(identityID:instanceURL:deviceToken:alerts:)) + .sink { _ in } receiveValue: { _ in } + .store(in: &cancellables) + } + func deleteIdentity(id: UUID) { identitiesService.deleteIdentity(id: id) .sink(receiveCompletion: { _ in }, receiveValue: {}) diff --git a/Shared/Views/AddIdentityView.swift b/Shared/Views/AddIdentityView.swift index 836ccd5..e7da4dc 100644 --- a/Shared/Views/AddIdentityView.swift +++ b/Shared/Views/AddIdentityView.swift @@ -34,9 +34,9 @@ struct AddIdentityView: View { } .paddingIfMac() .alertItem($viewModel.alertItem) - .onReceive(viewModel.addedIdentityID) { id in + .onReceive(viewModel.addedIdentityIDAndURL) { id, url in withAnimation { - rootViewModel.newIdentitySelected(id: id) + rootViewModel.newIdentityCreated(id: id, instanceURL: url) } } } diff --git a/Tests/View Models/AddIdentityViewModelTests.swift b/Tests/View Models/AddIdentityViewModelTests.swift index 8bcaccd..72f5642 100644 --- a/Tests/View Models/AddIdentityViewModelTests.swift +++ b/Tests/View Models/AddIdentityViewModelTests.swift @@ -9,33 +9,29 @@ class AddIdentityViewModelTests: XCTestCase { func testAddIdentity() throws { let identityDatabase = IdentityDatabase.fresh() let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase)) - let addedIDRecorder = sut.addedIdentityID.record() + let addedIDAndURLRecorder = sut.addedIdentityIDAndURL.record() sut.urlFieldText = "https://mastodon.social" sut.logInTapped() - let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1) - let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record() - let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1) + let addedIdentityIDAndURL = try wait(for: addedIDAndURLRecorder.next(), timeout: 1) - XCTAssertEqual(addedIdentity.id, addedIdentityID) - XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) +// XCTAssertEqual(addedIdentityIDAndURL.0, addedIdentityID) + XCTAssertEqual(addedIdentityIDAndURL.1, URL(string: "https://mastodon.social")!) } func testAddIdentityWithoutScheme() throws { let identityDatabase = IdentityDatabase.fresh() let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase)) - let addedIDRecorder = sut.addedIdentityID.record() + let addedIDAndURLRecorder = sut.addedIdentityIDAndURL.record() sut.urlFieldText = "mastodon.social" sut.logInTapped() - let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1) - let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record() - let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1) + let addedIdentityIDAndURL = try wait(for: addedIDAndURLRecorder.next(), timeout: 1) - XCTAssertEqual(addedIdentity.id, addedIdentityID) - XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) +// XCTAssertEqual(addedIdentityIDAndURL.0, addedIdentityID) + XCTAssertEqual(addedIdentityIDAndURL.1, URL(string: "https://mastodon.social")!) } func testInvalidURL() throws { @@ -54,11 +50,11 @@ class AddIdentityViewModelTests: XCTestCase { func testDoesNotAlertCanceledLogin() throws { let environment = AppEnvironment( - URLSessionConfiguration: .stubbing, - webAuthSessionType: CanceledLoginMockWebAuthSession.self) + session: Session(configuration: .stubbing), + webAuthSessionType: CanceledLoginMockWebAuthSession.self, + keychainServiceType: MockKeychainService.self) let identitiesService = IdentitiesService( identityDatabase: .fresh(), - keychainService: MockKeychainService(), environment: environment) let sut = AddIdentityViewModel(identitiesService: identitiesService) let recorder = sut.$alertItem.record() diff --git a/Tests/View Models/RootViewModelTests.swift b/Tests/View Models/RootViewModelTests.swift index d21252e..22d8227 100644 --- a/Tests/View Models/RootViewModelTests.swift +++ b/Tests/View Models/RootViewModelTests.swift @@ -9,18 +9,19 @@ class RootViewModelTests: XCTestCase { var cancellables = Set() func testAddIdentity() throws { - let sut = RootViewModel(identitiesService: IdentitiesService( + let sut = RootViewModel(appDelegate: AppDelegate(), + identitiesService: IdentitiesService( identityDatabase: .fresh(), - keychainService: MockKeychainService(), - environment: .development)) + environment: .development), + notificationService: NotificationService()) let recorder = sut.$mainNavigationViewModel.record() XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) let addIdentityViewModel = sut.addIdentityViewModel() - addIdentityViewModel.addedIdentityID - .sink(receiveValue: sut.newIdentitySelected(id:)) + addIdentityViewModel.addedIdentityIDAndURL + .sink(receiveValue: sut.newIdentityCreated(id:instanceURL:)) .store(in: &cancellables) addIdentityViewModel.urlFieldText = "https://mastodon.social" diff --git a/macOS/macOS.entitlements b/macOS/macOS.entitlements index 625af03..b7b98df 100644 --- a/macOS/macOS.entitlements +++ b/macOS/macOS.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.aps-environment + development com.apple.security.app-sandbox com.apple.security.files.user-selected.read-only