Commit 25505e1c authored by Kourser's avatar Kourser
Browse files

fix(ios): pause for calls and unplugged headphones, download in the background

Two bugs rather than missing features, both found while auditing what the
app does on iOS beyond playing audio.

Nothing observed the audio session. Downloaded episodes played with the
equalizer or silence skipping go through AVAudioEngine, which does not
pause by itself: taking AirPods out carried on through the loudspeaker,
and after a call the engine sat stopped while the app still believed it
was playing. AudioInterruptionHandler now pauses on an interruption and
resumes afterwards only if playback was running and the system says
resuming is appropriate, and pauses when the output device goes away.
The engine also remembers its last position: once the system stops it,
the player node's render clock is gone and restarting alone would play
silence, so it reschedules from where it was.

Downloads went through URLSession.shared, which iOS suspends about thirty
seconds after the app leaves the foreground — so automatic downloads only
ever finished while the app stayed open. On devices they now run in a
background session the system carries on even after the app is
terminated; the delegate moves the file and records it in the store
itself, since callbacks can arrive during a background relaunch before
any view exists.

macOS keeps a default session: it never suspends the app, so the problem
does not exist there. The Simulator does too, because its transfer daemon
fails every background task with NSURLErrorUnknown (-1) — the same
delegate code on a default session downloads a 21.9 MB episode byte for
byte, which is how it was verified. The background path itself, like the
interruption handling, needs a device: the Simulator can neither place a
call nor take AirPods out.

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 2023541e
Loading
Loading
Loading
Loading
+16 −2
Original line number Diff line number Diff line
@@ -38,6 +38,10 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {
    private var finished = false
    private var generation = 0
    private var ticker: Timer?
    /// Last position reported to the delegate. Once the system stops the
    /// engine — a call, Siri, a change of audio route — the player node's
    /// render clock is gone, and with it any way to work out where playback was.
    private var lastReportedTime: TimeInterval = 0

    public init() {
        engine.attach(player)
@@ -58,6 +62,7 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {

    public func load(url: URL, startAt seconds: TimeInterval) {
        teardown()
        lastReportedTime = max(0, seconds)
        let file: AVAudioFile
        do {
            file = try AVAudioFile(forReading: url)
@@ -100,6 +105,13 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {
                delegate?.engineDidFail(error)
                return
            }
            // Only the system stops the engine: `pause()` pauses the player node
            // and leaves the engine running. Stopping it discards everything
            // scheduled on the player, so restarting alone would play silence —
            // reschedule from the last known position instead.
            if scheduleFile != nil {
                seek(to: lastReportedTime)
            }
        }
        player.play()
        startTicker()
@@ -120,7 +132,8 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {
        finished = false
        startAnalysis(url: url, fromFrame: Self.frame(for: seconds, sampleRate: sampleRate))
        if wasPlaying { player.play() } else { stopTicker() }
        delegate?.engineDidUpdateTime(max(0, seconds))
        lastReportedTime = max(0, seconds)
        delegate?.engineDidUpdateTime(lastReportedTime)
    }

    public func setRate(_ rate: Float) {
@@ -227,7 +240,8 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {
        guard let nodeTime = player.lastRenderTime, nodeTime.isSampleTimeValid,
              let playerTime = player.playerTime(forNodeTime: nodeTime) else { return }
        let output = playerTime.sampleTime
        delegate?.engineDidUpdateTime(Double(timeline.fileFrame(forOutput: output)) / sampleRate)
        lastReportedTime = Double(timeline.fileFrame(forOutput: output)) / sampleRate
        delegate?.engineDidUpdateTime(lastReportedTime)

        if !finished, analysisComplete, timeline.totalOutputFrames > 0,
           output >= timeline.totalOutputFrames {
+72 −0
Original line number Diff line number Diff line
#if os(iOS)
import AVFoundation
import PlaybackKit

/// Keeps playback in step with the rest of the phone: calls, Siri, alarms, and
/// headphones coming and going.
///
/// Nothing handled this before. The `AVAudioEngine` path used for downloaded
/// episodes with the equalizer or silence skipping never pauses on its own, so
/// taking AirPods out carried on through the loudspeaker; and after a call the
/// app still believed it was playing while the engine sat stopped.
@MainActor
final class AudioInterruptionHandler {
    private let playback: PlaybackController
    /// Whether playback was running when the interruption began. Only then does
    /// it resume afterwards — a call must not start a podcast that was paused.
    private var resumeAfterInterruption = false
    private var observers: [NSObjectProtocol] = []

    init(playback: PlaybackController) {
        self.playback = playback
        let center = NotificationCenter.default
        observers.append(center.addObserver(
            forName: AVAudioSession.interruptionNotification, object: nil, queue: .main
        ) { [weak self] note in
            MainActor.assumeIsolated { self?.handleInterruption(note) }
        })
        observers.append(center.addObserver(
            forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main
        ) { [weak self] note in
            MainActor.assumeIsolated { self?.handleRouteChange(note) }
        })
    }

    deinit {
        observers.forEach(NotificationCenter.default.removeObserver)
    }

    private func handleInterruption(_ note: Notification) {
        guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
              let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
        switch type {
        case .began:
            resumeAfterInterruption = playback.isPlaying
            if playback.isPlaying { playback.pause() }
        case .ended:
            let rawOptions = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
            let options = AVAudioSession.InterruptionOptions(rawValue: rawOptions)
            // The system says whether resuming is appropriate: after a call it
            // is, after the user started music in another app it is not.
            let shouldResume = resumeAfterInterruption && options.contains(.shouldResume)
            resumeAfterInterruption = false
            guard shouldResume else { return }
            try? AVAudioSession.sharedInstance().setActive(true)
            playback.resume()
        @unknown default:
            break
        }
    }

    private func handleRouteChange(_ note: Notification) {
        guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
              let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
        // Headphones unplugged, AirPods taken out or gone out of range: the
        // platform convention is to pause rather than carry on through the
        // loudspeaker. Plugging something in, by contrast, never starts playback.
        if reason == .oldDeviceUnavailable, playback.isPlaying {
            playback.pause()
        }
    }
}
#endif
+153 −16
Original line number Diff line number Diff line
@@ -5,17 +5,36 @@ import StorageKit

/// Downloads episode media to the app container for offline playback and
/// tracks per-episode status. Completed downloads are persisted in the store.
///
/// Transfers run in a background `URLSession`, which the system carries on after
/// the app is suspended or even terminated. They used to go through
/// `URLSession.shared`, which iOS suspends about thirty seconds after leaving
/// the app — so in practice automatic downloads only finished while the app
/// stayed open, and never from the background refresh.
@MainActor
@Observable
final class DownloadController {
    enum Status: Equatable {
    enum Status: Equatable, Sendable {
        case idle, downloading, downloaded, failed
    }

    /// Identifies the background session. The system relaunches the app with
    /// this identifier to deliver the events of downloads that finished while
    /// it was not running.
    static let sessionIdentifier = "eu.cythin.skingomz.downloads"

    private(set) var statuses: [UUID: Status] = [:]

    @ObservationIgnored private let store: LibraryStore
    @ObservationIgnored private let directory: URL
    @ObservationIgnored private let sessionDelegate: DownloadSessionDelegate
    @ObservationIgnored private let session: URLSession
    /// Resumed once the system has delivered every pending background event.
    @ObservationIgnored private var eventsDelivered: CheckedContinuation<Void, Never>?
    /// The session is created with the model, before the system hands the app
    /// its background task — events can therefore finish arriving before anyone
    /// waits for them. Remembered here, or the wait would never end.
    @ObservationIgnored private var eventsDeliveredEarly = false
    /// Resolves the HTTP `Authorization` header value for an episode's
    /// enclosure (private feeds), or `nil`. Set by the app model.
    @ObservationIgnored var authorizationProvider: (@MainActor (Episode) -> String?)?
@@ -30,15 +49,66 @@ final class DownloadController {
        )) ?? FileManager.default.temporaryDirectory
        directory = base.appendingPathComponent("Downloads", isDirectory: true)
        try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)

        sessionDelegate = DownloadSessionDelegate(directory: directory, store: store)
        #if os(iOS) && !targetEnvironment(simulator)
        let configuration = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier)
        // Start right away: these are downloads someone asked for, or that the
        // app's own Wi-Fi / charging conditions already cleared.
        configuration.isDiscretionary = false
        configuration.sessionSendsLaunchEvents = true
        #else
        // macOS never suspends the app, so the problem the background session
        // solves does not exist there. And the Simulator's transfer daemon fails
        // every background task with NSURLErrorUnknown (-1): the very same code
        // on a default session downloads a 21.9 MB episode byte for byte.
        let configuration = URLSessionConfiguration.default
        #endif
        session = URLSession(configuration: configuration, delegate: sessionDelegate, delegateQueue: nil)

        sessionDelegate.onStatus = { [weak self] id, status in
            Task { @MainActor in self?.statuses[id] = status }
        }
        sessionDelegate.onEventsDelivered = { [weak self] in
            Task { @MainActor in
                guard let self else { return }
                if let waiting = self.eventsDelivered {
                    waiting.resume()
                    self.eventsDelivered = nil
                } else {
                    self.eventsDeliveredEarly = true
                }
            }
        }
    }

    /// Restores `.downloaded` status for files still present on disk.
    /// Restores `.downloaded` status for files still present on disk, and
    /// `.downloading` for transfers the system is still carrying on from a
    /// previous run of the app.
    func loadPersisted() async {
        let stored = (try? await store.allDownloads()) ?? [:]
        for (id, fileName) in stored
        where FileManager.default.fileExists(atPath: directory.appendingPathComponent(fileName).path) {
            statuses[id] = .downloaded
        }
        for task in await session.allTasks {
            if let id = task.taskDescription.flatMap(UUID.init(uuidString:)), statuses[id] != .downloaded {
                statuses[id] = .downloading
            }
        }
    }

    /// Called when the system relaunches the app to deliver the events of
    /// background downloads. Returns once they have all been handled, which is
    /// when the system may suspend the app again.
    func handleBackgroundEvents() async {
        if eventsDeliveredEarly {
            eventsDeliveredEarly = false
            return
        }
        await withCheckedContinuation { continuation in
            eventsDelivered = continuation
        }
    }

    func status(for episode: Episode) -> Status {
@@ -59,21 +129,15 @@ final class DownloadController {
        if let authorization = authorizationProvider?(episode) {
            request.setValue(authorization, forHTTPHeaderField: "Authorization")
        }
        Task {
            do {
                let (tempURL, _) = try await URLSession.shared.download(for: request)
                let destination = directory.appendingPathComponent(fileName(for: episode))
                try? FileManager.default.removeItem(at: destination)
                try FileManager.default.moveItem(at: tempURL, to: destination)
                try await store.markDownloaded(episodeID: episode.id, fileName: destination.lastPathComponent)
                statuses[episode.id] = .downloaded
            } catch {
                statuses[episode.id] = .failed
            }
        }
        let task = session.downloadTask(with: request)
        // The only link between a transfer and its episode that survives the
        // app being terminated and relaunched: the system keeps it with the task.
        task.taskDescription = episode.id.uuidString
        task.resume()
    }

    func delete(_ episode: Episode) {
        cancelTransfer(of: episode.id)
        let url = directory.appendingPathComponent(fileName(for: episode))
        try? FileManager.default.removeItem(at: url)
        statuses[episode.id] = .idle
@@ -94,14 +158,87 @@ final class DownloadController {
    /// Removes every downloaded file and clears status (used by the reset and
    /// the "clear cache" action).
    func reset() {
        session.getAllTasks { tasks in tasks.forEach { $0.cancel() } }
        statuses = [:]
        try? FileManager.default.removeItem(at: directory)
        try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
    }

    private func cancelTransfer(of id: UUID) {
        session.getAllTasks { tasks in
            tasks.first { $0.taskDescription == id.uuidString }?.cancel()
        }
    }

    /// Deterministic file name so it can be recomputed without a DB lookup.
    private func fileName(for episode: Episode) -> String {
        let ext = episode.enclosureURL?.pathExtension ?? ""
        return ext.isEmpty ? episode.id.uuidString : "\(episode.id.uuidString).\(ext)"
        DownloadSessionDelegate.fileName(for: episode.id, remote: episode.enclosureURL)
    }
}

/// Receives the background session's callbacks. They arrive on a private queue,
/// possibly while the app is being relaunched in the background and before any
/// view exists — so this does the essential work itself: moving the file out of
/// the system's temporary location, which is deleted the moment the callback
/// returns, and recording it in the store. Updating the on-screen status is a
/// courtesy on top, and `loadPersisted()` catches up if it was missed.
final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
    private let directory: URL
    private let store: LibraryStore
    /// Set once by the controller right after creation, before any transfer.
    var onStatus: (@Sendable (UUID, DownloadController.Status) -> Void)?
    var onEventsDelivered: (@Sendable () -> Void)?

    init(directory: URL, store: LibraryStore) {
        self.directory = directory
        self.store = store
    }

    static func fileName(for id: UUID, remote: URL?) -> String {
        let ext = remote?.pathExtension ?? ""
        return ext.isEmpty ? id.uuidString : "\(id.uuidString).\(ext)"
    }

    func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didFinishDownloadingTo location: URL
    ) {
        guard let id = downloadTask.taskDescription.flatMap(UUID.init(uuidString:)) else { return }
        // A 404 still "finishes": the body is an error page, not audio.
        if let http = downloadTask.response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
            onStatus?(id, .failed)
            return
        }
        let name = Self.fileName(for: id, remote: downloadTask.originalRequest?.url)
        let destination = directory.appendingPathComponent(name)
        do {
            try? FileManager.default.removeItem(at: destination)
            try FileManager.default.moveItem(at: location, to: destination)
        } catch {
            onStatus?(id, .failed)
            return
        }
        let store = store
        let onStatus = onStatus
        Task {
            do {
                try await store.markDownloaded(episodeID: id, fileName: name)
                onStatus?(id, .downloaded)
            } catch {
                onStatus?(id, .failed)
            }
        }
    }

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        guard let error, let id = task.taskDescription.flatMap(UUID.init(uuidString:)) else { return }
        // A cancellation is the user deleting the episode, not a failure.
        let cancelled = (error as? URLError)?.code == .cancelled
        onStatus?(id, cancelled ? .idle : .failed)
    }

    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        onEventsDelivered?()
    }
}
+11 −0
Original line number Diff line number Diff line
@@ -12,6 +12,9 @@ struct SkingomzApp: App {
    @State private var updateChecker = UpdateChecker()
    @State private var reviewPrompter = ReviewPrompter()
    @State private var nowPlaying: NowPlayingCoordinator?
    #if os(iOS)
    @State private var interruptions: AudioInterruptionHandler?
    #endif
    @State private var didConfigure = false

    /// Identifies the single main window, so the Fenêtre menu (and the Dock
@@ -62,6 +65,9 @@ struct SkingomzApp: App {
                            )
                        }
                    )
                    #if os(iOS)
                    interruptions = AudioInterruptionHandler(playback: playback)
                    #endif
                    #if os(macOS)
                    MacAppDelegate.playback = playback
                    MacAppDelegate.openMainWindow = { openWindow(id: Self.mainWindowID) }
@@ -109,6 +115,11 @@ struct SkingomzApp: App {
            await model.backgroundRefresh()
            BackgroundRefresh.schedule()
        }
        // Downloads that finished while the app was not running: the system
        // relaunches it with this identifier to hand over the files.
        .backgroundTask(.urlSession(DownloadController.sessionIdentifier)) {
            await model.downloads.handleBackgroundEvents()
        }
        #endif

        #if os(macOS)