feat(ios): register Swift TimeSafariNativeFetcher for New Activity notifications

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.
This commit is contained in:
Jose Olarte III
2026-04-02 19:02:48 +08:00
parent 90e6603d52
commit 7d87a746f9
12 changed files with 432 additions and 129 deletions

View File

@@ -18,6 +18,7 @@
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */; };
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */; };
B7E1C4F82A9D3E506F1B2C8D /* TimeSafariNativeFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F8E2D91B4C5E60718293A4 /* TimeSafariNativeFetcher.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -59,6 +60,7 @@
C86585E52ED4577F00824752 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImageUtility.swift; sourceTree = "<group>"; };
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImagePlugin.swift; sourceTree = "<group>"; };
A3F8E2D91B4C5E60718293A4 /* TimeSafariNativeFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSafariNativeFetcher.swift; sourceTree = "<group>"; };
E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -140,6 +142,7 @@
children = (
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */,
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */,
A3F8E2D91B4C5E60718293A4 /* TimeSafariNativeFetcher.swift */,
C86585E52ED4577F00824752 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
@@ -359,6 +362,7 @@
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */,
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */,
B7E1C4F82A9D3E506F1B2C8D /* TimeSafariNativeFetcher.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -1,6 +1,7 @@
import UIKit
import Capacitor
import CapacitorCommunitySqlite
import TimesafariDailyNotificationPlugin
import UserNotifications
@UIApplicationMain
@@ -9,6 +10,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// New Activity / dual schedule: plugin requires a registered native fetcher before configureNativeFetcher (parity with Android setNativeFetcher).
DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)
// Set notification center delegate so notifications show in foreground and rollover is triggered
UNUserNotificationCenter.current().delegate = self
@@ -89,13 +93,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
/// Show notifications when app is in foreground and post DailyNotificationDelivered for rollover.
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
let userInfo = notification.request.content.userInfo
if let notificationId = userInfo["notification_id"] as? String,
let scheduledTime = userInfo["scheduled_time"] as? Int64 {
NotificationCenter.default.post(
name: NSNotification.Name("DailyNotificationDelivered"),
object: nil,
userInfo: ["notification_id": notificationId, "scheduled_time": scheduledTime]
)
if let notificationId = userInfo["notification_id"] as? String {
let scheduledTime: Int64? = {
if let v = userInfo["scheduled_time"] as? Int64 { return v }
if let n = userInfo["scheduled_time"] as? NSNumber { return n.int64Value }
if let i = userInfo["scheduled_time"] as? Int { return Int64(i) }
return nil
}()
if let scheduledTime = scheduledTime {
NotificationCenter.default.post(
name: NSNotification.Name("DailyNotificationDelivered"),
object: nil,
userInfo: ["notification_id": notificationId, "scheduled_time": scheduledTime]
)
}
}
if #available(iOS 14.0, *) {
completionHandler([.banner, .sound, .badge])

View File

@@ -0,0 +1,215 @@
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
),
]
}
}

View File

@@ -86,7 +86,7 @@ PODS:
- SQLCipher/common (4.9.0)
- SQLCipher/standard (4.9.0):
- SQLCipher/common
- TimesafariDailyNotificationPlugin (2.1.1):
- TimesafariDailyNotificationPlugin (3.0.0):
- Capacitor
- ZIPFoundation (0.9.19)
@@ -172,7 +172,7 @@ SPEC CHECKSUMS:
nanopb: 438bc412db1928dac798aa6fd75726007be04262
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
TimesafariDailyNotificationPlugin: ab9860e6ab9db8019f64f3c08f115a0c4ffd32d9
TimesafariDailyNotificationPlugin: 4a344236630d9209234d46a417d351ac9c27e1b0
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
PODFILE CHECKSUM: 6d92bfa46c6c2d31d19b8c0c38f56a8ae9fd222f