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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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])
|
||||
|
||||
215
ios/App/App/TimeSafariNativeFetcher.swift
Normal file
215
ios/App/App/TimeSafariNativeFetcher.swift
Normal 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
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user