Skip to content

Commit d06cb85

Browse files
authored
Merge pull request #1559 from TortugaPower/fix/crash-library-display
Fix crash and logout sequence
2 parents 27ff5b6 + e32784d commit d06cb85

11 files changed

Lines changed: 154 additions & 36 deletions

File tree

BookPlayer/Generated/AutoMockable.generated.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,6 +1687,32 @@ class SyncServiceProtocolMock: SyncServiceProtocol {
16871687
set(value) { underlyingDownloadErrorPublisher = value }
16881688
}
16891689
var underlyingDownloadErrorPublisher: PassthroughSubject<(String, Error), Never>!
1690+
//MARK: - updateSyncEnabled
1691+
1692+
var updateSyncEnabledCallsCount = 0
1693+
var updateSyncEnabledCalled: Bool {
1694+
return updateSyncEnabledCallsCount > 0
1695+
}
1696+
var updateSyncEnabledReceivedEnabled: Bool?
1697+
var updateSyncEnabledReceivedInvocations: [Bool] = []
1698+
var updateSyncEnabledClosure: ((Bool) -> Void)?
1699+
func updateSyncEnabled(_ enabled: Bool) {
1700+
updateSyncEnabledCallsCount += 1
1701+
updateSyncEnabledReceivedEnabled = enabled
1702+
updateSyncEnabledReceivedInvocations.append(enabled)
1703+
updateSyncEnabledClosure?(enabled)
1704+
}
1705+
//MARK: - logout
1706+
1707+
var logoutCallsCount = 0
1708+
var logoutCalled: Bool {
1709+
return logoutCallsCount > 0
1710+
}
1711+
var logoutClosure: (() async -> Void)?
1712+
func logout() async {
1713+
logoutCallsCount += 1
1714+
await logoutClosure?()
1715+
}
16901716
//MARK: - queuedJobsCount
16911717

16921718
var queuedJobsCountCallsCount = 0

