diff --git a/Shared/Databases/IdentityDatabase.swift b/Shared/Databases/IdentityDatabase.swift index dfd94e7..28cdd80 100644 --- a/Shared/Databases/IdentityDatabase.swift +++ b/Shared/Databases/IdentityDatabase.swift @@ -38,7 +38,8 @@ extension IdentityDatabase { lastUsedAt: Date(), preferences: Identity.Preferences(), instanceURI: nil, - pushSubscriptionAlerts: nil) + lastRegisteredDeviceToken: nil, + pushSubscriptionAlerts: .initial) .save) .eraseToAnyPublisher() } @@ -202,7 +203,7 @@ private extension IdentityDatabase { .indexed() .references("instance", column: "uri") t.column("preferences", .blob).notNull() - t.column("pushSubscriptionAlerts", .blob) + t.column("pushSubscriptionAlerts", .blob).notNull() t.column("lastRegisteredDeviceToken", .text) } @@ -233,7 +234,8 @@ private struct StoredIdentity: Codable, Hashable, TableRecord, FetchableRecord, let lastUsedAt: Date let preferences: Identity.Preferences let instanceURI: String? - let pushSubscriptionAlerts: PushSubscription.Alerts? + let lastRegisteredDeviceToken: String? + let pushSubscriptionAlerts: PushSubscription.Alerts } extension StoredIdentity { @@ -253,7 +255,7 @@ private struct IdentityResult: Codable, Hashable, FetchableRecord { let identity: StoredIdentity let instance: Identity.Instance? let account: Identity.Account? - let pushSubscriptionAlerts: PushSubscription.Alerts? + let pushSubscriptionAlerts: PushSubscription.Alerts } private extension Identity { @@ -265,6 +267,7 @@ private extension Identity { preferences: result.identity.preferences, instance: result.instance, account: result.account, + lastRegisteredDeviceToken: result.identity.lastRegisteredDeviceToken, pushSubscriptionAlerts: result.pushSubscriptionAlerts) } } diff --git a/Shared/Model/Identity.swift b/Shared/Model/Identity.swift index c1d4f66..1291f04 100644 --- a/Shared/Model/Identity.swift +++ b/Shared/Model/Identity.swift @@ -9,7 +9,8 @@ struct Identity: Codable, Hashable, Identifiable { let preferences: Identity.Preferences let instance: Identity.Instance? let account: Identity.Account? - let pushSubscriptionAlerts: PushSubscription.Alerts? + let lastRegisteredDeviceToken: String? + let pushSubscriptionAlerts: PushSubscription.Alerts } extension Identity { diff --git a/Shared/Model/PushSubscription.swift b/Shared/Model/PushSubscription.swift index 07caf12..e3b05c5 100644 --- a/Shared/Model/PushSubscription.swift +++ b/Shared/Model/PushSubscription.swift @@ -15,3 +15,7 @@ struct PushSubscription: Codable { let alerts: Alerts let serverKey: String } + +extension PushSubscription.Alerts { + static let initial: Self = Self(follow: true, favourite: true, reblog: true, mention: true, poll: true) +} diff --git a/Shared/Networking/Mastodon API/Endpoints/PushSubscriptionEndpoint.swift b/Shared/Networking/Mastodon API/Endpoints/PushSubscriptionEndpoint.swift index 117f72e..069efac 100644 --- a/Shared/Networking/Mastodon API/Endpoints/PushSubscriptionEndpoint.swift +++ b/Shared/Networking/Mastodon API/Endpoints/PushSubscriptionEndpoint.swift @@ -7,13 +7,9 @@ enum PushSubscriptionEndpoint { endpoint: URL, publicKey: String, auth: String, - follow: Bool, - favourite: Bool, - reblog: Bool, - mention: Bool, - poll: Bool) + alerts: PushSubscription.Alerts) case read - case update(follow: Bool, favourite: Bool, reblog: Bool, mention: Bool, poll: Bool) + case update(alerts: PushSubscription.Alerts) case delete } @@ -37,7 +33,7 @@ extension PushSubscriptionEndpoint: MastodonEndpoint { var parameters: [String: Any]? { switch self { - case let .create(endpoint, publicKey, auth, follow, favourite, reblog, mention, poll): + case let .create(endpoint, publicKey, auth, alerts): return ["subscription": ["endpoint": endpoint.absoluteString, "keys": [ @@ -45,20 +41,20 @@ extension PushSubscriptionEndpoint: MastodonEndpoint { "auth": auth]], "data": [ "alerts": [ - "follow": follow, - "favourite": favourite, - "reblog": reblog, - "mention": mention, - "poll": poll + "follow": alerts.follow, + "favourite": alerts.favourite, + "reblog": alerts.reblog, + "mention": alerts.mention, + "poll": alerts.poll ]]] - case let .update(follow, favourite, reblog, mention, poll): + case let .update(alerts): return ["data": ["alerts": - ["follow": follow, - "favourite": favourite, - "reblog": reblog, - "mention": mention, - "poll": poll]]] + ["follow": alerts.follow, + "favourite": alerts.favourite, + "reblog": alerts.reblog, + "mention": alerts.mention, + "poll": alerts.poll]]] default: return nil } } diff --git a/Shared/Services/IdentitiesService.swift b/Shared/Services/IdentitiesService.swift index 1b2ca2a..69266f0 100644 --- a/Shared/Services/IdentitiesService.swift +++ b/Shared/Services/IdentitiesService.swift @@ -65,82 +65,20 @@ extension IdentitiesService { .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() } + .tryMap { [weak self] identities -> [AnyPublisher] 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() - }) + return try identities.map { + try self.identityService(id: $0.id) + .createPushSubscription(deviceToken: deviceToken, alerts: $0.pushSubscriptionAlerts) + .catch { _ in Empty() } // don't want to disrupt pipeline, consider future telemetry + .eraseToAnyPublisher() + } } + .map(Publishers.MergeMany.init) + .map { _ in () } .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 ff4fd2f..5b7ca38 100644 --- a/Shared/Services/IdentityService.swift +++ b/Shared/Services/IdentityService.swift @@ -10,6 +10,7 @@ class IdentityService { private let identityDatabase: IdentityDatabase private let environment: AppEnvironment private let networkClient: MastodonClient + private let secretsService: SecretsService private let observationErrorsInput = PassthroughSubject() init(identityID: UUID, @@ -29,12 +30,12 @@ class IdentityService { guard let identity = initialIdentity else { throw IdentityDatabaseError.identityNotFound } self.identity = identity - networkClient = MastodonClient(session: environment.session) - networkClient.instanceURL = identity.url - networkClient.accessToken = try SecretsService( + secretsService = SecretsService( identityID: identityID, keychainServiceType: environment.keychainServiceType) - .item(.accessToken) + networkClient = MastodonClient(session: environment.session) + networkClient.instanceURL = identity.url + networkClient.accessToken = try secretsService.item(.accessToken) observation.catch { [weak self] error -> Empty in self?.observationErrorsInput.send(error) @@ -85,4 +86,39 @@ extension IdentityService { func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher { identityDatabase.updatePreferences(preferences, forIdentityID: identity.id) } + + func createPushSubscription(deviceToken: String, alerts: PushSubscription.Alerts) -> AnyPublisher { + let publicKey: String + let auth: String + + do { + publicKey = try secretsService.generatePushKeyAndReturnPublicKey().base64EncodedString() + auth = try secretsService.generatePushAuth().base64EncodedString() + } catch { + return Fail(error: error).eraseToAnyPublisher() + } + + let identityID = identity.id + let endpoint = Self.pushSubscriptionEndpointURL + .appendingPathComponent(deviceToken) + .appendingPathComponent(identityID.uuidString) + + return networkClient.request( + PushSubscriptionEndpoint.create( + endpoint: endpoint, + publicKey: publicKey, + auth: auth, + alerts: alerts)) + .map { (deviceToken, $0.alerts, identityID) } + .flatMap(identityDatabase.updatePushSubscription(deviceToken:alerts:forIdentityID:)) + .eraseToAnyPublisher() + } +} + +private extension IdentityService { + #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/View Models/AddIdentityViewModel.swift b/Shared/View Models/AddIdentityViewModel.swift index d7426a5..44f1f4d 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 addedIdentityIDAndURL: AnyPublisher<(UUID, URL), Never> + let addedIdentityID: AnyPublisher private let identitiesService: IdentitiesService - private let addedIdentityIDAndURLInput = PassthroughSubject<(UUID, URL), Never>() + private let addedIdentityIDInput = PassthroughSubject() private var cancellables = Set() init(identitiesService: IdentitiesService) { self.identitiesService = identitiesService - addedIdentityIDAndURL = addedIdentityIDAndURLInput.eraseToAnyPublisher() + addedIdentityID = addedIdentityIDInput.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, instanceURL) } + .map { identityID } .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: addedIdentityIDAndURLInput.send) + .sink(receiveValue: addedIdentityIDInput.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, instanceURL) } + .map { identityID } .assignErrorsToAlertItem(to: \.alertItem, on: self) - .sink(receiveValue: addedIdentityIDAndURLInput.send) + .sink(receiveValue: addedIdentityIDInput.send) .store(in: &cancellables) } } diff --git a/Shared/View Models/RootViewModel.swift b/Shared/View Models/RootViewModel.swift index f051064..27a5117 100644 --- a/Shared/View Models/RootViewModel.swift +++ b/Shared/View Models/RootViewModel.swift @@ -55,22 +55,19 @@ extension RootViewModel { .store(in: &cancellables) identityService.updateLastUse() - .sink(receiveCompletion: { _ in }, receiveValue: {}) + .sink { _ in } receiveValue: { _ in } .store(in: &cancellables) - mainNavigationViewModel = MainNavigationViewModel(identityService: identityService) - } - - func newIdentityCreated(id: UUID, instanceURL: URL) { - newIdentitySelected(id: id) - userNotificationService.isAuthorized() .filter { $0 } .zip(appDelegate.registerForRemoteNotifications()) - .map { (id, instanceURL, $1, nil) } - .flatMap(identitiesService.updatePushSubscription(identityID:instanceURL:deviceToken:alerts:)) + .filter { identityService.identity.lastRegisteredDeviceToken != $1 } + .map { ($1, identityService.identity.pushSubscriptionAlerts) } + .flatMap(identityService.createPushSubscription(deviceToken:alerts:)) .sink { _ in } receiveValue: { _ in } .store(in: &cancellables) + + mainNavigationViewModel = MainNavigationViewModel(identityService: identityService) } func deleteIdentity(id: UUID) { diff --git a/Shared/Views/AddIdentityView.swift b/Shared/Views/AddIdentityView.swift index e7da4dc..836ccd5 100644 --- a/Shared/Views/AddIdentityView.swift +++ b/Shared/Views/AddIdentityView.swift @@ -34,9 +34,9 @@ struct AddIdentityView: View { } .paddingIfMac() .alertItem($viewModel.alertItem) - .onReceive(viewModel.addedIdentityIDAndURL) { id, url in + .onReceive(viewModel.addedIdentityID) { id in withAnimation { - rootViewModel.newIdentityCreated(id: id, instanceURL: url) + rootViewModel.newIdentitySelected(id: id) } } } diff --git a/Tests/View Models/RootViewModelTests.swift b/Tests/View Models/RootViewModelTests.swift index 22d8227..e19f97d 100644 --- a/Tests/View Models/RootViewModelTests.swift +++ b/Tests/View Models/RootViewModelTests.swift @@ -13,7 +13,7 @@ class RootViewModelTests: XCTestCase { identitiesService: IdentitiesService( identityDatabase: .fresh(), environment: .development), - notificationService: NotificationService()) + userNotificationService: UserNotificationService()) let recorder = sut.$mainNavigationViewModel.record() XCTAssertNil(try wait(for: recorder.next(), timeout: 1))