Add TimeSafariNativeFetcher (plansLastUpdatedBetween parity with Android) and call DailyNotificationPlugin.registerNativeFetcher from AppDelegate before JS configureNativeFetcher; broaden DailyNotificationDelivered scheduled_time types in willPresent. Wire the new file into the App target; normalize PBX object IDs to 24-char hex. Document plugin ≥3 handoff (consuming-app-handoff-ios-native-fetcher-chained-dual), refresh iOS/Android parity and notification-from-api-call file tables.
216 lines
8.5 KiB
Swift
216 lines
8.5 KiB
Swift
import Foundation
|
|
import TimesafariDailyNotificationPlugin
|
|
|
|
/// Native content fetcher for API-driven New Activity notifications on iOS.
|
|
/// Mirrors `TimeSafariNativeFetcher.java` (POST `plansLastUpdatedBetween`, starred plans, JWT pool, pagination).
|
|
final class TimeSafariNativeFetcher: NativeNotificationContentFetcher {
|
|
static let shared = TimeSafariNativeFetcher()
|
|
|
|
private let endpoint = "/api/v2/report/plansLastUpdatedBetween"
|
|
private let readTimeoutSec: TimeInterval = 15
|
|
private let maxRetries = 3
|
|
private let retryDelayMs = 1_000
|
|
|
|
/// Matches plugin `updateStarredPlans` storage (`DailyNotificationPlugin.swift`).
|
|
private let prefsStarredKey = "daily_notification_timesafari.starredPlanIds"
|
|
/// Matches Java `TimeSafariNativeFetcher` prefs namespace `daily_notification_timesafari` + `last_acked_jwt_id`.
|
|
private let prefsLastAckedKey = "daily_notification_timesafari.last_acked_jwt_id"
|
|
|
|
private var apiBaseUrl: String?
|
|
private var activeDid: String?
|
|
private var jwtToken: String?
|
|
private var jwtTokenPool: [String]?
|
|
|
|
private init() {}
|
|
|
|
func configure(apiBaseUrl: String, activeDid: String, jwtToken: String, jwtTokenPool: [String]?) {
|
|
self.apiBaseUrl = apiBaseUrl.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(
|
|
of: "/$",
|
|
with: "",
|
|
options: .regularExpression
|
|
)
|
|
self.activeDid = activeDid
|
|
self.jwtToken = jwtToken
|
|
self.jwtTokenPool = (jwtTokenPool?.isEmpty == false) ? jwtTokenPool : nil
|
|
}
|
|
|
|
func fetchContent(context: FetchContext) async throws -> [NotificationContent] {
|
|
try await fetchContentWithRetry(context: context, retryCount: 0)
|
|
}
|
|
|
|
/// One pool entry per UTC day (epoch day mod pool size); else primary `jwtToken` — same as Java.
|
|
private func selectBearerTokenForRequest() -> String? {
|
|
guard let pool = jwtTokenPool, !pool.isEmpty else { return jwtToken }
|
|
let epochDay = Int64(Date().timeIntervalSince1970 * 1000) / (24 * 60 * 60 * 1000)
|
|
let idx = Int(epochDay) % pool.count
|
|
let t = pool[idx]
|
|
if t.isEmpty { return jwtToken }
|
|
return t
|
|
}
|
|
|
|
private func fetchContentWithRetry(context: FetchContext, retryCount: Int) async throws -> [NotificationContent] {
|
|
guard let base = apiBaseUrl, !base.isEmpty,
|
|
activeDid != nil,
|
|
let bearer = selectBearerTokenForRequest(), !bearer.isEmpty
|
|
else {
|
|
NSLog("[TimeSafariNativeFetcher] Not configured; call configureNativeFetcher from JS first.")
|
|
return []
|
|
}
|
|
|
|
guard let url = URL(string: base + endpoint) else {
|
|
return []
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization")
|
|
request.timeoutInterval = readTimeoutSec
|
|
|
|
let planIds = getStarredPlanIds()
|
|
var afterId = getLastAcknowledgedJwtId() ?? "0"
|
|
if afterId.isEmpty { afterId = "0" }
|
|
|
|
let body: [String: Any] = [
|
|
"planIds": planIds,
|
|
"afterId": afterId,
|
|
]
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
|
|
NSLog(
|
|
"[TimeSafariNativeFetcher] POST \(endpoint) planCount=\(planIds.count) afterId=\(afterId.prefix(12))…"
|
|
)
|
|
|
|
let config = URLSessionConfiguration.ephemeral
|
|
config.timeoutIntervalForRequest = readTimeoutSec
|
|
config.timeoutIntervalForResource = readTimeoutSec
|
|
let session = URLSession(configuration: config)
|
|
|
|
do {
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let http = response as? HTTPURLResponse else {
|
|
return []
|
|
}
|
|
|
|
if http.statusCode == 200 {
|
|
let bodyStr = String(data: data, encoding: .utf8) ?? ""
|
|
let contents = parseApiResponse(responseBody: bodyStr, context: context)
|
|
if !contents.isEmpty {
|
|
updateLastAckedJwtIdFromResponse(responseBody: bodyStr)
|
|
}
|
|
return contents
|
|
}
|
|
|
|
if retryCount < maxRetries && (http.statusCode >= 500 || http.statusCode == 429) {
|
|
let delayMs = retryDelayMs * (1 << retryCount)
|
|
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
|
return try await fetchContentWithRetry(context: context, retryCount: retryCount + 1)
|
|
}
|
|
|
|
NSLog("[TimeSafariNativeFetcher] API error \(http.statusCode)")
|
|
return []
|
|
} catch {
|
|
NSLog("[TimeSafariNativeFetcher] Fetch failed: \(error.localizedDescription)")
|
|
if retryCount < maxRetries {
|
|
let delayMs = retryDelayMs * (1 << retryCount)
|
|
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
|
return try await fetchContentWithRetry(context: context, retryCount: retryCount + 1)
|
|
}
|
|
return []
|
|
}
|
|
}
|
|
|
|
private func getStarredPlanIds() -> [String] {
|
|
guard let jsonStr = UserDefaults.standard.string(forKey: prefsStarredKey),
|
|
!jsonStr.isEmpty, jsonStr != "[]",
|
|
let data = jsonStr.data(using: .utf8),
|
|
let arr = try? JSONSerialization.jsonObject(with: data) as? [Any]
|
|
else {
|
|
return []
|
|
}
|
|
return arr.compactMap { $0 as? String }
|
|
}
|
|
|
|
private func getLastAcknowledgedJwtId() -> String? {
|
|
let s = UserDefaults.standard.string(forKey: prefsLastAckedKey)
|
|
return (s?.isEmpty == false) ? s : nil
|
|
}
|
|
|
|
private func updateLastAckedJwtIdFromResponse(responseBody: String) {
|
|
guard let data = responseBody.data(using: .utf8),
|
|
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let dataArray = root["data"] as? [[String: Any]], !dataArray.isEmpty
|
|
else { return }
|
|
|
|
let lastItem = dataArray[dataArray.count - 1]
|
|
var jwtId: String?
|
|
if let j = lastItem["jwtId"] as? String {
|
|
jwtId = j
|
|
} else if let plan = lastItem["plan"] as? [String: Any], let j = plan["jwtId"] as? String {
|
|
jwtId = j
|
|
}
|
|
if let jwtId = jwtId, !jwtId.isEmpty {
|
|
UserDefaults.standard.set(jwtId, forKey: prefsLastAckedKey)
|
|
}
|
|
}
|
|
|
|
private func extractProjectDisplayTitle(_ item: [String: Any]) -> String {
|
|
if let plan = item["plan"] as? [String: Any],
|
|
let name = plan["name"] as? String,
|
|
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
return name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
return "Unnamed Project"
|
|
}
|
|
|
|
private func extractJwtIdFromItem(_ item: [String: Any]) -> String? {
|
|
if let plan = item["plan"] as? [String: Any], let j = plan["jwtId"] as? String, !j.isEmpty {
|
|
return j
|
|
}
|
|
if let j = item["jwtId"] as? String, !j.isEmpty { return j }
|
|
return nil
|
|
}
|
|
|
|
private func parseApiResponse(responseBody: String, context: FetchContext) -> [NotificationContent] {
|
|
guard let data = responseBody.data(using: .utf8),
|
|
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let dataArray = root["data"] as? [[String: Any]], !dataArray.isEmpty
|
|
else {
|
|
return []
|
|
}
|
|
|
|
let firstItem = dataArray[0]
|
|
let firstTitle = extractProjectDisplayTitle(firstItem)
|
|
let jwtId = extractJwtIdFromItem(firstItem)
|
|
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
let scheduledMs: Int64 = context.scheduledTimeMillis ?? (nowMs + 3_600_000)
|
|
|
|
let n = dataArray.count
|
|
let quotedFirst = "\u{201C}\(firstTitle)\u{201D}"
|
|
let title: String
|
|
let body: String
|
|
if n == 1 {
|
|
title = "Starred Project Update"
|
|
body = "\(quotedFirst) has been updated."
|
|
} else {
|
|
title = "Starred Project Updates"
|
|
let more = n - 1
|
|
body = "\(quotedFirst) + \(more) more have been updated."
|
|
}
|
|
|
|
let id = "endorser_\(jwtId ?? "batch_\(nowMs)")"
|
|
return [
|
|
NotificationContent(
|
|
id: id,
|
|
title: title,
|
|
body: body,
|
|
scheduledTime: scheduledMs,
|
|
fetchedAt: nowMs,
|
|
url: apiBaseUrl,
|
|
payload: nil,
|
|
etag: nil
|
|
),
|
|
]
|
|
}
|
|
}
|