BookPlayer/Library/ItemList/ItemListView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ struct ItemListView: View {
8383
preferencesService.register(folderUuid: uuid)
8484
}
8585
}
86-
.onReceive(preferencesService.preferencesChanged) { key in
86+
.onReceive(preferencesService.preferencesChanged.receive(on: DispatchQueue.main)) { key in
8787
// Server-driven sort change for the location currently on screen:
8888
// PreferencesSyncService has already rewritten orderRank in CoreData
8989
// via dispatchResort → sortContents. The cached `[SimpleLibraryItem]`

BookPlayer/Library/ItemList/Views/BookView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,12 @@ struct BookView: View {
8686
let audioMetadataService = AudioMetadataService()
8787
let libraryService = LibraryService()
8888
libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService)
89+
let accountService = AccountService()
90+
accountService.setup(dataManager: dataManager)
8991
syncService.setup(
9092
isActive: true,
9193
libraryService: libraryService,
94+
accountService: accountService,
9295
dataManager: dataManager
9396
)
9497

BookPlayer/Library/ItemList/Views/PercentageProgressView.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ struct PercentageProgressView: View {
2020
}
2121

2222
var body: some View {
23-
Group {
23+
// `progress` can arrive non-finite (e.g. an item whose duration is still
24+
// 0 makes `percentCompleted` 0/0 = NaN). `Int(NaN)` traps at runtime, so
25+
// collapse any non-finite value to 0 and clamp into the expected 0...1.
26+
let progress = self.progress.isFinite ? min(max(self.progress, 0), 1) : 0
27+
return Group {
2428
if progress == 0 {
2529
EmptyView()
2630
} else if progress == 1 {

BookPlayer/MainView.swift

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ struct MainView: View {
1919
@Environment(\.libraryService) private var libraryService
2020
@Environment(\.playerState) private var playerState
2121
@Environment(\.syncService) private var syncService
22-
@Environment(\.accountService) private var accountService
2322
@Environment(\.jellyfinService) private var jellyfinService
2423
@Environment(\.audiobookshelfService) private var audiobookshelfService
2524
@Environment(\.playbackService) private var playbackService
@@ -124,22 +123,14 @@ struct MainView: View {
124123
playerState.showPlayer = false
125124
}
126125
}
127-
.onReceive(
128-
NotificationCenter.default.publisher(for: .accountUpdate, object: nil)
129-
) { _ in
130-
guard accountService.hasAccount() else { return }
131-
132-
if accountService.hasSyncEnabled() {
133-
if !syncService.isActive {
134-
syncService.isActive = true
135-
Task {
136-
try? await listSyncRefreshService.syncList(at: nil)
137-
listState.reloadAll()
138-
}
139-
}
140-
} else if syncService.isActive {
141-
syncService.isActive = false
142-
syncService.cancelAllJobs()
126+
// SyncService owns the active/inactive decision (it observes account/subscription
127+
// changes itself). The view only reacts to sync becoming active to refresh the
128+
// library list — it no longer writes `isActive`.
129+
.onChange(of: syncService.isActive) { _, isActive in
130+
guard isActive else { return }
131+
Task {
132+
try? await listSyncRefreshService.syncList(at: nil)
133+
listState.reloadAll()
143134
}
144135
}
145136
}

BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,10 @@ struct ProfileSyncTasksSectionView: View {
9696
let audioMetadataService = AudioMetadataService()
9797
let libraryService = LibraryService()
9898
libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService)
99+
let accountService = AccountService()
100+
accountService.setup(dataManager: dataManager)
99101
let syncService = SyncService()
100-
syncService.setup(isActive: true, libraryService: libraryService, dataManager: dataManager)
102+
syncService.setup(isActive: true, libraryService: libraryService, accountService: accountService, dataManager: dataManager)
101103

102104
return syncService
103105
}()

BookPlayer/Utils/AppServices.swift

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ final class AppServices: BPLogger {
7676
let accountService = makeAccountService(dataManager: dataManager)
7777
let audioMetadataService = makeAudioMetadataService()
7878
let libraryService = makeLibraryService(dataManager: dataManager, audioMetadataService: audioMetadataService)
79-
let syncService = makeSyncService(accountService: accountService, libraryService: libraryService, dataManager: dataManager)
79+
let syncService = makeSyncService(
80+
accountService: accountService,
81+
libraryService: libraryService,
82+
dataManager: dataManager
83+
)
8084
let playbackService = makePlaybackService(libraryService: libraryService)
8185
let playerManager = PlayerManager(
8286
libraryService: libraryService,
@@ -214,15 +218,27 @@ final class AppServices: BPLogger {
214218
return AudioMetadataService()
215219
}
216220

217-
private func makeLibraryService(dataManager: DataManager, audioMetadataService: AudioMetadataServiceProtocol) -> LibraryService {
221+
private func makeLibraryService(
222+
dataManager: DataManager,
223+
audioMetadataService: AudioMetadataServiceProtocol
224+
) -> LibraryService {
218225
let service = LibraryService()
219226
service.setup(dataManager: dataManager, audioMetadataService: audioMetadataService)
220227
return service
221228
}
222229

223-
private func makeSyncService(accountService: AccountService, libraryService: LibraryService, dataManager: DataManager) -> SyncService {
230+
private func makeSyncService(
231+
accountService: AccountService,
232+
libraryService: LibraryService,
233+
dataManager: DataManager
234+
) -> SyncService {
224235
let service = SyncService()
225-
service.setup(isActive: accountService.hasSyncEnabled(), libraryService: libraryService, dataManager: dataManager)
236+
service.setup(
237+
isActive: accountService.hasSyncEnabled(),
238+
libraryService: libraryService,
239+
accountService: accountService,
240+
dataManager: dataManager
241+
)
226242
return service
227243
}
228244

BookPlayerWatch/CoreServices.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,6 @@ class CoreServices: ObservableObject {
4848

4949
func updateSyncEnabled(_ enabled: Bool) {
5050
hasSyncEnabled = enabled
51-
syncService.isActive = enabled
51+
syncService.updateSyncEnabled(enabled)
5252
}
5353
}

BookPlayerWatch/ExtensionDelegate.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, ObservableObject {
7575
syncService.setup(
7676
isActive: accountService.hasSyncEnabled(),
7777
libraryService: libraryService,
78+
accountService: accountService,
7879
dataManager: dataManager
7980
)
8081
let playbackService = PlaybackService()

Shared/Services/Sync/SyncService.swift

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,15 @@ public enum BPSyncError: Error {
2121

2222
/// sourcery: AutoMockable
2323
public 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
102109
public 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

491551
extension SyncService {

0 commit comments

Comments
 (0)