diff --git a/App/AppModel.swift b/App/AppModel.swift index 43914cb..4665ee6 100644 --- a/App/AppModel.swift +++ b/App/AppModel.swift @@ -1,3 +1,4 @@ +import BackgroundTasks import ClementineKit import SwiftUI @@ -299,6 +300,7 @@ final class AppModel { if !session.isPlayingHere { session.suspend() } + scheduleBackgroundSync() case .active: session.resume() default: @@ -330,6 +332,70 @@ final class AppModel { } } +// MARK: Background sync + +extension AppModel { + /// The background task that updates what's kept on the phone: one of the + /// BGTaskSchedulerPermittedIdentifiers in Info.plist. + nonisolated static let backgroundSyncID = (Bundle.main.bundleIdentifier ?? "org.clementine-player.remote") + ".sync" + + /// Lets iOS start background syncs. iOS needs this before the app finishes launching. + func registerBackgroundSync() { + guard TestingFeatures.enabled else { return } + BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.backgroundSyncID, using: .main) { [weak self] task in + // On the main queue, as asked, so only the main actor uses it. + nonisolated(unsafe) let task = task + MainActor.assumeIsolated { + if let self { + self.runBackgroundSync(task) + } else { + task.setTaskCompleted(success: false) + } + } + } + } + + /// Asks iOS to start a background sync, while the phone is on power, no sooner than + /// [BackgroundSync.interval] from now; or not to, with Update kept music in the background off. + func scheduleBackgroundSync() { + guard TestingFeatures.enabled else { return } + guard settings.backgroundSync else { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.backgroundSyncID) + return + } + let request = BGProcessingTaskRequest(identifier: Self.backgroundSyncID) + request.requiresExternalPower = true + request.requiresNetworkConnectivity = true + request.earliestBeginDate = Date(timeIntervalSinceNow: BackgroundSync.interval) + try? BGTaskScheduler.shared.submit(request) + } + + /// Syncs for [task], over a network Download over allows, and asks for the next one. + private func runBackgroundSync(_ task: BGTask) { + scheduleBackgroundSync() + let run = Task { + guard let path = await NetworkMonitor.currentPath(), + BackgroundSync.allows( + settings.downloadNetwork, expensive: path.expensive, constrained: path.constrained) + else { return } + await BackgroundSync.run(session: session, syncer: syncer, settings: settings) { + UIApplication.shared.applicationState == .background + } + } + // iOS ends the task: what's downloaded so far stays, and the next sync carries on. + task.expirationHandler = { [syncer] in + Task { @MainActor in + syncer.stop() + run.cancel() + } + } + Task { + await run.value + task.setTaskCompleted(success: true) + } + } +} + /// Why the app couldn't connect, or stopped being connected. enum ConnectProblem: Equatable, Identifiable { /// Clementine wants an auth code, or a different one. diff --git a/App/ClementineRemoteApp.swift b/App/ClementineRemoteApp.swift index 618712d..8412e17 100644 --- a/App/ClementineRemoteApp.swift +++ b/App/ClementineRemoteApp.swift @@ -9,6 +9,7 @@ struct ClementineRemoteApp: App { init() { let model = AppModel() AppModel.shared = model + model.registerBackgroundSync() _model = State(initialValue: model) } diff --git a/App/Info.plist b/App/Info.plist index 1d9c4f6..ce929ba 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -4,6 +4,10 @@ AppGroup $(APP_GROUP) + BGTaskSchedulerPermittedIdentifiers + + $(PRODUCT_BUNDLE_IDENTIFIER).sync + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -44,6 +48,7 @@ UIBackgroundModes audio + processing UIFileSharingEnabled diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 642bbf5..813038a 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -26170,6 +26170,9 @@ "Update it" : { "comment" : "Heading in the Keep on this phone sheet, over how often what's kept is updated from Clementine." }, + "Update kept music in the background" : { + "comment" : "A switch, under Download over: lets the phone update what's kept on it from Clementine by itself." + }, "Update library" : { "localizations" : { "el" : { @@ -26742,6 +26745,9 @@ } } }, + "While the phone is charging, on a network Download over allows, music set to Every time I connect is updated from the last Clementine you connected to, if it's on." : { + "comment" : "Under the switch above. \"Every time I connect\" is the choice in the Keep on this phone sheet." + }, "Wi-Fi and mobile data, not in Low Data Mode" : { "comment" : "A choice under Download over: Wi-Fi, and mobile data unless Low Data Mode is on.", "localizations" : { diff --git a/App/Settings/SettingsView.swift b/App/Settings/SettingsView.swift index cdb2dcd..43e8081 100644 --- a/App/Settings/SettingsView.swift +++ b/App/Settings/SettingsView.swift @@ -11,6 +11,7 @@ struct SettingsView: View { @AppStorage(SettingKey.librarySorting) private var sorting = LibrarySorting.ascending.rawValue @AppStorage(SettingKey.wifiOnly) private var wifiOnly = false @AppStorage(SettingKey.downloadNetwork) private var downloadNetwork: String? + @AppStorage(SettingKey.backgroundSync) private var backgroundSync = true @AppStorage(SettingKey.replaceExisting) private var replaceExisting = false @AppStorage(SettingKey.playlistFolder) private var playlistFolder = false @AppStorage(SettingKey.artistFolder) private var artistFolder = true @@ -77,6 +78,16 @@ struct SettingsView: View { "Download over", comment: "A setting: which networks songs download over, for downloads and for what's kept on this phone. Its choices follow.") } + if TestingFeatures.enabled { + Toggle(isOn: $backgroundSync) { + Text( + "Update kept music in the background", + comment: "A switch, under Download over: lets the phone update what's kept on it from Clementine by itself.") + Text( + "While the phone is charging, on a network Download over allows, music set to Every time I connect is updated from the last Clementine you connected to, if it's on.", + comment: "Under the switch above. \"Every time I connect\" is the choice in the Keep on this phone sheet.") + } + } Toggle(isOn: $replaceExisting) { Text("Replace existing files") Text("Downloads songs again when they differ from those on this phone.") diff --git a/App/Support/NetworkMonitor.swift b/App/Support/NetworkMonitor.swift index ebba5a3..c0dee44 100644 --- a/App/Support/NetworkMonitor.swift +++ b/App/Support/NetworkMonitor.swift @@ -44,6 +44,15 @@ final class NetworkMonitor { network.allows(expensive: isExpensive, constrained: isConstrained) } + /// The network the phone is on now, as iOS first says: whether it's [expensive] and + /// [constrained]. Nil if iOS doesn't say, as when the task is cancelled. + nonisolated static func currentPath() async -> (expensive: Bool, constrained: Bool)? { + for await path in NWPathMonitor() { + return (expensive: path.isExpensive, constrained: path.isConstrained) + } + return nil + } + /// What's wrong with the network for reaching Clementine, if anything. var problem: Problem? { if !isOnWiFi { diff --git a/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift b/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift index df4f8ce..161e998 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift @@ -127,9 +127,11 @@ public final class RemoteSession { // MARK: Connecting /// Connects to Clementine at [endpoint], called [name] (its address if nil). With [renderer], - /// this device offers itself as somewhere Clementine can play. + /// this device offers itself as somewhere Clementine can play. Without [sendPlaylistSongs], as + /// for a background sync, Clementine sends the playlists but not their songs. public func connect( - to endpoint: Endpoint, name: String? = nil, authCode: Int32, renderer: RendererCapabilities? = nil + to endpoint: Endpoint, name: String? = nil, authCode: Int32, renderer: RendererCapabilities? = nil, + sendPlaylistSongs: Bool = true ) { drop() reset() @@ -141,7 +143,7 @@ public final class RemoteSession { connectedSince = nil trafficAtConnect = Traffic.byteCounts status = .connecting - open(sendPlaylistSongs: true) + open(sendPlaylistSongs: sendPlaylistSongs, renderer: renderer) } /// Disconnects, and forgets Clementine's state. @@ -161,14 +163,16 @@ public final class RemoteSession { status = .suspended } - /// Reconnects after [suspend], then refreshes the playlists. - public func resume() { + /// Reconnects after [suspend], then refreshes the playlists. Without [offeringRenderer], as + /// for a background sync, this connection doesn't offer the phone for Clementine to play on, + /// so nothing can start playing on it unasked; the next connection offers it again. + public func resume(offeringRenderer: Bool = true) { guard status == .suspended, endpoint != nil else { return } status = .reconnecting requestedPlaylists = [] playlistsLoading = nil refreshPlaylistsWhenReady = true - open(sendPlaylistSongs: false) + open(sendPlaylistSongs: false, renderer: offeringRenderer ? renderer : nil) } /// Everything the app has sent and received since connecting: to Clementine, and audio streamed @@ -192,7 +196,7 @@ public final class RemoteSession { observers[id] = nil } - private func open(sendPlaylistSongs: Bool) { + private func open(sendPlaylistSongs: Bool, renderer: RendererCapabilities?) { guard let endpoint else { return } let connection = ClementineConnection(endpoint: endpoint, authCode: authCode, renderer: renderer) self.connection = connection diff --git a/Packages/ClementineKit/Sources/ClementineKit/Settings/Settings.swift b/Packages/ClementineKit/Sources/ClementineKit/Settings/Settings.swift index de39004..16c2fb1 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Settings/Settings.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Settings/Settings.swift @@ -21,6 +21,8 @@ public enum SettingKey { public static let playlistFolder = "pref_dl_pl_save_own_dir" public static let artistFolder = "pref_dl_artist_dir" public static let albumFolder = "pref_dl_album_dir" + /// Whether what's kept on the phone is updated in the background (BackgroundSync). + public static let backgroundSync = "pref_background_sync" // Connection public static let autoConnect = "pref_autoconnect" public static let port = "pref_port" @@ -52,6 +54,7 @@ public struct Settings: Sendable { SettingKey.playlistFolder: false, SettingKey.artistFolder: true, SettingKey.albumFolder: true, + SettingKey.backgroundSync: true, SettingKey.autoConnect: true, SettingKey.port: Int(RemoteProtocol.defaultPort), SettingKey.keepScreenOn: false, @@ -90,6 +93,7 @@ public struct Settings: Sendable { public var playlistFolder: Bool { store.bool(forKey: SettingKey.playlistFolder) } public var artistFolder: Bool { store.bool(forKey: SettingKey.artistFolder) } public var albumFolder: Bool { store.bool(forKey: SettingKey.albumFolder) } + public var backgroundSync: Bool { store.bool(forKey: SettingKey.backgroundSync) } public var autoConnect: Bool { store.bool(forKey: SettingKey.autoConnect) } public var port: UInt16 { UInt16(exactly: store.integer(forKey: SettingKey.port)) ?? RemoteProtocol.defaultPort diff --git a/Packages/ClementineKit/Sources/ClementineKit/Sync/BackgroundSync.swift b/Packages/ClementineKit/Sources/ClementineKit/Sync/BackgroundSync.swift new file mode 100644 index 0000000..3c20eb6 --- /dev/null +++ b/Packages/ClementineKit/Sources/ClementineKit/Sync/BackgroundSync.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Updates what's kept on the phone in the background, with Update kept music in the background +/// on. The app runs [run] from a background processing task about every 6 hours, which iOS starts +/// while the phone is on power, usually overnight. A run connects to the last Clementine that +/// synced, at its last address, syncs its rules and disconnects. +@MainActor +public enum BackgroundSync { + /// How soon after one run the next may start. + public static let interval: TimeInterval = 6 * 60 * 60 + /// How long a run waits for Clementine to say what it is. + static let connectTimeout: Duration = .seconds(15) + + /// Whether a run may use a network that's [expensive], such as mobile data, and + /// [constrained], in Low Data Mode: what [network], chosen under Download over, allows, but + /// never in Low Data Mode, which asks apps to hold back work that can wait. + public nonisolated static func allows(_ network: DownloadNetwork, expensive: Bool, constrained: Bool) -> Bool { + !constrained && network.allows(expensive: expensive, constrained: constrained) + } + + /// Whether any of [rules] would sync: those updated each time, and those yet to get their + /// songs. + static func hasRulesToSync(_ rules: [SyncRule]) -> Bool { + rules.contains { $0.refreshPolicy != .never || $0.lastSynced == nil } + } + + /// Syncs in the background with [session], the app's, through [syncer], and returns whether it + /// synced. If the app is connected, its connection syncs. Otherwise it connects to the last + /// Clementine that synced, or reconnects a session suspended in the background, syncs if the + /// Clementine that answers is the one the rules are for, and disconnects again, unless + /// [inBackground] says the user has opened the app meanwhile. Cancelling it, as when iOS ends + /// the run, gives up waiting; [Syncer.stop] stops the sync itself. + @discardableResult + public static func run( + session: RemoteSession, syncer: Syncer, settings: Settings, inBackground: () -> Bool = { true } + ) async -> Bool { + guard TestingFeatures.enabled, settings.backgroundSync else { return false } + switch session.status { + case .connected: + syncer.syncNow() + await syncer.finish() + return true + case .connecting, .downloadingData, .reconnecting: + // Its connection syncs once Clementine says what it is. + return false + case .disconnected, .suspended: + break + } + + let serverID = settings.syncServerID + guard !serverID.isEmpty, !settings.lastHost.isEmpty, let store = syncer.store, + let rules = try? await store.rules(serverID: serverID), hasRulesToSync(rules) + else { return false } + + syncer.onlyServerID = serverID + defer { syncer.onlyServerID = nil } + // The syncer starts syncing when Clementine says what it is, if it's the one. A suspended + // session still has what the last Clementine said, so this waits for it to say it again. + let heard = Heard() + let observer = session.addObserver { message in + if message.type == .info { + heard.info = true + } + } + defer { session.removeObserver(observer) } + let wasSuspended = session.status == .suspended + if wasSuspended { + // Not offering the phone to play on: a Clementine could start playing on it unasked. + session.resume(offeringRenderer: false) + } else { + session.connect( + to: Endpoint(host: settings.lastHost, port: settings.port), authCode: settings.lastAuthCode, + sendPlaylistSongs: false) + } + + let deadline = ContinuousClock.now + connectTimeout + while !heard.info, session.status != .disconnected, ContinuousClock.now < deadline, !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(100)) + } + let synced = heard.info && session.syncing.available && session.syncing.serverID == serverID + if synced { + await syncer.finish() + } + + if inBackground() { + if wasSuspended { + session.suspend() + } else { + session.disconnect() + } + } + return synced + } +} + +/// Whether Clementine has said what it is. +@MainActor +private final class Heard { + var info = false +} diff --git a/Packages/ClementineKit/Sources/ClementineKit/Sync/Syncer.swift b/Packages/ClementineKit/Sources/ClementineKit/Sync/Syncer.swift index 8b62676..504abab 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Sync/Syncer.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Sync/Syncer.swift @@ -39,7 +39,8 @@ public struct KeptRule: Sendable, Equatable, Identifiable { /// Syncs the rules for the connected Clementine while the app is connected to it: each time it /// connects, and when asked ([syncNow]), such as after a rule is made ([keep]) or removed -/// ([remove]), over the networks Download over allows. Background sync comes later. +/// ([remove]), over the networks Download over allows. [BackgroundSync] connects in the +/// background for it. @MainActor @Observable public final class Syncer { @@ -68,6 +69,9 @@ public final class Syncer { @ObservationIgnored private let timeout: Duration /// Whether the phone's network is one that Download over allows. @ObservationIgnored public var networkAllows: @MainActor (DownloadNetwork) -> Bool = { _ in true } + /// While set, as during a background sync, only this Clementine syncs: another that connects + /// is left alone, and doesn't become the one the rules shown are for. + @ObservationIgnored public var onlyServerID: String? @ObservationIgnored private var task: Task? @ObservationIgnored private var again = false @@ -89,6 +93,7 @@ public final class Syncer { manifests.handle(message) // Clementine says what it can do each time the app connects. guard message.type == .info else { return } + if let onlyServerID, session.syncing.serverID != onlyServerID { return } if session.syncing.supported { settings.syncServerID = session.syncing.serverID refresh() @@ -175,6 +180,13 @@ public final class Syncer { await task?.value } + /// Stops the sync that's running, as when iOS ends a background sync. The songs downloaded + /// so far stay, and the next sync carries on. + public func stop() { + again = false + task?.cancel() + } + private func runOnce() async { let syncing = session.syncing guard syncing.available else { return } @@ -197,6 +209,8 @@ public final class Syncer { timeout: timeout) do { status.results = try await engine.sync(connection).rules + } catch is CancellationError { + // Stopped: what finished stays, and the next sync carries on. } catch { // The store failing, say: tried again next time. status.unavailable = true diff --git a/Packages/ClementineKit/Tests/ClementineKitTests/BackgroundSyncTests.swift b/Packages/ClementineKit/Tests/ClementineKitTests/BackgroundSyncTests.swift new file mode 100644 index 0000000..201e805 --- /dev/null +++ b/Packages/ClementineKit/Tests/ClementineKitTests/BackgroundSyncTests.swift @@ -0,0 +1,168 @@ +import Foundation +import Testing +@testable import ClementineKit + +/// Updating what's kept on the phone in the background. +@MainActor +@Suite(.timeLimit(.minutes(1))) +struct BackgroundSyncTests { + /// Settings for a phone that last synced with SyncerTests.server, at [endpoint]. + static func settings(_ endpoint: Endpoint) -> Settings { + let settings = Settings(suiteName: "BackgroundSyncTests-\(UUID().uuidString)") + settings.syncServerID = SyncerTests.server + settings.lastHost = endpoint.host + settings.store.set(Int(endpoint.port), forKey: SettingKey.port) + return settings + } + + /// Clementine that says it's [serverID], and answers each manifest request with no songs. + static func clementine(serverID: String = SyncerTests.server) async throws -> FakeClementine { + let clementine = try await FakeClementine() + clementine.respond { message, client in + switch message.type { + case .connect: + for reply in [SyncerTests.syncingInfo(serverID: serverID)] + FakeClementine.firstData.dropFirst() { + try? await client.send(reply) + } + case .requestSyncManifest: + try? await client.send(SyncerTests.manifest(message.requestSyncManifest.sourceID)) + default: + break + } + } + return clementine + } + + static func playlistRule(_ policy: RefreshPolicy = .onConnect) -> SyncRule { + SyncRule(serverID: SyncerTests.server, sourceID: "playlist/1", title: "Playlist 1", kind: .playlist, refreshPolicy: policy) + } + + @Test func neverRunsInLowDataMode() { + #expect(BackgroundSync.allows(.wifiOnly, expensive: false, constrained: false)) + #expect(!BackgroundSync.allows(.wifiOnly, expensive: true, constrained: false)) + #expect(BackgroundSync.allows(.any, expensive: true, constrained: false)) + #expect(!BackgroundSync.allows(.any, expensive: false, constrained: true)) + #expect(!BackgroundSync.allows(.wifiOnly, expensive: false, constrained: true)) + } + + @Test func syncsRulesUpdatedEachTimeAndThoseYetToGetTheirSongs() { + var album = Self.playlistRule(.never) + #expect(BackgroundSync.hasRulesToSync([album])) + album.lastSynced = .now + #expect(!BackgroundSync.hasRulesToSync([album])) + #expect(BackgroundSync.hasRulesToSync([album, Self.playlistRule()])) + #expect(!BackgroundSync.hasRulesToSync([])) + } + + @Test func connectsSyncsAndDisconnects() async throws { + let clementine = try await Self.clementine() + defer { clementine.stop() } + let store = try SyncStore(url: nil) + let rule = try await store.addRule(Self.playlistRule()) + let settings = Self.settings(clementine.endpoint) + let session = RemoteSession() + let syncer = Syncer(session: session, store: store, folder: FileManager.default.temporaryDirectory, settings: settings) + + #expect(await BackgroundSync.run(session: session, syncer: syncer, settings: settings)) + #expect(syncer.status.results[rule] == .synced(songs: 0, downloaded: 0, skipped: 0)) + #expect(session.status == .disconnected) + #expect(syncer.onlyServerID == nil) + // Only what syncing needs: not every playlist's songs. + let connect = try #require(clementine.received.first { $0.type == .connect }) + #expect(!connect.requestConnect.sendPlaylistSongs) + } + + @Test func leavesAnotherClementineAtTheSameAddressAlone() async throws { + let clementine = try await Self.clementine(serverID: "another") + defer { clementine.stop() } + let store = try SyncStore(url: nil) + _ = try await store.addRule(Self.playlistRule()) + let settings = Self.settings(clementine.endpoint) + let session = RemoteSession() + let syncer = Syncer(session: session, store: store, folder: FileManager.default.temporaryDirectory, settings: settings) + + #expect(!(await BackgroundSync.run(session: session, syncer: syncer, settings: settings))) + #expect(!clementine.received.contains { $0.type == .requestSyncManifest }) + #expect(settings.syncServerID == SyncerTests.server) + #expect(session.status == .disconnected) + } + + @Test func givesUpWhenClementineIsntThere() async throws { + let clementine = try await FakeClementine() + let endpoint = clementine.endpoint + clementine.stop() + let store = try SyncStore(url: nil) + _ = try await store.addRule(Self.playlistRule()) + let settings = Self.settings(endpoint) + let session = RemoteSession() + let syncer = Syncer(session: session, store: store, folder: FileManager.default.temporaryDirectory, settings: settings) + + #expect(!(await BackgroundSync.run(session: session, syncer: syncer, settings: settings))) + #expect(session.status == .disconnected) + } + + @Test func doesntConnectWithNothingToSyncOrWithTheSettingOff() async throws { + let clementine = try await Self.clementine() + defer { clementine.stop() } + let store = try SyncStore(url: nil) + var album = Self.playlistRule(.never) + album.lastSynced = .now + _ = try await store.addRule(album) + let settings = Self.settings(clementine.endpoint) + let session = RemoteSession() + let syncer = Syncer(session: session, store: store, folder: FileManager.default.temporaryDirectory, settings: settings) + + #expect(!(await BackgroundSync.run(session: session, syncer: syncer, settings: settings))) + var playlist = Self.playlistRule() + playlist.sourceID = "playlist/2" + _ = try await store.addRule(playlist) + settings.store.set(false, forKey: SettingKey.backgroundSync) + #expect(!(await BackgroundSync.run(session: session, syncer: syncer, settings: settings))) + #expect(session.status == .disconnected) + #expect(clementine.clientCount == 0) + } + + @Test func syncsOverTheAppsConnection() async throws { + let clementine = try await Self.clementine() + defer { clementine.stop() } + let store = try SyncStore(url: nil) + let rule = try await store.addRule(Self.playlistRule()) + let settings = Self.settings(clementine.endpoint) + let session = RemoteSession() + let syncer = Syncer(session: session, store: store, folder: FileManager.default.temporaryDirectory, settings: settings) + session.connect(to: clementine.endpoint, authCode: 0) + try await eventually { session.status == .connected } + await syncer.finish() + + #expect(await BackgroundSync.run(session: session, syncer: syncer, settings: settings)) + #expect(syncer.status.results[rule] != nil) + // The app's connection stays. + #expect(session.status == .connected) + session.disconnect() + } + + @Test func reconnectsASuspendedSessionWithoutOfferingThePhoneToPlayOn() async throws { + let clementine = try await Self.clementine() + defer { clementine.stop() } + let store = try SyncStore(url: nil) + let rule = try await store.addRule(Self.playlistRule()) + let settings = Self.settings(clementine.endpoint) + let session = RemoteSession() + let syncer = Syncer(session: session, store: store, folder: FileManager.default.temporaryDirectory, settings: settings) + session.connect( + to: clementine.endpoint, authCode: 0, renderer: Renderer.capabilities(id: "phone", name: "iPhone")) + try await eventually { session.status == .connected } + await syncer.finish() + session.suspend() + + #expect(await BackgroundSync.run(session: session, syncer: syncer, settings: settings)) + #expect(syncer.status.results[rule] != nil) + #expect(session.status == .suspended) + let connects = clementine.received.filter { $0.type == .connect } + #expect(connects.count == 2) + #expect(connects.first?.requestConnect.hasRenderer == true) + #expect(connects.last?.requestConnect.hasRenderer == false) + // The app offers it again when it next resumes. + #expect(session.renderer != nil) + } +} diff --git a/project.yml b/project.yml index ddc293b..12d4262 100644 --- a/project.yml +++ b/project.yml @@ -72,9 +72,13 @@ targets: LSSupportsOpeningDocumentsInPlace: true ITSAppUsesNonExemptEncryption: false # Remote streaming: Clementine's music keeps playing in the background, fetched over plain - # HTTP from Clementine's media server on the local network. + # HTTP from Clementine's media server on the local network. Processing: what's kept on the + # phone is updated from Clementine in a background task (BackgroundSync). UIBackgroundModes: - audio + - processing + BGTaskSchedulerPermittedIdentifiers: + - $(PRODUCT_BUNDLE_IDENTIFIER).sync NSAppTransportSecurity: NSAllowsLocalNetworking: true NSAllowsArbitraryLoadsForMedia: true