2
2
mirror of https://github.com/mastodon/mastodon-ios synced 2025-04-11 22:58:02 +02:00

Remove push notification subscription management responsibility from SettingService

The SettingService used to be created very soon after launch, so it would catch the new registration token. It is now only created when needed by the Settings view controller, which makes it a bad place to keep the listener responsible for updating the current subscriptions with the new device token.

Also, update all subscriptions on launch, rather than each account’s subscriptions only when they become the active account.

Contributes to IOS-384
This commit is contained in:
shannon 2025-04-01 09:16:28 -04:00
parent bf2a1ce790
commit d7d565879f
6 changed files with 137 additions and 126 deletions

View File

@ -218,35 +218,16 @@ extension SettingsCoordinator: NotificationSettingsViewControllerDelegate {
guard let subscription = setting.activeSubscription,
setting.domain == authenticationBox.domain,
setting.userID == authenticationBox.userID,
let legacyViewModel = NotificationService.shared.dequeueNotificationViewModel(mastodonAuthenticationBox: authenticationBox), let deviceToken = NotificationService.shared.deviceToken.value else { return }
setting.userID == authenticationBox.userID else { return }
let queryData = Mastodon.API.Subscriptions.QueryData(
policy: viewModel.selectedPolicy.subscriptionPolicy,
alerts: Mastodon.API.Subscriptions.QueryData.Alerts(
favourite: viewModel.notifyFavorites,
follow: viewModel.notifyNewFollowers,
reblog: viewModel.notifyBoosts,
mention: viewModel.notifyMentions,
poll: subscription.alert.poll
)
NotificationService.shared.updatePushNotificationSubscription(subscription.objectID, for: authenticationBox, policy: viewModel.selectedPolicy.subscriptionPolicy, alerts: Mastodon.API.Subscriptions.QueryData.Alerts(
favourite: viewModel.notifyFavorites,
follow: viewModel.notifyNewFollowers,
reblog: viewModel.notifyBoosts,
mention: viewModel.notifyMentions,
poll: subscription.alert.poll
)
let query = legacyViewModel.createSubscribeQuery(
deviceToken: deviceToken,
queryData: queryData,
mastodonAuthenticationBox: authenticationBox
)
APIService.shared.createSubscription(
subscriptionObjectID: subscription.objectID,
query: query,
mastodonAuthenticationBox: authenticationBox
).sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { output in
print(output)
})
.store(in: &disposeBag)
}
func showNotificationSettings(_ viewController: UIViewController) {

View File

@ -64,7 +64,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
extension AppDelegate {
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
NotificationService.shared.deviceToken.value = deviceToken
NotificationService.shared.registrationStatus.send(.registrationTokenReceived(deviceToken))
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
NotificationService.shared.registrationStatus.send(.error(error))
}
}

View File

@ -13,43 +13,39 @@ import MastodonSDK
extension APIService {
public func createSubscription(
public func subscribeToPushNotifications(
subscriptionObjectID: NSManagedObjectID,
query: Mastodon.API.Subscriptions.CreateSubscriptionQuery,
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
) async throws -> Mastodon.Entity.Subscription {
let authorization = mastodonAuthenticationBox.userAuthorization
let domain = mastodonAuthenticationBox.domain
return Mastodon.API.Subscriptions.createSubscription(
let responseContent = try await Mastodon.API.Subscriptions.createSubscription(
session: session,
domain: domain,
authorization: authorization,
query: query
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> in
let managedObjectContext = self.backgroundManagedObjectContext
return managedObjectContext.performChanges {
guard let subscription = managedObjectContext.object(with: subscriptionObjectID) as? NotificationSubscription else {
assertionFailure()
return
}
subscription.alert.update(favourite: response.value.alerts.favourite)
subscription.alert.update(reblog: response.value.alerts.reblog)
subscription.alert.update(follow: response.value.alerts.follow)
subscription.alert.update(mention: response.value.alerts.mention)
subscription.endpoint = response.value.endpoint
subscription.serverKey = response.value.serverKey
subscription.userToken = authorization.accessToken
subscription.didUpdate(at: response.networkDate)
let newSubscription = responseContent.value
let managedObjectContext = self.backgroundManagedObjectContext
try await managedObjectContext.performChanges {
guard let subscription = managedObjectContext.object(with: subscriptionObjectID) as? NotificationSubscription else {
assertionFailure()
return
}
.setFailureType(to: Error.self)
.map { _ in return response }
.eraseToAnyPublisher()
subscription.alert.update(favourite: newSubscription.alerts.favourite)
subscription.alert.update(reblog: newSubscription.alerts.reblog)
subscription.alert.update(follow: newSubscription.alerts.follow)
subscription.alert.update(mention: newSubscription.alerts.mention)
subscription.endpoint = newSubscription.endpoint
subscription.serverKey = newSubscription.serverKey
subscription.userToken = authorization.accessToken
subscription.didUpdate(at: responseContent.networkDate)
}
.eraseToAnyPublisher()
return newSubscription
}
func cancelSubscription(

View File

@ -16,6 +16,12 @@ import MastodonLocalization
@MainActor
public final class NotificationService {
public enum PushNotificationRegistrationStatus {
case registering
case error(Error)
case registrationTokenReceived(Data)
}
public static let shared = { NotificationService() }()
public static let unreadShortcutItemIdentifier = "org.joinmastodon.app.NotificationService.unread-shortcut"
@ -25,8 +31,8 @@ public final class NotificationService {
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.NotificationService.working-queue")
// input
public let registrationStatus = CurrentValueSubject<PushNotificationRegistrationStatus, Never>(.registering)
public let isNotificationPermissionGranted = CurrentValueSubject<Bool, Never>(false)
public let deviceToken = CurrentValueSubject<Data?, Never>(nil)
public let applicationIconBadgeNeedsUpdate = CurrentValueSubject<Void, Never>(Void())
// output
@ -36,13 +42,22 @@ public final class NotificationService {
public let requestRevealNotificationPublisher = PassthroughSubject<MastodonPushNotification, Never>()
private init() {
AuthenticationServiceProvider.shared.currentActiveUser
.sink(receiveValue: { [weak self] auth in
Publishers.CombineLatest(
AuthenticationServiceProvider.shared.currentActiveUser,
registrationStatus
)
.sink(receiveValue: { [weak self] auth, registrationStatus in
guard let self = self else { return }
// request permission when sign-in
guard auth != nil else { return }
self.requestNotificationPermission()
switch (auth, registrationStatus) {
case (nil, _):
break
case (_, .registrationTokenReceived):
self.requestNotificationPermissionAndUpdateSubscriptions()
case (_, .registering), (_, .error):
break
}
})
.store(in: &disposeBag)
@ -73,18 +88,22 @@ public final class NotificationService {
}
extension NotificationService {
private func requestNotificationPermission() {
private func requestNotificationPermissionAndUpdateSubscriptions() {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in
guard let self = self else { return }
guard self.isNotificationPermissionGranted.value != granted else { return }
self.isNotificationPermissionGranted.value = granted
if let _ = error {
// Handle the error here.
switch (granted, registrationStatus.value) {
case (true, .registrationTokenReceived(let token)):
Task {
await self.updatePushNotificationSubscriptions(deviceToken: token)
}
case (true, .error), (true, .registering):
break
case (false, _):
break
}
// Enable or disable features based on the authorization.
}
}
}
@ -169,7 +188,7 @@ extension NotificationService {
private func fetchLatestNotifications(
pushNotification: MastodonPushNotification
) async throws {
guard let authenticationBox = try await authenticationBox(for: pushNotification) else { return }
guard let authenticationBox = authenticationBox(for: pushNotification) else { return }
_ = try await APIService.shared.notifications(
olderThan: nil,
@ -231,6 +250,62 @@ extension NotificationService {
}
extension NotificationService {
private func updatePushNotificationSubscriptions(deviceToken: Data) async {
do {
for userAuthBox in AuthenticationServiceProvider.shared.mastodonAuthenticationBoxes {
guard let setting = SettingService.shared.setting(for: userAuthBox) else { continue }
guard let subscription = setting.activeSubscription else { continue }
let queryData = Mastodon.API.Subscriptions.QueryData(
policy: subscription.policy,
alerts: Mastodon.API.Subscriptions.QueryData.Alerts(
favourite: subscription.alert.favourite,
follow: subscription.alert.follow,
reblog: subscription.alert.reblog,
mention: subscription.alert.mention,
poll: subscription.alert.poll
)
)
let query = NotificationService.createSubscribeQuery(
deviceToken: deviceToken,
queryData: queryData,
mastodonAuthenticationBox: userAuthBox
)
let createSubscriptionTask = Task {
try await APIService.shared.subscribeToPushNotifications(
subscriptionObjectID: subscription.objectID,
query: query,
mastodonAuthenticationBox: userAuthBox
)
}
let _ = try await createSubscriptionTask.value
}
} catch {
assertionFailure("error creating push notification subscription")
}
}
public func updatePushNotificationSubscription(_ subscriptionObjectID: NSManagedObjectID, for userAuthBox: MastodonAuthenticationBox, policy: Mastodon.API.Subscriptions.QueryData.Policy, alerts: Mastodon.API.Subscriptions.QueryData.Alerts) {
guard case let .registrationTokenReceived(deviceToken) = registrationStatus.value else { return }
let queryData = Mastodon.API.Subscriptions.QueryData(policy: policy, alerts: alerts)
let query = NotificationService.createSubscribeQuery(
deviceToken: deviceToken,
queryData: queryData,
mastodonAuthenticationBox: userAuthBox
)
Task {
let _ = try await APIService.shared.subscribeToPushNotifications(
subscriptionObjectID: subscriptionObjectID,
query: query,
mastodonAuthenticationBox: userAuthBox
)
}
}
}
// MARK: - NotificationViewModel
extension NotificationService {
@ -251,8 +326,8 @@ extension NotificationService {
}
}
extension NotificationService.NotificationViewModel {
public func createSubscribeQuery(
extension NotificationService {
public static func createSubscribeQuery(
deviceToken: Data,
queryData: Mastodon.API.Subscriptions.QueryData,
mastodonAuthenticationBox: MastodonAuthenticationBox

View File

@ -66,62 +66,11 @@ public final class SettingService {
.sink { [weak self] mastodonAuthenticationBoxes, settings in
guard let self = self else { return }
guard let activeMastodonAuthenticationBox = mastodonAuthenticationBoxes.first else { return }
let currentSetting = settings.first(where: { setting in
return setting.domain == activeMastodonAuthenticationBox.domain
&& setting.userID == activeMastodonAuthenticationBox.userID
})
let currentSetting = setting(for: activeMastodonAuthenticationBox)
self.currentSetting.value = currentSetting
}
.store(in: &disposeBag)
Publishers.CombineLatest3(
notificationService.deviceToken,
currentSetting.eraseToAnyPublisher(),
AuthenticationServiceProvider.shared.$mastodonAuthenticationBoxes
)
.compactMap { [weak self] deviceToken, setting, mastodonAuthenticationBoxes -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error>? in
guard let self = self else { return nil }
guard let deviceToken = deviceToken else { return nil }
guard let setting = setting else { return nil }
guard let authenticationBox = mastodonAuthenticationBoxes.first else { return nil }
guard let subscription = setting.activeSubscription else { return nil }
guard setting.domain == authenticationBox.domain,
setting.userID == authenticationBox.userID else { return nil }
let _viewModel = notificationService.dequeueNotificationViewModel(
mastodonAuthenticationBox: authenticationBox
)
guard let viewModel = _viewModel else { return nil }
let queryData = Mastodon.API.Subscriptions.QueryData(
policy: subscription.policy,
alerts: Mastodon.API.Subscriptions.QueryData.Alerts(
favourite: subscription.alert.favourite,
follow: subscription.alert.follow,
reblog: subscription.alert.reblog,
mention: subscription.alert.mention,
poll: subscription.alert.poll
)
)
let query = viewModel.createSubscribeQuery(
deviceToken: deviceToken,
queryData: queryData,
mastodonAuthenticationBox: authenticationBox
)
return apiService.createSubscription(
subscriptionObjectID: subscription.objectID,
query: query,
mastodonAuthenticationBox: authenticationBox
)
}
.debounce(for: .seconds(3), scheduler: DispatchQueue.main) // limit subscribe request emit time interval
.sink(receiveValue: { _ in
})
.store(in: &disposeBag)
}
}
extension SettingService {
@ -138,3 +87,12 @@ extension SettingService {
return alertController
}
}
extension SettingService {
public func setting(for userAuthBox: MastodonAuthenticationBox) -> Setting? {
return settingFetchedResultController.settings.value.first(where: { setting in
return setting.domain == userAuthBox.domain
&& setting.userID == userAuthBox.userID
})
}
}

View File

@ -61,24 +61,21 @@ extension Mastodon.API.Subscriptions {
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - authorization: User token. Could be nil if status is public
/// - Returns: `AnyPublisher` contains `Subscription` nested in the response
/// - Returns: `Subscription`
public static func createSubscription(
session: URLSession,
domain: String,
authorization: Mastodon.API.OAuth.Authorization,
query: CreateSubscriptionQuery
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Subscription> {
let request = Mastodon.API.post(
url: pushEndpointURL(domain: domain),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
let (data, response) = try await session.data(for: request)
let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
/// Change types of notifications