iOS: episodes kept on the phone play with no connection (#425)
A WKWebView runs no service worker, so the page's Keep button (#412) was not shown in the app and everything played from the server. Now the page asks the app to keep the file (keep, unkeep, clearKept), and the app answers with what it holds (kept, keepFailed) whether or not anything plays. The app fetches with a background URLSession, so a download goes on with the app closed, into Application Support (not backed up; the files can be fetched again), and Playback plays from the file when there is one. The DeadAir copy is kept under its own path, as the browser keeps it. Places and finishes reached offline wait in an outbox, newest per address, and go when the network is back. Checked in the simulator against a local server: kept /media/42 and /media/42/deadair, then with the server refusing /media, a fresh launch played each from the phone and asked the server for nothing. The page itself still needs a connection to open (#435). Deploy the app (build 6) before the page: a new page in an old app shows a Keep button nothing answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,8 +5,18 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
Badge.registerBackgroundRefresh()
|
||||
Offline.shared.resume()
|
||||
Outbox.watch()
|
||||
return true
|
||||
}
|
||||
|
||||
/// A download kept going while the app was closed has finished (#425): the system launched us
|
||||
/// to hand it over, and waits on this handler.
|
||||
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String,
|
||||
completionHandler: @escaping () -> Void) {
|
||||
Offline.shared.backgroundCompletion = completionHandler
|
||||
Offline.shared.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// The one window. iOS will not run an app that has not adopted scenes, and a CarPlay app would
|
||||
@@ -32,6 +42,9 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
Badge.requestPermission()
|
||||
Badge.scheduleBackgroundRefresh()
|
||||
Task { await Badge.refresh() }
|
||||
Task {
|
||||
await Outbox.flush()
|
||||
await Badge.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,16 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
||||
playback.onState = { [weak self] playing in self?.send(["t": "state", "playing": playing]) }
|
||||
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
|
||||
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
|
||||
Offline.shared.onChange = { [weak self] in self?.sendKept() }
|
||||
Offline.shared.onFail = { [weak self] path, msg in
|
||||
self?.send(["t": "keepFailed", "url": path, "message": msg])
|
||||
}
|
||||
}
|
||||
|
||||
/// What the phone holds, and is fetching, by the paths the page keeps them under (#425).
|
||||
private func sendKept() {
|
||||
send(["t": "kept", "urls": Offline.shared.store.paths,
|
||||
"keeping": Offline.shared.downloading.sorted()])
|
||||
}
|
||||
|
||||
// MARK: - page to host
|
||||
@@ -40,6 +50,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
||||
case "ready":
|
||||
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
|
||||
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
|
||||
sendKept()
|
||||
case "load":
|
||||
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
|
||||
let enc = m["enc"] as? Int,
|
||||
@@ -59,6 +70,9 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
||||
// The page sends the path it would have written to, not a time: ours is the clock that
|
||||
// is not stale.
|
||||
case "position": playback.savePosition()
|
||||
case "keep": if let url = m["url"] as? String { Offline.shared.keep(url) }
|
||||
case "unkeep": if let url = m["url"] as? String { Offline.shared.remove(url) }
|
||||
case "clearKept": Offline.shared.removeAll()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
89
ios/Sources/EpisodeStore.swift
Normal file
89
ios/Sources/EpisodeStore.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
/// The episodes kept on this device (#425): which file holds each, by the path the page keeps it
|
||||
/// under, `/media/42`, or `/media/42/deadair` for the copy without its dead air. A plain value over
|
||||
/// a folder, so a test can point it at a temporary one.
|
||||
struct EpisodeStore {
|
||||
struct Entry: Codable, Equatable {
|
||||
var file: String
|
||||
var bytes: Int64
|
||||
}
|
||||
|
||||
let directory: URL
|
||||
private(set) var entries: [String: Entry]
|
||||
|
||||
private var indexURL: URL { directory.appendingPathComponent("index.json") }
|
||||
|
||||
init(directory: URL) {
|
||||
self.directory = directory
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
// The files can be fetched again, so they are not worth the user's iCloud backup.
|
||||
var dir = directory
|
||||
var values = URLResourceValues()
|
||||
values.isExcludedFromBackup = true
|
||||
try? dir.setResourceValues(values)
|
||||
|
||||
let saved = (try? Data(contentsOf: directory.appendingPathComponent("index.json")))
|
||||
.flatMap { try? JSONDecoder().decode([String: Entry].self, from: $0) } ?? [:]
|
||||
// A file the system took back is not kept, whatever the index says.
|
||||
entries = saved.filter {
|
||||
FileManager.default.fileExists(atPath: directory.appendingPathComponent($0.value.file).path)
|
||||
}
|
||||
}
|
||||
|
||||
var paths: [String] { entries.keys.sorted() }
|
||||
|
||||
func fileURL(for path: String) -> URL? {
|
||||
entries[path].map { directory.appendingPathComponent($0.file) }
|
||||
}
|
||||
|
||||
/// Only the page's two kinds of file, which also keeps what a path can make of a file name to
|
||||
/// digits and a word: a message from the page is not trusted to name a place on disk.
|
||||
static func isKeepable(_ path: String) -> Bool {
|
||||
path.range(of: #"^/media/[0-9]+(/deadair)?$"#, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
static func fileName(for path: String, ext: String) -> String {
|
||||
path.dropFirst().replacingOccurrences(of: "/", with: "-") + "." + ext
|
||||
}
|
||||
|
||||
/// What AVFoundation can tell a file's type from, since a download has no name of its own.
|
||||
static func ext(forMime mime: String?) -> String {
|
||||
switch mime?.lowercased().split(separator: ";").first.map(String.init) {
|
||||
case "audio/mp4", "audio/x-m4a", "audio/m4a": return "m4a"
|
||||
case "audio/aac", "audio/aacp": return "aac"
|
||||
case "audio/ogg", "audio/opus": return "ogg"
|
||||
case "audio/wav", "audio/x-wav": return "wav"
|
||||
case "audio/flac", "audio/x-flac": return "flac"
|
||||
default: return "mp3"
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the file at `temp`, which the system deletes when the delegate returns, as `path`'s.
|
||||
mutating func add(_ path: String, moving temp: URL, ext: String) throws {
|
||||
precondition(Self.isKeepable(path))
|
||||
let name = Self.fileName(for: path, ext: ext)
|
||||
let dest = directory.appendingPathComponent(name)
|
||||
try? FileManager.default.removeItem(at: dest)
|
||||
try FileManager.default.moveItem(at: temp, to: dest)
|
||||
let bytes = (try? FileManager.default.attributesOfItem(atPath: dest.path))?[.size] as? Int64 ?? 0
|
||||
entries[path] = Entry(file: name, bytes: bytes)
|
||||
save()
|
||||
}
|
||||
|
||||
mutating func remove(_ path: String) {
|
||||
guard let e = entries.removeValue(forKey: path) else { return }
|
||||
try? FileManager.default.removeItem(at: directory.appendingPathComponent(e.file))
|
||||
save()
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
for e in entries.values { try? FileManager.default.removeItem(at: directory.appendingPathComponent(e.file)) }
|
||||
entries = [:]
|
||||
save()
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if let data = try? JSONEncoder().encode(entries) { try? data.write(to: indexURL, options: .atomic) }
|
||||
}
|
||||
}
|
||||
124
ios/Sources/Offline.swift
Normal file
124
ios/Sources/Offline.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import Foundation
|
||||
|
||||
/// Episodes kept on the device to play with no connection (#425). The page's Keep button asks
|
||||
/// (through the bridge); a background `URLSession` fetches, so a download goes on with the app
|
||||
/// closed and the phone locked; `Playback` plays from the file when there is one.
|
||||
///
|
||||
/// A browser keeps them in a cache its service worker answers from, which a `WKWebView` does not
|
||||
/// run, so the app keeps its own and the page is told which it holds.
|
||||
@MainActor
|
||||
final class Offline: NSObject, URLSessionDownloadDelegate {
|
||||
static let shared = Offline()
|
||||
|
||||
/// What is kept, or being fetched, changed.
|
||||
var onChange: (() -> Void)?
|
||||
/// A path that could not be kept, and why in words for the person.
|
||||
var onFail: ((String, String) -> Void)?
|
||||
/// The system's handler for a launch it made to deliver a finished download: told when done.
|
||||
var backgroundCompletion: (() -> Void)?
|
||||
|
||||
private(set) var store: EpisodeStore
|
||||
private(set) var downloading: Set<String> = []
|
||||
|
||||
private lazy var session: URLSession = {
|
||||
let cfg = URLSessionConfiguration.background(withIdentifier: "net.sdf1.ipx.offline")
|
||||
cfg.sessionSendsLaunchEvents = true
|
||||
cfg.httpCookieStorage = .shared
|
||||
// The delegate's queue is main, which is what lets this class be the main actor's.
|
||||
return URLSession(configuration: cfg, delegate: self, delegateQueue: .main)
|
||||
}()
|
||||
|
||||
private override init() {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
store = EpisodeStore(directory: base.appendingPathComponent("episodes", isDirectory: true))
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// At launch: reconnects to downloads begun before the app was last closed, so the page can be
|
||||
/// told they are still going.
|
||||
func resume() {
|
||||
session.getAllTasks { [weak self] tasks in
|
||||
let paths = tasks.compactMap { $0.taskDescription }
|
||||
Task { @MainActor in
|
||||
self?.downloading.formUnion(paths)
|
||||
if !paths.isEmpty { self?.onChange?() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keep(_ path: String) {
|
||||
guard EpisodeStore.isKeepable(path) else { return }
|
||||
guard store.fileURL(for: path) == nil, !downloading.contains(path) else { onChange?(); return }
|
||||
guard let url = Server.resolveForPlayer(path) else {
|
||||
onFail?(path, "Set the server first.")
|
||||
return
|
||||
}
|
||||
var req = URLRequest(url: url)
|
||||
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
|
||||
let task = session.downloadTask(with: req)
|
||||
task.taskDescription = path
|
||||
downloading.insert(path)
|
||||
task.resume()
|
||||
onChange?()
|
||||
}
|
||||
|
||||
func remove(_ path: String) {
|
||||
session.getAllTasks { tasks in tasks.filter { $0.taskDescription == path }.forEach { $0.cancel() } }
|
||||
downloading.remove(path)
|
||||
store.remove(path)
|
||||
onChange?()
|
||||
}
|
||||
|
||||
/// Signing out: the next person on this phone is not to hear what was kept for the last.
|
||||
func removeAll() {
|
||||
session.getAllTasks { tasks in tasks.forEach { $0.cancel() } }
|
||||
downloading = []
|
||||
store.removeAll()
|
||||
onChange?()
|
||||
}
|
||||
|
||||
// MARK: - URLSessionDownloadDelegate
|
||||
|
||||
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL) {
|
||||
// On the main queue (see `session`), and the file is the system's until this returns, so
|
||||
// it is moved here and not later.
|
||||
MainActor.assumeIsolated {
|
||||
guard let path = downloadTask.taskDescription else { return }
|
||||
let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard status == 200 else {
|
||||
downloading.remove(path)
|
||||
onFail?(path, status == 401 || status == 403
|
||||
? "Check the player token in Settings." : "The server said \(status).")
|
||||
onChange?()
|
||||
return
|
||||
}
|
||||
let ext = EpisodeStore.ext(forMime: downloadTask.response?.mimeType)
|
||||
do {
|
||||
try store.add(path, moving: location, ext: ext)
|
||||
} catch {
|
||||
onFail?(path, "There is not enough room on this phone to keep it.")
|
||||
}
|
||||
downloading.remove(path)
|
||||
onChange?()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard let error, (error as NSError).code != NSURLErrorCancelled else { return }
|
||||
MainActor.assumeIsolated {
|
||||
guard let path = task.taskDescription else { return }
|
||||
downloading.remove(path)
|
||||
onFail?(path, (error as NSError).code == NSURLErrorNotConnectedToInternet
|
||||
? "You are offline. Keep it once you are back." : "Unable to keep it on this device. Try again.")
|
||||
onChange?()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||
MainActor.assumeIsolated {
|
||||
backgroundCompletion?()
|
||||
backgroundCompletion = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
66
ios/Sources/Outbox.swift
Normal file
66
ios/Sources/Outbox.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Writes the player made with no connection, sent when there is one (#425): where you got to in
|
||||
/// an episode, and that you finished it. In a browser the page's service worker holds these; the
|
||||
/// app's player is the one writing them, so it holds its own.
|
||||
///
|
||||
/// A newer write to the same address replaces an older one, since each says where you are now, and
|
||||
/// the queue then stays as long as the episodes played offline, not as long as the time spent.
|
||||
enum Outbox {
|
||||
struct Write: Codable, Equatable {
|
||||
var path: String
|
||||
var json: Data
|
||||
}
|
||||
|
||||
private static let key = "ipx.outbox"
|
||||
private static var flushing = false
|
||||
private static let monitor = NWPathMonitor()
|
||||
|
||||
static var pending: [Write] {
|
||||
get { UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode([Write].self, from: $0) } ?? [] }
|
||||
set { UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: key) }
|
||||
}
|
||||
|
||||
/// A write that never arrived. Only the player's own two kinds are kept: anything else, such
|
||||
/// as adding a feed, fails offline as it always has.
|
||||
static func add(path: String, json: [String: Any]) {
|
||||
guard path.hasSuffix("/position") || path.hasSuffix("/flags"),
|
||||
let data = try? JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) else { return }
|
||||
var all = pending.filter { $0.path != path }
|
||||
all.append(Write(path: path, json: data))
|
||||
pending = all
|
||||
}
|
||||
|
||||
/// In the order they were made. The first that does not reach the server stops it for the next
|
||||
/// try; one the server answered at all is done, whatever it said, as the worker treats it.
|
||||
static func flush() async {
|
||||
guard !flushing, !pending.isEmpty else { return }
|
||||
flushing = true
|
||||
defer { flushing = false }
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.httpCookieStorage = .shared
|
||||
cfg.timeoutIntervalForRequest = 20
|
||||
let session = URLSession(configuration: cfg)
|
||||
for w in pending {
|
||||
guard let url = Server.resolveForPlayer(w.path) else { return }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
|
||||
req.httpBody = w.json
|
||||
guard (try? await session.data(for: req)) != nil else { return }
|
||||
// Removed by what it is, not where it was: a newer write may have replaced it while
|
||||
// this one was on its way.
|
||||
pending.removeAll { $0 == w }
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends what waited whenever the phone gets a connection back.
|
||||
static func watch() {
|
||||
monitor.pathUpdateHandler = { path in
|
||||
if path.status == .satisfied { Task { await flush() } }
|
||||
}
|
||||
monitor.start(queue: .global(qos: .utility))
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,10 @@ final class Playback: NSObject {
|
||||
AVURLAssetHTTPCookiesKey: HTTPCookieStorage.shared.cookies(for: item.url) ?? [],
|
||||
]
|
||||
if Credentials.isSet { options["AVURLAssetHTTPHeaderFieldsKey"] = Credentials.headers }
|
||||
let asset = AVURLAsset(url: item.url, options: options)
|
||||
// One kept on the phone plays from there, connection or not (#425). The page asks for it
|
||||
// as `/media/42?offline`; the path alone is what it was kept under.
|
||||
let asset = Offline.shared.store.fileURL(for: item.url.path).map { AVURLAsset(url: $0) }
|
||||
?? AVURLAsset(url: item.url, options: options)
|
||||
let playerItem = AVPlayerItem(asset: asset)
|
||||
let player = AVPlayer(playerItem: playerItem)
|
||||
player.volume = volume
|
||||
@@ -177,7 +180,10 @@ final class Playback: NSObject {
|
||||
if let d = duration { body["duration"] = Int(d.rounded()) }
|
||||
// DeadAir's copy runs on a shorter clock; the server has to know which one this is.
|
||||
if item.url.path.contains("/deadair") { body["deadair"] = item.enc }
|
||||
Task { await request("POST", entryPath(item, "position"), body) }
|
||||
let path = entryPath(item, "position")
|
||||
// Not arrived, kept for when the connection is back: offline, the ten-second retry would
|
||||
// only fail again, and the place reached on the last pause would be lost.
|
||||
Task { if await request("POST", path, body) == nil { Outbox.add(path: path, json: body) } }
|
||||
}
|
||||
|
||||
// MARK: - as it plays
|
||||
@@ -204,8 +210,9 @@ final class Playback: NSObject {
|
||||
private func markRead() {
|
||||
guard !markedRead, let item else { return }
|
||||
markedRead = true
|
||||
let path = entryPath(item, "flags")
|
||||
Task {
|
||||
await request("POST", entryPath(item, "flags"), ["read": true])
|
||||
if await request("POST", path, ["read": true]) == nil { Outbox.add(path: path, json: ["read": true]) }
|
||||
await Badge.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user