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:
@@ -9,3 +9,4 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.
|
|||||||
- iPX on the iPhone: the page in an app, playing through the phone's own player, so an episode plays on with the screen locked and the lock screen, Control Center and a car's buttons drive it (#418).
|
- iPX on the iPhone: the page in an app, playing through the phone's own player, so an episode plays on with the screen locked and the lock screen, Control Center and a car's buttons drive it (#418).
|
||||||
- Player token (shake, then Player Token): an ipx API token, kept in the keychain, which the player, the badge and CarPlay send to `https://ipxapi.sdf1.net` instead of using the page's sign-in cookie, so playback does not stop when that cookie expires (#419).
|
- Player token (shake, then Player Token): an ipx API token, kept in the keychain, which the player, the badge and CarPlay send to `https://ipxapi.sdf1.net` instead of using the page's sign-in cookie, so playback does not stop when that cookie expires (#419).
|
||||||
- The icon shows All Subscriptions' unread count, as the page's badge does: set when the app comes forward, after an item is marked read, and by a refresh iOS runs now and then while the app is closed, so it can be hours behind (#421).
|
- The icon shows All Subscriptions' unread count, as the page's badge does: set when the app comes forward, after an item is marked read, and by a refresh iOS runs now and then while the app is closed, so it can be hours behind (#421).
|
||||||
|
- Episodes kept on the phone, with the page's Keep button, play with no connection: the app fetches the file in the background, even with the app closed, and plays it from the phone; where you got to and what you finished offline is sent once the connection is back. Signing out removes them (#425).
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ Page to app, on `webkit.messageHandlers.ipx`:
|
|||||||
| `{t:"play"}` `{t:"pause"}` `{t:"stop"}` | |
|
| `{t:"play"}` `{t:"pause"}` `{t:"stop"}` | |
|
||||||
| `{t:"seek", to}` `{t:"rate", v}` `{t:"volume", v}` | |
|
| `{t:"seek", to}` `{t:"rate", v}` `{t:"volume", v}` | |
|
||||||
| `{t:"position", url}` | save where we are; a path, not a time, since a page frozen in the background holds an old one |
|
| `{t:"position", url}` | save where we are; a path, not a time, since a page frozen in the background holds an old one |
|
||||||
|
| `{t:"keep", url}` `{t:"unkeep", url}` | keep a file on the phone, or let it go: the page's path for it, `/media/42` or `/media/42/deadair` (#425) |
|
||||||
|
| `{t:"clearKept"}` | signing out: everything kept goes |
|
||||||
|
|
||||||
App to page, as `window.ipxNative.on(...)`:
|
App to page, as `window.ipxNative.on(...)`:
|
||||||
|
|
||||||
@@ -79,14 +81,19 @@ App to page, as `window.ipxNative.on(...)`:
|
|||||||
| `{t:"state", playing}` | `play` or `pause` |
|
| `{t:"state", playing}` | `play` or `pause` |
|
||||||
| `{t:"ended"}` | |
|
| `{t:"ended"}` | |
|
||||||
| `{t:"error", message}` | the page says so |
|
| `{t:"error", message}` | the page says so |
|
||||||
|
| `{t:"kept", urls, keeping}` | what the phone holds and is fetching, at `ready` and after each change; sent whether or not anything plays |
|
||||||
|
| `{t:"keepFailed", url, message}` | one that could not be kept, and why |
|
||||||
|
|
||||||
The app writes the position (every ten seconds and on pause) and marks an item read at 90%, from
|
The app writes the position (every ten seconds and on pause) and marks an item read at 90%, from
|
||||||
its own clock, so both happen while the page is frozen. Video stays in the page.
|
its own clock, so both happen while the page is frozen. Video stays in the page.
|
||||||
|
|
||||||
## Not yet
|
## Not yet
|
||||||
|
|
||||||
* Episodes kept on the device (#412) are the page's service worker's, which a `WKWebView` does
|
* Episodes kept on the phone (#425) play with no connection, from files the app fetches with a
|
||||||
not run without App-Bound Domains; the app plays them from the server.
|
background `URLSession` (`Offline.swift`, `EpisodeStore.swift`); a place or a finish reached
|
||||||
|
offline waits in `Outbox.swift` until the connection is back. But the page itself does not open
|
||||||
|
without a connection: a `WKWebView` runs no service worker without App-Bound Domains (#435). So
|
||||||
|
keep what you want, open the app while online, and it plays on with the connection gone.
|
||||||
* Without the player token the cookie is the only credential: when Cloudflare Access's expires
|
* Without the player token the cookie is the only credential: when Cloudflare Access's expires
|
||||||
mid-drive, playback stops with "Sign in again", and opening the app signs in.
|
mid-drive, playback stops with "Sign in again", and opening the app signs in.
|
||||||
* CarPlay's dashboard app needs Apple's entitlement first; then it is a second scene over the same
|
* CarPlay's dashboard app needs Apple's entitlement first; then it is a second scene over the same
|
||||||
|
|||||||
@@ -5,8 +5,18 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
func application(_ application: UIApplication,
|
func application(_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||||
Badge.registerBackgroundRefresh()
|
Badge.registerBackgroundRefresh()
|
||||||
|
Offline.shared.resume()
|
||||||
|
Outbox.watch()
|
||||||
return true
|
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
|
/// 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) {
|
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||||
Badge.requestPermission()
|
Badge.requestPermission()
|
||||||
Badge.scheduleBackgroundRefresh()
|
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.onState = { [weak self] playing in self?.send(["t": "state", "playing": playing]) }
|
||||||
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
|
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
|
||||||
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
|
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
|
// MARK: - page to host
|
||||||
@@ -40,6 +50,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
|||||||
case "ready":
|
case "ready":
|
||||||
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
|
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
|
||||||
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
|
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
|
||||||
|
sendKept()
|
||||||
case "load":
|
case "load":
|
||||||
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
|
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
|
||||||
let enc = m["enc"] as? Int,
|
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
|
// The page sends the path it would have written to, not a time: ours is the clock that
|
||||||
// is not stale.
|
// is not stale.
|
||||||
case "position": playback.savePosition()
|
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
|
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) ?? [],
|
AVURLAssetHTTPCookiesKey: HTTPCookieStorage.shared.cookies(for: item.url) ?? [],
|
||||||
]
|
]
|
||||||
if Credentials.isSet { options["AVURLAssetHTTPHeaderFieldsKey"] = Credentials.headers }
|
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 playerItem = AVPlayerItem(asset: asset)
|
||||||
let player = AVPlayer(playerItem: playerItem)
|
let player = AVPlayer(playerItem: playerItem)
|
||||||
player.volume = volume
|
player.volume = volume
|
||||||
@@ -177,7 +180,10 @@ final class Playback: NSObject {
|
|||||||
if let d = duration { body["duration"] = Int(d.rounded()) }
|
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.
|
// 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 }
|
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
|
// MARK: - as it plays
|
||||||
@@ -204,8 +210,9 @@ final class Playback: NSObject {
|
|||||||
private func markRead() {
|
private func markRead() {
|
||||||
guard !markedRead, let item else { return }
|
guard !markedRead, let item else { return }
|
||||||
markedRead = true
|
markedRead = true
|
||||||
|
let path = entryPath(item, "flags")
|
||||||
Task {
|
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()
|
await Badge.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
ios/Tests/OfflineTests.swift
Normal file
72
ios/Tests/OfflineTests.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import iPX
|
||||||
|
|
||||||
|
final class OfflineTests: XCTestCase {
|
||||||
|
private var dir: URL!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(at: dir)
|
||||||
|
UserDefaults.standard.removeObject(forKey: "ipx.outbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tempFile(_ bytes: Int = 4) -> URL {
|
||||||
|
let u = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try? Data(repeating: 7, count: bytes).write(to: u)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOnlyThePagesTwoKindsOfFileAreKept() {
|
||||||
|
XCTAssertTrue(EpisodeStore.isKeepable("/media/42"))
|
||||||
|
XCTAssertTrue(EpisodeStore.isKeepable("/media/42/deadair"))
|
||||||
|
XCTAssertFalse(EpisodeStore.isKeepable("/media/42/deadair.m3u8"))
|
||||||
|
XCTAssertFalse(EpisodeStore.isKeepable("/media/../../etc/passwd"), "a path names a place on disk")
|
||||||
|
XCTAssertFalse(EpisodeStore.isKeepable("/api/feeds"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAKeptFileIsFoundAgainAfterARelaunch() throws {
|
||||||
|
var store = EpisodeStore(directory: dir)
|
||||||
|
try store.add("/media/42", moving: tempFile(10), ext: "mp3")
|
||||||
|
XCTAssertEqual(store.paths, ["/media/42"])
|
||||||
|
XCTAssertEqual(store.entries["/media/42"]?.bytes, 10)
|
||||||
|
|
||||||
|
let again = EpisodeStore(directory: dir)
|
||||||
|
XCTAssertEqual(again.fileURL(for: "/media/42")?.lastPathComponent, "media-42.mp3")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAFileTheSystemTookBackIsNotKept() throws {
|
||||||
|
var store = EpisodeStore(directory: dir)
|
||||||
|
try store.add("/media/7/deadair", moving: tempFile(), ext: "m4a")
|
||||||
|
try FileManager.default.removeItem(at: store.fileURL(for: "/media/7/deadair")!)
|
||||||
|
XCTAssertNil(EpisodeStore(directory: dir).fileURL(for: "/media/7/deadair"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemovingTakesTheFileToo() throws {
|
||||||
|
var store = EpisodeStore(directory: dir)
|
||||||
|
try store.add("/media/1", moving: tempFile(), ext: "mp3")
|
||||||
|
let file = store.fileURL(for: "/media/1")!
|
||||||
|
store.remove("/media/1")
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: file.path))
|
||||||
|
XCTAssertTrue(store.paths.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTheTypeIsTakenFromTheServersAnswer() {
|
||||||
|
XCTAssertEqual(EpisodeStore.ext(forMime: "audio/mpeg"), "mp3")
|
||||||
|
XCTAssertEqual(EpisodeStore.ext(forMime: "audio/x-m4a"), "m4a")
|
||||||
|
XCTAssertEqual(EpisodeStore.ext(forMime: "audio/mp4; codecs=mp4a.40.2"), "m4a")
|
||||||
|
XCTAssertEqual(EpisodeStore.ext(forMime: nil), "mp3")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testANewerPlaceReplacesTheOlderAndOtherWritesAreLeftOut() {
|
||||||
|
Outbox.add(path: "/api/entries/f/g/position", json: ["secs": 10])
|
||||||
|
Outbox.add(path: "/api/entries/f/g/flags", json: ["read": true])
|
||||||
|
Outbox.add(path: "/api/entries/f/g/position", json: ["secs": 90])
|
||||||
|
Outbox.add(path: "/api/feeds", json: ["url": "x"])
|
||||||
|
let all = Outbox.pending
|
||||||
|
XCTAssertEqual(all.map(\.path), ["/api/entries/f/g/flags", "/api/entries/f/g/position"])
|
||||||
|
XCTAssertEqual(String(data: all[1].json, encoding: .utf8), #"{"secs":90}"#)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: iPX
|
CFBundleDisplayName: iPX
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
CFBundleVersion: "5"
|
CFBundleVersion: "6"
|
||||||
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on
|
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on
|
||||||
# a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant.
|
# a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant.
|
||||||
UIBackgroundModes: [audio, fetch]
|
UIBackgroundModes: [audio, fetch]
|
||||||
|
|||||||
@@ -116,6 +116,37 @@ audio.src = '/media/99';
|
|||||||
ok(!last('load'), 'a video was handed to the host');
|
ok(!last('load'), 'a video was handed to the host');
|
||||||
ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
|
ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
|
||||||
|
|
||||||
|
// Kept on the phone (#425): no service worker in a WebView, so the button is the app's to answer.
|
||||||
|
const enc42 = { id: 42, deadair: 0, mime: 'audio/mpeg' };
|
||||||
|
ok(String(vm.runInContext('keepBtn(X)', Object.assign(ctx, { X: enc42 }))).includes('data-a="offline"'),
|
||||||
|
'no Keep button in the app, which has no service worker to keep a file');
|
||||||
|
since();
|
||||||
|
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
|
||||||
|
ok(last('keep')?.url === '/media/42', 'Keep did not ask the app for the file');
|
||||||
|
ok(vm.runInContext('keeping.has("/media/42")', ctx), 'the button did not show it was on its way');
|
||||||
|
|
||||||
|
// The app's answer, which comes whether or not anything is playing.
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
ctx.window.ipxNative.on({ t: 'kept', urls: ['/media/42'], keeping: [] });
|
||||||
|
ok(vm.runInContext('isKept(X)', ctx), "the app's list did not reach the page");
|
||||||
|
ok(!vm.runInContext('keeping.has("/media/42")', ctx), 'still shown as on its way once kept');
|
||||||
|
ok(vm.runInContext('mediaSrc(X)', ctx) === '/media/42?offline', 'a kept file is not asked for as kept');
|
||||||
|
|
||||||
|
since();
|
||||||
|
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
|
||||||
|
ok(last('unkeep')?.url === '/media/42', 'pressing it again did not ask the app to let it go');
|
||||||
|
ctx.window.ipxNative.on({ t: 'kept', urls: [], keeping: [] });
|
||||||
|
ok(!vm.runInContext('isKept(X)', ctx), 'still kept after the app let it go');
|
||||||
|
|
||||||
|
// One that could not be kept stops showing as on its way.
|
||||||
|
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
|
||||||
|
ctx.window.ipxNative.on({ t: 'keepFailed', url: '/media/42', message: 'The server said 404.' });
|
||||||
|
ok(!vm.runInContext('keeping.has("/media/42")', ctx), 'a failed keep still shows as on its way');
|
||||||
|
|
||||||
|
since();
|
||||||
|
await vm.runInContext('clearOffline()', ctx);
|
||||||
|
ok(last('clearKept'), 'signing out left the phone holding what was kept');
|
||||||
|
|
||||||
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
||||||
console.log('OK: native-bridge: the host takes playback and the page follows it');
|
console.log('OK: native-bridge: the host takes playback and keeping, and the page follows it');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- In the iOS app, Keep on this device keeps the episode on the phone, where the app's own player plays it with no connection; until now the app had no Keep button (#425).
|
||||||
- An episode kept on this device says so in its row of the list, On this device, not only in its files (#413).
|
- An episode kept on this device says so in its row of the list, On this device, not only in its files (#413).
|
||||||
- Keep an episode on this device to listen offline: the button beside Play on a downloaded episode keeps the file, without its dead air if DeadAir shortens it for you, and it then plays from the device whether you are online or not, with your place sent once the connection is back. Press it again to take it off; signing out takes everything off (#412).
|
- Keep an episode on this device to listen offline: the button beside Play on a downloaded episode keeps the file, without its dead air if DeadAir shortens it for you, and it then plays from the device whether you are online or not, with your place sent once the connection is back. Press it again to take it off; signing out takes everything off (#412).
|
||||||
- iPX opens and reads without a connection: the lists it has fetched (All Subscriptions, Unread and the feeds it preloads or you opened) are kept on the device with the pictures in their unread items, up to 200 MB of them, and items read or flagged and places in an episode while offline are sent when the connection is back. Signing out clears it all (#389).
|
- iPX opens and reads without a connection: the lists it has fetched (All Subscriptions, Unread and the feeds it preloads or you opened) are kept on the device with the pictures in their unread items, up to 200 MB of them, and items read or flagged and places in an episode while offline are sent when the connection is back. Signing out clears it all (#389).
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ function installNativePlayback(){
|
|||||||
(window as any).ipxNative = {
|
(window as any).ipxNative = {
|
||||||
version: 1,
|
version: 1,
|
||||||
on(m: any){
|
on(m: any){
|
||||||
|
// Not playback's, so not gated on it: the app says what it keeps whether or not it plays.
|
||||||
|
if(m.t==='kept'||m.t==='keepFailed'){ hostKept(m); return; }
|
||||||
if(!N.on) return;
|
if(!N.on) return;
|
||||||
switch(m.t){
|
switch(m.t){
|
||||||
case 'time':
|
case 'time':
|
||||||
|
|||||||
@@ -58,15 +58,26 @@ function syncKeepButtons(){
|
|||||||
for(const e of S.entries)
|
for(const e of S.entries)
|
||||||
if(e.enclosures.some(isKept)!==!!$(`#eps .ep[data-key="${CSS.escape(entryKey(e))}"] .kept`)) swapRow(e);
|
if(e.enclosures.some(isKept)!==!!$(`#eps .ep[data-key="${CSS.escape(entryKey(e))}"] .kept`)) swapRow(e);
|
||||||
}
|
}
|
||||||
/// The button for a file on the server that plays; none without a worker to play it offline.
|
/// The button for a file on the server that plays; none without a worker, or an app, to play it
|
||||||
|
/// offline.
|
||||||
function keepBtn(x: Enclosure){
|
function keepBtn(x: Enclosure){
|
||||||
if(!worker) return '';
|
if(!worker&&!ipxHost) return '';
|
||||||
const k=isKept(x), label=k?'Kept on this device to play offline. Remove it':'Keep on this device to play offline';
|
const k=isKept(x), label=k?'Kept on this device to play offline. Remove it':'Keep on this device to play offline';
|
||||||
return `<button class="btn ico" data-a="offline" data-enc="${x.id}" data-url="${keepUrl(x)}" aria-pressed="${k}" aria-busy="${keeping.has(keepUrl(x))}" title="${label}" aria-label="${label}">${k?ICON.kept:ICON.keep}</button>`;
|
return `<button class="btn ico" data-a="offline" data-enc="${x.id}" data-url="${keepUrl(x)}" aria-pressed="${k}" aria-busy="${keeping.has(keepUrl(x))}" title="${label}" aria-label="${label}">${k?ICON.kept:ICON.keep}</button>`;
|
||||||
}
|
}
|
||||||
async function toggleKept(x: Enclosure, name: string){
|
async function toggleKept(x: Enclosure, name: string){
|
||||||
const url=keepUrl(x);
|
const url=keepUrl(x);
|
||||||
if(keeping.has(url)) return;
|
if(keeping.has(url)) return;
|
||||||
|
// In the iOS app the phone keeps the file and its player plays it (#425): a WebView runs no
|
||||||
|
// service worker, and the app's player could not read the page's cache if it did. The app
|
||||||
|
// answers with what it holds, hostKept below.
|
||||||
|
if(ipxHost){
|
||||||
|
if(kept.has(url)){ ipxHost({t:'unkeep',url}); toast('Removed from this device: '+name); return; }
|
||||||
|
keeping.add(url); syncKeepButtons();
|
||||||
|
toast('Keeping on this device: '+name);
|
||||||
|
ipxHost({t:'keep',url});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const c=await caches.open(KEPT);
|
const c=await caches.open(KEPT);
|
||||||
if(kept.has(url)){
|
if(kept.has(url)){
|
||||||
await c.delete(url); kept.delete(url); syncKeepButtons();
|
await c.delete(url); kept.delete(url); syncKeepButtons();
|
||||||
@@ -91,8 +102,26 @@ async function toggleKept(x: Enclosure, name: string){
|
|||||||
}finally{ keeping.delete(url); syncKeepButtons(); }
|
}finally{ keeping.delete(url); syncKeepButtons(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the app holds and is fetching (#425), told when the page starts and after each change,
|
||||||
|
/// or that one could not be kept. Only a file asked for here says it was kept: the list at start
|
||||||
|
/// is no news.
|
||||||
|
function hostKept(m: {t: string, urls?: string[], keeping?: string[], url?: string, message?: string}){
|
||||||
|
if(m.t==='keepFailed'){
|
||||||
|
keeping.delete(m.url||'');
|
||||||
|
toast(m.message||'Unable to keep it on this device. Try again.',true);
|
||||||
|
syncKeepButtons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const asked=new Set(keeping);
|
||||||
|
kept.clear(); for(const u of m.urls||[]) kept.add(u);
|
||||||
|
keeping.clear(); for(const u of m.keeping||[]) keeping.add(u);
|
||||||
|
for(const u of asked) if(kept.has(u)) toast('Kept on this device, to play offline');
|
||||||
|
syncKeepButtons();
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything kept for offline, gone: at sign-out, and when another account signs in here.
|
/// Everything kept for offline, gone: at sign-out, and when another account signs in here.
|
||||||
async function clearOffline(){
|
async function clearOffline(){
|
||||||
|
ipxHost?.({t:'clearKept'});
|
||||||
try{ for(const k of await caches.keys()) if(k.startsWith('ipx-')) await caches.delete(k); }catch{}
|
try{ for(const k of await caches.keys()) if(k.startsWith('ipx-')) await caches.delete(k); }catch{}
|
||||||
kept.clear();
|
kept.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user