Files
yattee/Yattee/Services/SubscriptionService.swift
Arkadiusz Fal 2fded2e1dd Fix account defects found in review
Five findings against the accounts layer, all real.

A fresh login created a random-UUID account and then flipped
usesAccountLogin, which posts instancesDidChange and re-ran legacy
reconciliation immediately. That guard only looked for an account whose ID
equalled the instance ID, so it synthesized a second, credential-less
account alongside the real one. Signing in now marks the instance migrated,
and reconciliation treats any account on an instance as evidence its login
is already represented.

save() set hasStoredContext, so the save inside reconcileLegacyAccounts
marked the still-default .local context as explicitly chosen and the
legacy-context migration that ran immediately after did nothing. Upgrading
users with a selected server account silently lost it. Only setContext,
applySyncedContext and the migration itself record a choice now.

An Apple Account change re-resolved the credential scope but never told
AccountsManager to reload its identity-namespaced storage - the only reload
was a one-time launch task - so the previous identity's accounts, context
and outbox stayed live under the new one, as did cached per-account
subscription providers. The scope now posts when its resolved value
changes; the accounts store reloads and the provider cache is dropped.

Fourteen call sites still read the pre-revamp instance-keyed Keychain item,
which a new login no longer writes: reopening the source screen showed the
account as signed out, and feed, browse, home and import operations treated
it as unauthenticated. They now resolve the credential through the account
layer, preferring the selected context's account and otherwise any
signed-in account on that instance.

Signing in while the identity scope was still unresolved created the
account in a temporary namespace that reloadForCurrentIdentity later
discards, while credential storage silently refused the write and the UI
reported success. Login is gated on a resolved scope and verifies the
credential actually stored before reporting success, rolling back a
newly-created account if it did not.
2026-08-24 23:57:44 +02:00

373 lines
13 KiB
Swift

