Files
ipx/ios/Sources/Badge.swift
Ray Slakinski 8d4c70a571 Admin page: the top bar clears the status bar in the iOS app (#423)
admin.html's viewport meta lacked viewport-fit=cover, which index.html
has. Without it env(safe-area-inset-top) is 0, so the top bar's padding
did not clear the clock in the app's full-screen web view, and the back
arrow sat under it and could not be tapped. Seen in the simulator, the
built page served locally: the arrow and the title level with the
clock before, below the status bar after.

Also a launch argument, -ipx.noBadgePrompt, so a simulator run can get
past the badge permission dialog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:18:55 -05:00

79 lines
3.4 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]) { _, _ in }
}
/// 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 url = Server.resolveForPlayer("/api/feeds") else { return false }
var req = URLRequest(url: url)
req.timeoutInterval = 20
req.setValue("application/json", forHTTPHeaderField: "Accept")
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
let cfg = URLSessionConfiguration.default
cfg.httpCookieStorage = .shared
guard let (data, resp) = try? await URLSession(configuration: cfg).data(for: req),
(resp as? HTTPURLResponse)?.statusCode == 200,
let n = unread(inAll: data) else { return false }
try? await UNUserNotificationCenter.current().setBadgeCount(n)
return true
}
// 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)
}
}