@@ -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)
@@ -183,11 +198,21 @@ public final class SyncService: SyncServiceProtocol, BPLogger {
183198
184199 func bindObservers( ) {
185200 NotificationCenter . default. publisher ( for: . logout, object: nil )
186- . sink ( receiveValue: { _ in
187- UserDefaults . standard. set (
188- false ,
189- forKey: Constants . UserDefaults. hasScheduledLibraryContents
190- )
201+ . sink ( receiveValue: { [ weak self] _ in
202+ /// Covers every logout path (iOS sign-out, account deletion, Watch), since
203+ /// they all post `.logout`. Tears down the queue, the flag, and `isActive`.
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 ( ) )
191216 } )
192217 . store ( in: & disposeBag)
193218
@@ -240,6 +265,11 @@ public final class SyncService: SyncServiceProtocol, BPLogger {
240265 ) async throws {
241266 Self . logger. trace ( " Fetching list of contents " )
242267
268+ /// Same gate as `syncLibraryContents()`: don't reconcile while a logout teardown
269+ /// is still clearing the queue, in case a fast re-login routed here before the
270+ /// `hasScheduledLibraryContents` flag was reset.
271+ await teardownTask? . value
272+
243273 let response = try await fetchContents ( at: relativePath)
244274
245275 try await processContentsResponse ( response, parentFolder: relativePath, canDelete: true )
@@ -255,6 +285,10 @@ public final class SyncService: SyncServiceProtocol, BPLogger {
255285 throw BookPlayerError . networkError ( " Sync is not enabled " )
256286 }
257287
288+ /// Wait for any in-flight logout teardown to finish before scheduling, so a fast
289+ /// logout→login can't have a late `resetAllJobs()` wipe freshly-scheduled jobs.
290+ await teardownTask? . value
291+
258292 if await queuedJobsCount ( ) > 0 {
259293 Self . logger. trace ( " Clearing orphaned tasks before initial library sync " )
260294 await resetAllJobs ( )
@@ -486,6 +520,32 @@ public final class SyncService: SyncServiceProtocol, BPLogger {
486520 public func resetAllJobs( ) async {
487521 await jobManager. resetAllJobs ( )
488522 }
523+
524+ /// Enables or disables syncing in response to account/subscription state. Disabling
525+ /// also cancels any queued jobs. Idempotent — a no-op when the state is unchanged.
526+ /// `isActive` is mutated on the main actor since it's an `@Observable` value read by
527+ /// SwiftUI; the actual sync-content refresh is triggered by observers of `isActive`.
528+ public func updateSyncEnabled( _ enabled: Bool ) {
529+ Task { @MainActor in
530+ guard self . isActive != enabled else { return }
531+ self . isActive = enabled
532+ if !enabled {
533+ self . cancelAllJobs ( )
534+ }
535+ }
536+ }
537+
538+ /// Tears down sync state when the account logs out (or is deleted): stops syncing,
539+ /// clears the persisted task queue, and resets the "scheduled library contents" flag
540+ /// so the next login runs a fresh initial sync from an empty queue. Idempotent.
541+ public func logout( ) async {
542+ await MainActor . run { self . isActive = false }
543+ UserDefaults . standard. set (
544+ false ,
545+ forKey: Constants . UserDefaults. hasScheduledLibraryContents
546+ )
547+ await resetAllJobs ( )
548+ }
489549}
490550
491551extension SyncService {
0 commit comments