//
// SubscriptionService.swift
// Yattee
//
// Service for managing subscriptions through the selected provider.
// Supports local (iCloud) and Invidious account subscriptions.
//
import Foundation
/// Errors that can occur during subscription operations.
enum SubscriptionError: LocalizedError, Sendable {
case alreadySubscribed
case notSubscribed
case providerNotAvailable
case operationFailed(String)
var errorDescription: String? {
switch self {
case .alreadySubscribed:
return String(localized: "subscription.error.alreadySubscribed")
case .notSubscribed:
return String(localized: "subscription.error.notSubscribed")
case .providerNotAvailable:
return String(localized: "subscription.error.providerNotAvailable")
case .operationFailed(let message):
return message
}
}
}
/// Service that manages subscriptions through the selected subscription provider.
/// The provider is determined by the user's subscription account setting.
@MainActor
@Observable
final class SubscriptionService {
// MARK: - Dependencies
private let dataManager: DataManager
private let settingsManager: SettingsManager
private let instancesManager: InstancesManager
private let invidiousCredentialsManager: InvidiousCredentialsManager
private let pipedCredentialsManager: PipedCredentialsManager
private let invidiousAPI: InvidiousAPI
private let pipedAPI: PipedAPI
// MARK: - Providers
private var localProvider: LocalSubscriptionProvider?
/// One provider per account, built on demand and kept so its in-memory
/// subscription cache survives context switches.
private var accountProviders: [UUID: any SubscriptionProvider] = [:]
private weak var accountsManager: AccountsManager?
// MARK: - State
/// Whether a subscription operation is in progress.
private(set) var isLoading = false
/// The last error that occurred.
private(set) var lastError: Error?
// MARK: - Initialization
init(
dataManager: DataManager,
settingsManager: SettingsManager,
instancesManager: InstancesManager,
invidiousCredentialsManager: InvidiousCredentialsManager,
pipedCredentialsManager: PipedCredentialsManager,
invidiousAPI: InvidiousAPI,
pipedAPI: PipedAPI
) {
self.dataManager = dataManager
self.settingsManager = settingsManager
self.instancesManager = instancesManager
self.invidiousCredentialsManager = invidiousCredentialsManager
self.pipedCredentialsManager = pipedCredentialsManager
self.invidiousAPI = invidiousAPI
self.pipedAPI = pipedAPI
// Account providers are built lazily: which accounts exist is not known
// until AccountsManager has loaded and iCloud has delivered instances.
self.localProvider = LocalSubscriptionProvider(dataManager: dataManager)
}
func setAccountsManager(_ manager: AccountsManager) {
accountsManager = manager
// Providers cache another identity's subscriptions in memory, so they
// cannot outlive a change of Apple Account.
NotificationCenter.default.addObserver(
forName: .credentialIdentityScopeDidChange,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.accountProviders.removeAll() }
}
}
// MARK: - Current Provider
/// The provider for a context, built on first use.
///
/// Returns nil for an account whose instance or backend cannot supply
/// subscriptions — a caller that gets nil must surface that rather than
/// quietly falling back to local data the user did not select.
func provider(for context: DataContext) -> (any SubscriptionProvider)? {
switch context {
case .local:
return localProvider
case let .account(id):
if let existing = accountProviders[id] { return existing }
guard let accountsManager,
let account = accountsManager.account(id: id),
let instance = accountsManager.instance(for: account),
instance.type.accountCapabilities.supportsSubscriptions
else { return nil }
let provider: (any SubscriptionProvider)?
switch instance.type {
case .invidious:
provider = InvidiousSubscriptionProvider(
account: account,
invidiousAPI: invidiousAPI,
credentialsManager: invidiousCredentialsManager,
accountsManager: accountsManager,
settingsManager: settingsManager
)
case .piped:
provider = PipedSubscriptionProvider(
account: account,
pipedAPI: pipedAPI,
credentialsManager: pipedCredentialsManager,
accountsManager: accountsManager,
settingsManager: settingsManager
)
default:
provider = nil
}
accountProviders[id] = provider
return provider
}
}
/// Drops a removed account's provider so its cached subscriptions cannot be
/// served after the account is gone.
func discardProvider(for accountID: UUID) {
accountProviders[accountID] = nil
}
/// Returns the current subscription provider for the selected context.
var currentProvider: (any SubscriptionProvider)? {
provider(for: currentContext)
}
/// The selected context, or `.local` before accounts have loaded.
var currentContext: DataContext {
accountsManager?.currentContext ?? .local
}
/// Subscription count for the active account, when known without a network fetch.
/// Always available for local accounts; nil for server accounts until their cache is populated.
var cachedSubscriptionCount: Int? {
currentProvider?.cachedSubscriptionCount
}
// MARK: - Subscribe
/// Subscribes to a channel using the current provider.
/// - Parameter channel: The channel to subscribe to.
/// - Throws: SubscriptionProviderError if the operation fails.
func subscribe(to channel: Channel) async throws {
guard let provider = currentProvider else {
throw SubscriptionError.providerNotAvailable
}
isLoading = true
lastError = nil
do {
try await provider.subscribe(to: channel)
postChangeNotificationForServerAccount()
LoggingService.shared.info(
"Subscribed to \(channel.name) via \(provider.context)",
category: .general
)
} catch {
lastError = error
LoggingService.shared.error(
"Failed to subscribe to \(channel.name): \(error.localizedDescription)",
category: .general
)
throw error
}
isLoading = false
}
/// Subscribes to a channel from an Author.
/// - Parameters:
/// - author: The author/channel to subscribe to.
/// - source: The content source.
func subscribe(to author: Author, source: ContentSource) async throws {
let channel = Channel(
id: ChannelID(source: source, channelID: author.id),
name: author.name,
subscriberCount: author.subscriberCount,
thumbnailURL: author.thumbnailURL
)
try await subscribe(to: channel)
}
// MARK: - Unsubscribe
/// Unsubscribes from a channel using the current provider.
/// - Parameter channelID: The channel ID to unsubscribe from.
/// - Throws: SubscriptionProviderError if the operation fails.
func unsubscribe(from channelID: String) async throws {
guard let provider = currentProvider else {
throw SubscriptionError.providerNotAvailable
}
isLoading = true
lastError = nil
do {
try await provider.unsubscribe(from: channelID)
postChangeNotificationForServerAccount()
LoggingService.shared.info(
"Unsubscribed from \(channelID) via \(provider.context)",
category: .general
)
} catch {
lastError = error
LoggingService.shared.error(
"Failed to unsubscribe from \(channelID): \(error.localizedDescription)",
category: .general
)
throw error
}
isLoading = false
}
// MARK: - Query
/// Checks if subscribed to a channel.
/// - Parameter channelID: The channel ID to check.
/// - Returns: `true` if subscribed, `false` otherwise.
func isSubscribed(to channelID: String) async -> Bool {
guard let provider = currentProvider else {
return false
}
return await provider.isSubscribed(to: channelID)
}
/// Fetches all subscriptions from the current provider.
/// - Returns: Array of subscribed channels.
func fetchSubscriptions() async throws -> [Channel] {
guard let provider = currentProvider else {
throw SubscriptionError.providerNotAvailable
}
isLoading = true
lastError = nil
do {
let channels = try await provider.fetchSubscriptions()
isLoading = false
return channels
} catch {
lastError = error
isLoading = false
throw error
}
}
/// Synchronously fetches subscriptions for local provider.
/// Returns nil if the current provider is not local (requires async fetch).
func fetchSubscriptionsSync() -> [Channel]? {
guard currentContext.isLocal else {
return nil
}
return dataManager.subscriptions().map { subscription in
Channel(
id: ChannelID(source: subscription.contentSource, channelID: subscription.channelID),
name: subscription.name,
description: subscription.channelDescription,
subscriberCount: subscription.subscriberCount,
thumbnailURL: subscription.avatarURL,
bannerURL: subscription.bannerURL,
isVerified: subscription.isVerified
)
}
}
/// Refreshes the subscription cache from the current provider.
func refreshCache() async throws {
guard let provider = currentProvider else {
throw SubscriptionError.providerNotAvailable
}
isLoading = true
lastError = nil
do {
try await provider.refreshCache()
isLoading = false
} catch {
lastError = error
isLoading = false
throw error
}
}
// MARK: - Import
/// Imports parsed external subscriptions (YouTube CSV / OPML) into the active account.
/// Local accounts write to SwiftData; server accounts subscribe on the server,
/// one channel at a time. Channels that are already subscribed are skipped.
func importSubscriptions(_ channels: [(channelID: String, name: String)]) async -> (imported: Int, skipped: Int) {
guard !currentContext.isLocal else {
return dataManager.importSubscriptionsFromExternal(channels)
}
guard let provider = currentProvider else { return (0, channels.count) }
// Populate the cache so already-subscribed channels can be skipped
try? await provider.refreshCache()
var imported = 0
var skipped = 0
for entry in channels {
if await provider.isSubscribed(to: entry.channelID) {
skipped += 1
continue
}
do {
try await provider.subscribe(to: Channel(id: .global(entry.channelID), name: entry.name))
imported += 1
} catch {
skipped += 1
LoggingService.shared.error(
"Failed to import subscription \(entry.channelID): \(error.localizedDescription)",
category: .general
)
}
}
if imported > 0 {
postChangeNotificationForServerAccount()
}
return (imported, skipped)
}
// MARK: - Private Helpers
/// Posts a subscriptions-changed notification for server accounts.
/// Local accounts already post it from DataManager with a change payload.
private func postChangeNotificationForServerAccount() {
guard !currentContext.isLocal else { return }
NotificationCenter.default.post(name: .subscriptionsDidChange, object: nil)
}
}