@@ -21,8 +21,15 @@ public enum BPSyncError: Error {
2121
2222/// sourcery: AutoMockable
2323public protocol SyncServiceProtocol {
24- /// Flag to check if it can sync or not
25- var isActive : Bool { get set }
24+ /// Flag to check if it can sync or not. Owned by `SyncService`; mutate it through
25+ /// `updateSyncEnabled(_:)` / `logout()` rather than assigning directly.
26+ var isActive : Bool { get }
27+ /// Enable or disable syncing in response to account/subscription state. Disabling
28+ /// also cancels any queued jobs.
29+ func updateSyncEnabled( _ enabled: Bool )
30+ /// Tear down sync state on logout/account deletion (stop syncing, clear the queue
31+ /// and the scheduled-contents flag).
32+ func logout( ) async
2633 /// Completion publisher for ongoing-download tasks
2734 var downloadCompletedPublisher : PassthroughSubject < ( String , String , String ? ) , Never > { get }
2835 /// Progress publisher for ongoing-download tasks
@@ -101,10 +108,16 @@ public protocol SyncServiceProtocol {
101108@Observable
102109public final class SyncService : SyncServiceProtocol , BPLogger {
103110 private var libraryService : LibrarySyncProtocol !
111+ private var accountService : AccountServiceProtocol !
104112 private var tasksCountService : SyncTasksCountService !
105113 var jobManager : JobSchedulerProtocol !
106114 private var client : NetworkClientProtocol !
107- public var isActive : Bool = false
115+ /// Owned here: writes go through `updateSyncEnabled(_:)` / `logout()`, mutated on the
116+ /// main actor. External callers read only.
117+ public private( set) var isActive : Bool = false
118+ /// In-flight logout teardown, awaited before an initial library sync re-schedules,
119+ /// so a re-login can't begin scheduling until the queue reset has finished.
120+ private var teardownTask : Task < Void , Never > ?
108121
109122 /// Dictionary holding the initiating item relative path as key and the download tasks as value
110123 private var downloadTasksDictionary = [ String: [ URLSessionTask] ] ( )
@@ -132,11 +145,13 @@ public final class SyncService: SyncServiceProtocol, BPLogger {
132145 public func setup(
133146 isActive: Bool ,
134147 libraryService: LibrarySyncProtocol ,
148+ accountService: AccountServiceProtocol ,
135149 client: NetworkClientProtocol = NetworkClient ( ) ,
136150 dataManager: DataManager
137151 ) {
138152 self . isActive = isActive
139153 self . libraryService = libraryService
154+ self . accountService = accountService
140155 let tasksDataManager = TasksDataManager ( )
141156 self . tasksCountService = SyncTasksCountService ( tasksDataManager: tasksDataManager)
142157 self . jobManager = SyncJobScheduler ( tasksDataManager: tasksDataManager, dataManager: dataManager)
@@ -186,7 +201,18 @@ public final class SyncService: SyncServiceProtocol, BPLogger {
186201 . sink ( receiveValue: { [ weak self] _ in
187202 /// Covers every logout path (iOS sign-out, account deletion, Watch), since
188203 /// they all post `.logout`. Tears down the queue, the flag, and `isActive`.
189- Task { await self ? . logout ( ) }
204+ /// Held in `teardownTask` so a re-login's initial sync awaits it first.
205+ self ? . teardownTask = Task { await self ? . logout ( ) }
206+ } )
207+ . store ( in: & disposeBag)
208+
209+ /// Sync ownership lives here, not in the views: any account/subscription change
210+ /// re-derives whether syncing should be active. All logout paths post `.logout`
211+ /// (handled above), so account-present-but-sync-disabled is the case we map here.
212+ NotificationCenter . default. publisher ( for: . accountUpdate, object: nil )
213+ . sink ( receiveValue: { [ weak self] _ in
214+ guard let self, self . accountService. hasAccount ( ) else { return }
215+ self . updateSyncEnabled ( self . accountService. hasSyncEnabled ( ) )
190216 } )
191217 . store ( in: & disposeBag)
192218
@@ -254,6 +280,10 @@ public final class SyncService: SyncServiceProtocol, BPLogger {
254280 throw BookPlayerError . networkError ( " Sync is not enabled " )
255281 }
256282
283+ /// Wait for any in-flight logout teardown to finish before scheduling, so a fast
284+ /// logout→login can't have a late `resetAllJobs()` wipe freshly-scheduled jobs.
285+ await teardownTask? . value
286+
257287 if await queuedJobsCount ( ) > 0 {
258288 Self . logger. trace ( " Clearing orphaned tasks before initial library sync " )
259289 await resetAllJobs ( )
@@ -486,6 +516,20 @@ public final class SyncService: SyncServiceProtocol, BPLogger {
486516 await jobManager. resetAllJobs ( )
487517 }
488518
519+ /// Enables or disables syncing in response to account/subscription state. Disabling
520+ /// also cancels any queued jobs. Idempotent — a no-op when the state is unchanged.
521+ /// `isActive` is mutated on the main actor since it's an `@Observable` value read by
522+ /// SwiftUI; the actual sync-content refresh is triggered by observers of `isActive`.
523+ public func updateSyncEnabled( _ enabled: Bool ) {
524+ Task { @MainActor in
525+ guard self . isActive != enabled else { return }
526+ self . isActive = enabled
527+ if !enabled {
528+ self . cancelAllJobs ( )
529+ }
530+ }
531+ }
532+
489533 /// Tears down sync state when the account logs out (or is deleted): stops syncing,
490534 /// clears the persisted task queue, and resets the "scheduled library contents" flag
491535 /// so the next login runs a fresh initial sync from an empty queue. Idempotent.
0 commit comments