Files
ipx/ios/Sources/Badge.swift
Ray Slakinski 2bb7ed2109 iOS: CarPlay (#420)
A CarPlay scene beside the phone's: In Progress, Unread and Shows as
CPListTemplates from ipx's API, read with the player's credentials, and
a pick played through the same Playback, so it resumes from the saved
place, saves as it goes and marks read at 90%. Now Playing is the
system's template over Playback's now-playing info.

When the car starts a different episode the page is sent
{t:"replaced"} and closes its player bar. Left open, the bar would have
gone on as the old episode while the host's clock belonged to the new
one, saving that time and marking the wrong item read.

The badge's fetch moves into API.get, which CarPlay shares. Build 13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:10:34 -05:00

98 lines
4.1 KiB
Swift

import BackgroundTasks
import UIKit
import UserNotifications
/// The number on the icon: All Subscriptions' unread count, as the page's badge shows it (#421).
///
/// Set when the app comes forward, after the player marks an item read, and by a background
/// refresh while it is closed. iOS decides when that runs (a few times a day for an app that is
/// opened often), so the number can be hours behind; push would make it exact and needs a sender
/// in ipx.
enum Badge {
static let taskId = "net.sdf1.ipx.refresh"
private struct Row: Decodable {
let id: String
let group: String?
let unread: Int?
let hide_from_all: Bool?
}
/// web/src/feeds.ts `unreadInAll`: a feed counts unless it, or the OPML it came from, is
/// hidden from All Subscriptions. Nil when the body is not a feed list, so an error page
/// does not turn the badge to zero.
static func unread(inAll body: Data) -> Int? {
guard let rows = try? JSONDecoder().decode([Row].self, from: body) else { return nil }
let hidden = Set(rows.filter { $0.hide_from_all == true }.map(\.id))
return rows.reduce(0) { n, f in
if f.hide_from_all == true || f.group.map(hidden.contains) == true { return n }
return n + max(0, f.unread ?? 0)
}
}
/// Asks once, for the badge alone: no alerts, no sounds.
static func requestPermission() {
// `-ipx.noBadgePrompt YES` on the launch arguments: a simulator run that drives the page
// has nobody to tap Allow, and the dialog covers it.
if UserDefaults.standard.bool(forKey: "ipx.noBadgePrompt") { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { granted, _ in
// Each launch, not once: Apple can change the token, and says to ask every time.
guard granted else { return }
Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() }
}
}
/// The page's own count, sent as its list changes (#421).
@MainActor static func set(_ n: Int) {
lastSet = Date()
WidgetFeed.update { $0.unread = max(0, n) }
Task { try? await UNUserNotificationCenter.current().setBadgeCount(max(0, n)) }
}
/// When the icon was last set from a count, by either way.
@MainActor private static var lastSet = Date.distantPast
/// While an episode plays with the screen locked the app is awake, unlike the page: so new
/// items reach the icon every quarter of an hour, which background refresh does not promise.
@MainActor static func refreshIfStale() {
guard Date().timeIntervalSince(lastSet) > 15 * 60 else { return }
lastSet = Date()
Task { await refresh() }
}
/// Asks ipx and sets the icon. Left as it is when ipx cannot be reached or does not answer
/// with a feed list: an old number is better than a wrong one.
@discardableResult
static func refresh() async -> Bool {
guard let n = await fetch() else { return false }
await MainActor.run { set(n) }
return true
}
/// All Subscriptions' unread count from ipx, or nil when it cannot be had.
static func fetch() async -> Int? {
guard let data = await API.get("/api/feeds") else { return nil }
return unread(inAll: data)
}
// MARK: - while the app is closed
/// Before the app finishes launching, which is when iOS wants the handler registered.
static func registerBackgroundRefresh() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskId, using: nil) { task in
// The next one first, so a run that fails or is cut short does not end the chain.
scheduleBackgroundRefresh()
let work = Task {
task.setTaskCompleted(success: await refresh())
}
task.expirationHandler = { work.cancel() }
}
}
static func scheduleBackgroundRefresh() {
let req = BGAppRefreshTaskRequest(identifier: taskId)
req.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)
try? BGTaskScheduler.shared.submit(req)
}
}