Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions App/AppModel.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import BackgroundTasks
import ClementineKit
import SwiftUI

Expand Down Expand Up @@ -299,6 +300,7 @@ final class AppModel {
if !session.isPlayingHere {
session.suspend()
}
scheduleBackgroundSync()
case .active:
session.resume()
default:
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions App/ClementineRemoteApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct ClementineRemoteApp: App {
init() {
let model = AppModel()
AppModel.shared = model
model.registerBackgroundSync()
_model = State(initialValue: model)
}

Expand Down
5 changes: 5 additions & 0 deletions App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<dict>
<key>AppGroup</key>
<string>$(APP_GROUP)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).sync</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
Expand Down Expand Up @@ -44,6 +48,7 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>processing</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
Expand Down
6 changes: 6 additions & 0 deletions App/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down
11 changes: 11 additions & 0 deletions App/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.")
Expand Down
9 changes: 9 additions & 0 deletions App/Support/NetworkMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions Packages/ClementineKit/Sources/ClementineKit/Sync/BackgroundSync.swift
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading