feat: implement cross-platform features and UI integration
- iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker, BookmarkViewModel, FeedViewModel - iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker services - Linux: Add settings-store.vala, State.vala signals, view widgets (FeedList, FeedDetail, AddFeed, Search, Settings, Bookmark) - Linux: Add bookmark-store.vala, bookmark vala model, search-service.vala - Android: Add NotificationService, NotificationManager, NotificationPreferencesStore - Android: Add BookmarkDao, BookmarkRepository, SettingsStore - Add unit tests for iOS, Android, Linux - Add integration tests - Add performance benchmarks - Update tasks and documentation Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
120
native-route/ios/RSSuper/AppDelegate.swift
Normal file
120
native-route/ios/RSSuper/AppDelegate.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
|
||||
var notificationManager: NotificationManager?
|
||||
var notificationPreferencesStore: NotificationPreferencesStore?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Initialize notification manager
|
||||
notificationManager = NotificationManager.shared
|
||||
notificationPreferencesStore = NotificationPreferencesStore.shared
|
||||
|
||||
// Initialize notification manager
|
||||
notificationManager?.initialize()
|
||||
|
||||
// Set up notification center delegate
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
// Update badge count when app comes to foreground
|
||||
notificationCenter.addObserver(
|
||||
self,
|
||||
selector: #selector(updateBadgeCount),
|
||||
name: Notification.Name("badgeUpdate"),
|
||||
object: nil
|
||||
)
|
||||
|
||||
print("AppDelegate: App launched")
|
||||
return true
|
||||
}
|
||||
|
||||
/// Update badge count when app comes to foreground
|
||||
@objc func updateBadgeCount() {
|
||||
if let count = notificationManager?.unreadCount() {
|
||||
print("Badge count updated: \(count)")
|
||||
}
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
|
||||
print("Scene sessions discarded")
|
||||
}
|
||||
|
||||
// MARK: - Notification Center Delegate
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
// Get notification content
|
||||
let content = notification.content
|
||||
|
||||
// Determine presentation options based on urgency
|
||||
let category = content.categoryIdentifier
|
||||
let options: UNNotificationPresentationOptions = [
|
||||
.banner,
|
||||
.sound,
|
||||
.badge
|
||||
]
|
||||
|
||||
if category == "Critical" {
|
||||
options.insert(.criticalAlert)
|
||||
options.insert(.sound)
|
||||
} else if category == "Low Priority" {
|
||||
options.remove(.sound)
|
||||
} else {
|
||||
options.remove(.sound)
|
||||
}
|
||||
|
||||
completionHandler(options)
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
// Handle notification click
|
||||
let action = response.action
|
||||
let identifier = action.identifier
|
||||
|
||||
print("Notification clicked: \(identifier)")
|
||||
|
||||
// Open app when notification is clicked
|
||||
if identifier == "openApp" {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||
let window = windowScene.windows.first
|
||||
window?.makeKeyAndVisible()
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
// Handle notification click
|
||||
let action = response.action
|
||||
let identifier = action.identifier
|
||||
|
||||
print("Notification clicked: \(identifier)")
|
||||
|
||||
// Open app when notification is clicked
|
||||
if identifier == "openApp" {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||
let window = windowScene.windows.first
|
||||
window?.makeKeyAndVisible()
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Center Extension
|
||||
|
||||
extension Notification.Name {
|
||||
static let badgeUpdate = Notification.Name("badgeUpdate")
|
||||
}
|
||||
|
||||
234
native-route/ios/RSSuper/BackgroundSyncService.swift
Normal file
234
native-route/ios/RSSuper/BackgroundSyncService.swift
Normal file
@@ -0,0 +1,234 @@
|
||||
import Foundation
|
||||
import BackgroundTasks
|
||||
|
||||
/// Main background sync service coordinator
|
||||
/// Orchestrates background feed synchronization using BGTaskScheduler
|
||||
final class BackgroundSyncService {
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = BackgroundSyncService()
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// Identifier for the background refresh task
|
||||
static let backgroundRefreshIdentifier = "com.rssuper.backgroundRefresh"
|
||||
|
||||
/// Identifier for the periodic sync task
|
||||
static let periodicSyncIdentifier = "com.rssuper.periodicSync"
|
||||
|
||||
private let syncScheduler: SyncScheduler
|
||||
private let syncWorker: SyncWorker
|
||||
|
||||
/// Current sync state
|
||||
private var isSyncing: Bool = false
|
||||
|
||||
/// Last successful sync date
|
||||
var lastSyncDate: Date?
|
||||
|
||||
/// Pending feeds count
|
||||
var pendingFeedsCount: Int = 0
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init() {
|
||||
self.syncScheduler = SyncScheduler()
|
||||
self.syncWorker = SyncWorker()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Register background tasks with the system
|
||||
func registerBackgroundTasks() {
|
||||
// Register app refresh task
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.backgroundRefreshIdentifier,
|
||||
with: nil) { task in
|
||||
self.handleBackgroundTask(task)
|
||||
}
|
||||
|
||||
// Register periodic sync task (if available on device)
|
||||
BGTaskScheduler.shared.register(forTaskIdentifier: Self.periodicSyncIdentifier,
|
||||
with: nil) { task in
|
||||
self.handlePeriodicSync(task)
|
||||
}
|
||||
|
||||
print("✓ Background tasks registered")
|
||||
}
|
||||
|
||||
/// Schedule a background refresh task
|
||||
func scheduleBackgroundRefresh() -> Bool {
|
||||
guard !isSyncing else {
|
||||
print("⚠️ Sync already in progress")
|
||||
return false
|
||||
}
|
||||
|
||||
let taskRequest = BGAppRefreshTaskRequest(identifier: Self.backgroundRefreshIdentifier)
|
||||
|
||||
// Schedule between 15 minutes and 4 hours from now
|
||||
taskRequest.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
|
||||
|
||||
// Set retry interval (minimum 15 minutes)
|
||||
taskRequest.requiredReasons = [.networkAvailable]
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(taskRequest)
|
||||
print("✓ Background refresh scheduled")
|
||||
return true
|
||||
} catch {
|
||||
print("❌ Failed to schedule background refresh: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule periodic sync (iOS 13+) with custom interval
|
||||
func schedulePeriodicSync(interval: TimeInterval = 6 * 3600) -> Bool {
|
||||
guard !isSyncing else {
|
||||
print("⚠️ Sync already in progress")
|
||||
return false
|
||||
}
|
||||
|
||||
let taskRequest = BGProcessingTaskRequest(identifier: Self.periodicSyncIdentifier)
|
||||
taskRequest.requiresNetworkConnectivity = true
|
||||
taskRequest.requiresExternalPower = false // Allow on battery
|
||||
taskRequest.minimumInterval = interval
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(taskRequest)
|
||||
print("✓ Periodic sync scheduled (interval: \(interval/3600)h)")
|
||||
return true
|
||||
} catch {
|
||||
print("❌ Failed to schedule periodic sync: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel all pending background tasks
|
||||
func cancelAllPendingTasks() {
|
||||
BGTaskScheduler.shared.cancelAllTaskRequests()
|
||||
print("✓ All pending background tasks cancelled")
|
||||
}
|
||||
|
||||
/// Get pending task requests
|
||||
func getPendingTaskRequests() async -> [BGTaskScheduler.PendingTaskRequest] {
|
||||
do {
|
||||
let requests = try await BGTaskScheduler.shared.pendingTaskRequests()
|
||||
return requests
|
||||
} catch {
|
||||
print("❌ Failed to get pending tasks: \(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Force immediate sync (for testing or user-initiated)
|
||||
func forceSync() async {
|
||||
guard !isSyncing else {
|
||||
print("⚠️ Sync already in progress")
|
||||
return
|
||||
}
|
||||
|
||||
isSyncing = true
|
||||
|
||||
do {
|
||||
let result = try await syncWorker.performSync()
|
||||
lastSyncDate = Date()
|
||||
|
||||
print("✓ Force sync completed: \(result.feedsSynced) feeds, \(result.articlesFetched) articles")
|
||||
|
||||
// Schedule next background refresh
|
||||
scheduleBackgroundRefresh()
|
||||
|
||||
} catch {
|
||||
print("❌ Force sync failed: \(error)")
|
||||
}
|
||||
|
||||
isSyncing = false
|
||||
}
|
||||
|
||||
/// Check if background tasks are enabled
|
||||
func areBackgroundTasksEnabled() -> Bool {
|
||||
// Check if Background Modes capability is enabled
|
||||
// This is a basic check; more sophisticated checks can be added
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Handle background app refresh task
|
||||
private func handleBackgroundTask(_ task: BGTask) {
|
||||
guard let appRefreshTask = task as? BGAppRefreshTask else {
|
||||
print("❌ Unexpected task type")
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
print("🔄 Background refresh task started (expiration: \(appRefreshTask.expirationDate))")
|
||||
|
||||
isSyncing = true
|
||||
|
||||
Task(priority: .userInitiated) {
|
||||
do {
|
||||
let result = try await syncWorker.performSync()
|
||||
|
||||
lastSyncDate = Date()
|
||||
|
||||
print("✓ Background refresh completed: \(result.feedsSynced) feeds, \(result.articlesFetched) articles")
|
||||
|
||||
// Re-schedule the task
|
||||
scheduleBackgroundRefresh()
|
||||
|
||||
task.setTaskCompleted(success: true)
|
||||
|
||||
} catch {
|
||||
print("❌ Background refresh failed: \(error)")
|
||||
task.setTaskCompleted(success: false, retryAttempted: true)
|
||||
}
|
||||
|
||||
isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle periodic sync task
|
||||
private func handlePeriodicSync(_ task: BGTask) {
|
||||
guard let processingTask = task as? BGProcessingTask else {
|
||||
print("❌ Unexpected task type")
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
print("🔄 Periodic sync task started (expiration: \(processingTask.expirationDate))")
|
||||
|
||||
isSyncing = true
|
||||
|
||||
Task(priority: .utility) {
|
||||
do {
|
||||
let result = try await syncWorker.performSync()
|
||||
|
||||
lastSyncDate = Date()
|
||||
|
||||
print("✓ Periodic sync completed: \(result.feedsSynced) feeds, \(result.articlesFetched) articles")
|
||||
|
||||
task.setTaskCompleted(success: true)
|
||||
|
||||
} catch {
|
||||
print("❌ Periodic sync failed: \(error)")
|
||||
task.setTaskCompleted(success: false, retryAttempted: true)
|
||||
}
|
||||
|
||||
isSyncing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SyncResult
|
||||
|
||||
/// Result of a sync operation
|
||||
struct SyncResult {
|
||||
let feedsSynced: Int
|
||||
let articlesFetched: Int
|
||||
let errors: [Error]
|
||||
|
||||
init(feedsSynced: Int = 0, articlesFetched: Int = 0, errors: [Error] = []) {
|
||||
self.feedsSynced = feedsSynced
|
||||
self.articlesFetched = articlesFetched
|
||||
self.errors = errors
|
||||
}
|
||||
}
|
||||
55
native-route/ios/RSSuper/Info.plist
Normal file
55
native-route/ios/RSSuper/Info.plist
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>We need your location to provide nearby feed updates.</string>
|
||||
<key>NSUserNotificationsUsageDescription</key>
|
||||
<string>We need permission to send you RSSuper notifications for new articles and feed updates.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>primary</string>
|
||||
<key>UIImageName</key>
|
||||
<string>logo</string>
|
||||
</dict>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<false/>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
109
native-route/ios/RSSuper/RefreshFeedsAppIntent.swift
Normal file
109
native-route/ios/RSSuper/RefreshFeedsAppIntent.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
import AppIntents
|
||||
|
||||
/// AppIntent for background feed refresh
|
||||
/// Allows users to create Shortcuts for manual feed refresh
|
||||
struct RefreshFeedsAppIntent: AppIntent {
|
||||
static var title: LocalizedStringResource {
|
||||
"Refresh Feeds"
|
||||
}
|
||||
|
||||
static var description: LocalizedStringResource {
|
||||
"Manually refresh all subscribed feeds"
|
||||
}
|
||||
|
||||
static var intentIdentifier: String {
|
||||
"refreshFeeds"
|
||||
}
|
||||
|
||||
static var openAppAfterRun: Bool {
|
||||
false // Don't open app after background refresh
|
||||
}
|
||||
|
||||
@Parameter(title: "Refresh All", default: true)
|
||||
var refreshAll: Bool
|
||||
|
||||
@Parameter(title: "Specific Feed", default: "")
|
||||
var feedId: String
|
||||
|
||||
init() {}
|
||||
|
||||
init(refreshAll: Bool, feedId: String) {
|
||||
self.refreshAll = refreshAll
|
||||
self.feedId = feedId
|
||||
}
|
||||
|
||||
func perform() async throws -> RefreshFeedsResult {
|
||||
// Check if we have network connectivity
|
||||
guard await checkNetworkConnectivity() else {
|
||||
return RefreshFeedsResult(
|
||||
status: .failed,
|
||||
message: "No network connectivity",
|
||||
feedsRefreshed: 0
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
if refreshAll {
|
||||
// Refresh all feeds
|
||||
let result = try await BackgroundSyncService.shared.forceSync()
|
||||
|
||||
return RefreshFeedsResult(
|
||||
status: .success,
|
||||
message: "All feeds refreshed",
|
||||
feedsRefreshed: result.feedsSynced
|
||||
)
|
||||
} else if !feedId.isEmpty {
|
||||
// Refresh specific feed
|
||||
let result = try await BackgroundSyncService.shared.performPartialSync(
|
||||
subscriptionIds: [feedId]
|
||||
)
|
||||
|
||||
return RefreshFeedsResult(
|
||||
status: .success,
|
||||
message: "Feed refreshed",
|
||||
feedsRefreshed: result.feedsSynced
|
||||
)
|
||||
} else {
|
||||
return RefreshFeedsResult(
|
||||
status: .failed,
|
||||
message: "No feed specified",
|
||||
feedsRefreshed: 0
|
||||
)
|
||||
}
|
||||
|
||||
} catch {
|
||||
return RefreshFeedsResult(
|
||||
status: .failed,
|
||||
message: error.localizedDescription,
|
||||
feedsRefreshed: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func checkNetworkConnectivity() async -> Bool {
|
||||
// TODO: Implement actual network connectivity check
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of RefreshFeedsAppIntent
|
||||
struct RefreshFeedsResult: AppIntentResult {
|
||||
enum Status: String, Codable {
|
||||
case success
|
||||
case failed
|
||||
}
|
||||
|
||||
var status: Status
|
||||
var message: String
|
||||
var feedsRefreshed: Int
|
||||
|
||||
var title: String {
|
||||
switch status {
|
||||
case .success:
|
||||
return "✓ Refreshed \(feedsRefreshed) feed(s)"
|
||||
case .failed:
|
||||
return "✗ Refresh failed: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
209
native-route/ios/RSSuper/Services/NotificationManager.swift
Normal file
209
native-route/ios/RSSuper/Services/NotificationManager.swift
Normal file
@@ -0,0 +1,209 @@
|
||||
import UserNotifications
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Notification manager for iOS RSSuper
|
||||
/// Coordinates notifications, badge management, and preference storage
|
||||
class NotificationManager {
|
||||
|
||||
static let shared = NotificationManager()
|
||||
|
||||
private let notificationService = NotificationService.shared
|
||||
private let notificationCenter = NotificationCenter.default
|
||||
private let defaultBadgeIcon: String = "rssuper-icon"
|
||||
|
||||
private var unreadCount = 0
|
||||
private var badgeVisible = true
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Initialize the notification manager
|
||||
func initialize() {
|
||||
notificationService.initialize(self)
|
||||
loadBadgeCount()
|
||||
|
||||
// Set up badge visibility
|
||||
if badgeVisible {
|
||||
showBadge()
|
||||
} else {
|
||||
hideBadge()
|
||||
}
|
||||
|
||||
print("NotificationManager initialized")
|
||||
}
|
||||
|
||||
/// Load saved badge count
|
||||
private func loadBadgeCount() {
|
||||
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
|
||||
|
||||
if let count = appDelegate.notificationManager?.badgeCount {
|
||||
self.unreadCount = count
|
||||
updateBadgeLabel(label: String(count))
|
||||
}
|
||||
}
|
||||
|
||||
/// Show badge
|
||||
func showBadge() {
|
||||
guard badgeVisible else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.notificationCenter.post(name: .badgeUpdate, object: nil)
|
||||
}
|
||||
|
||||
print("Badge shown")
|
||||
}
|
||||
|
||||
/// Hide badge
|
||||
func hideBadge() {
|
||||
DispatchQueue.main.async {
|
||||
self.notificationCenter.post(name: .badgeUpdate, object: nil)
|
||||
}
|
||||
|
||||
print("Badge hidden")
|
||||
}
|
||||
|
||||
/// Update badge with count
|
||||
func updateBadge(label: String) {
|
||||
DispatchQueue.main.async {
|
||||
self.updateBadgeLabel(label: label)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update badge label
|
||||
private func updateBadgeLabel(label: String) {
|
||||
let badge = UNNotificationBadgeManager()
|
||||
badge.badgeCount = Int(label) ?? 0
|
||||
badge.badgeIcon = defaultBadgeIcon
|
||||
badge.badgePosition = .center
|
||||
badge.badgeBackground = UIColor.systemBackground
|
||||
badge.badgeText = label
|
||||
badge.badgeTextColor = .black
|
||||
badge.badgeFont = .preferredFont(forTextStyle: .body)
|
||||
badge.badgeCornerRadius = 0
|
||||
badge.badgeBorder = nil
|
||||
badge.badgeShadow = nil
|
||||
badge.badgeCornerRadius = 0
|
||||
badge.badgeBorder = nil
|
||||
badge.badgeShadow = nil
|
||||
badge.badgeCornerRadius = 0
|
||||
badge.badgeBorder = nil
|
||||
badge.badgeShadow = nil
|
||||
badge.badgeCornerRadius = 0
|
||||
badge.badgeBorder = nil
|
||||
badge.badgeShadow = nil
|
||||
badge.badgeCornerRadius = 0
|
||||
badge.badgeBorder = nil
|
||||
badge.badgeShadow = nil
|
||||
badge.badgeCornerRadius = 0
|
||||
badge.badgeBorder = nil
|
||||
badge.badgeShadow = nil
|
||||
badge.badgeCornerRadius = 0
|
||||
badge.badgeBorder = nil
|
||||
badge.badgeShadow = nil
|
||||
}
|
||||
|
||||
/// Set unread count
|
||||
func setUnreadCount(_ count: Int) {
|
||||
unreadCount = count
|
||||
|
||||
// Update badge
|
||||
if count > 0 {
|
||||
showBadge()
|
||||
} else {
|
||||
hideBadge()
|
||||
}
|
||||
|
||||
// Update badge label
|
||||
updateBadge(label: String(count))
|
||||
}
|
||||
|
||||
/// Clear unread count
|
||||
func clearUnreadCount() {
|
||||
unreadCount = 0
|
||||
hideBadge()
|
||||
updateBadge(label: "0")
|
||||
}
|
||||
|
||||
/// Get unread count
|
||||
func unreadCount() -> Int {
|
||||
return unreadCount
|
||||
}
|
||||
|
||||
/// Get badge visibility
|
||||
func badgeVisibility() -> Bool {
|
||||
return badgeVisible
|
||||
}
|
||||
|
||||
/// Set badge visibility
|
||||
func setBadgeVisibility(_ visible: Bool) {
|
||||
badgeVisible = visible
|
||||
|
||||
if visible {
|
||||
showBadge()
|
||||
} else {
|
||||
hideBadge()
|
||||
}
|
||||
}
|
||||
|
||||
/// Show notification with badge
|
||||
func showWithBadge(title: String, body: String, icon: String, urgency: NotificationUrgency) {
|
||||
let notification = notificationService.showNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
icon: icon,
|
||||
urgency: urgency
|
||||
)
|
||||
|
||||
if unreadCount == 0 {
|
||||
showBadge()
|
||||
}
|
||||
}
|
||||
|
||||
/// Show notification without badge
|
||||
func showWithoutBadge(title: String, body: String, icon: String, urgency: NotificationUrgency) {
|
||||
let notification = notificationService.showNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
icon: icon,
|
||||
urgency: urgency
|
||||
)
|
||||
}
|
||||
|
||||
/// Show critical notification
|
||||
func showCritical(title: String, body: String, icon: String) {
|
||||
showWithBadge(title: title, body: body, icon: icon, urgency: .critical)
|
||||
}
|
||||
|
||||
/// Show low priority notification
|
||||
func showLow(title: String, body: String, icon: String) {
|
||||
showWithBadge(title: title, body: body, icon: icon, urgency: .low)
|
||||
}
|
||||
|
||||
/// Show normal notification
|
||||
func showNormal(title: String, body: String, icon: String) {
|
||||
showWithBadge(title: title, body: body, icon: icon, urgency: .normal)
|
||||
}
|
||||
|
||||
/// Get notification service
|
||||
func notificationService() -> NotificationService {
|
||||
return notificationService
|
||||
}
|
||||
|
||||
/// Get notification center
|
||||
func notificationCenter() -> UNUserNotificationCenter {
|
||||
return notificationService.notificationCenter()
|
||||
}
|
||||
|
||||
/// Check if notification manager is available
|
||||
func isAvailable() -> Bool {
|
||||
return notificationService.isAvailable
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Center Extensions
|
||||
|
||||
extension Notification.Name {
|
||||
static let badgeUpdate = Notification.Name("badgeUpdate")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import Combine
|
||||
|
||||
/// Notification preferences store for iOS RSSuper
|
||||
/// Provides persistent storage for user notification settings
|
||||
class NotificationPreferencesStore {
|
||||
|
||||
static let shared = NotificationPreferencesStore()
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let prefsKey = "notification_prefs"
|
||||
|
||||
private var preferences: NotificationPreferences?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private init() {
|
||||
loadPreferences()
|
||||
}
|
||||
|
||||
/// Load saved preferences
|
||||
private func loadPreferences() {
|
||||
guard let json = defaults.string(forKey: prefsKey) else {
|
||||
// Set default preferences
|
||||
preferences = NotificationPreferences()
|
||||
defaults.set(json, forKey: prefsKey)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
preferences = try JSONDecoder().decode(NotificationPreferences.self, from: Data(json))
|
||||
} catch {
|
||||
print("Failed to decode preferences: \(error)")
|
||||
preferences = NotificationPreferences()
|
||||
}
|
||||
}
|
||||
|
||||
/// Save preferences
|
||||
func savePreferences(_ prefs: NotificationPreferences) {
|
||||
if let json = try? JSONEncoder().encode(prefs) {
|
||||
defaults.set(json, forKey: prefsKey)
|
||||
}
|
||||
preferences = prefs
|
||||
}
|
||||
|
||||
/// Get notification preferences
|
||||
func preferences() -> NotificationPreferences? {
|
||||
return preferences
|
||||
}
|
||||
|
||||
/// Get new articles preference
|
||||
func isNewArticlesEnabled() -> Bool {
|
||||
return preferences?.newArticles ?? true
|
||||
}
|
||||
|
||||
/// Set new articles preference
|
||||
func setNewArticles(_ enabled: Bool) {
|
||||
preferences?.newArticles = enabled
|
||||
savePreferences(preferences ?? NotificationPreferences())
|
||||
}
|
||||
|
||||
/// Get episode releases preference
|
||||
func isEpisodeReleasesEnabled() -> Bool {
|
||||
return preferences?.episodeReleases ?? true
|
||||
}
|
||||
|
||||
/// Set episode releases preference
|
||||
func setEpisodeReleases(_ enabled: Bool) {
|
||||
preferences?.episodeReleases = enabled
|
||||
savePreferences(preferences ?? NotificationPreferences())
|
||||
}
|
||||
|
||||
/// Get custom alerts preference
|
||||
func isCustomAlertsEnabled() -> Bool {
|
||||
return preferences?.customAlerts ?? true
|
||||
}
|
||||
|
||||
/// Set custom alerts preference
|
||||
func setCustomAlerts(_ enabled: Bool) {
|
||||
preferences?.customAlerts = enabled
|
||||
savePreferences(preferences ?? NotificationPreferences())
|
||||
}
|
||||
|
||||
/// Get badge count preference
|
||||
func isBadgeCountEnabled() -> Bool {
|
||||
return preferences?.badgeCount ?? true
|
||||
}
|
||||
|
||||
/// Set badge count preference
|
||||
func setBadgeCount(_ enabled: Bool) {
|
||||
preferences?.badgeCount = enabled
|
||||
savePreferences(preferences ?? NotificationPreferences())
|
||||
}
|
||||
|
||||
/// Get sound preference
|
||||
func isSoundEnabled() -> Bool {
|
||||
return preferences?.sound ?? true
|
||||
}
|
||||
|
||||
/// Set sound preference
|
||||
func setSound(_ enabled: Bool) {
|
||||
preferences?.sound = enabled
|
||||
savePreferences(preferences ?? NotificationPreferences())
|
||||
}
|
||||
|
||||
/// Get vibration preference
|
||||
func isVibrationEnabled() -> Bool {
|
||||
return preferences?.vibration ?? true
|
||||
}
|
||||
|
||||
/// Set vibration preference
|
||||
func setVibration(_ enabled: Bool) {
|
||||
preferences?.vibration = enabled
|
||||
savePreferences(preferences ?? NotificationPreferences())
|
||||
}
|
||||
|
||||
/// Enable all notifications
|
||||
func enableAll() {
|
||||
preferences = NotificationPreferences()
|
||||
savePreferences(preferences ?? NotificationPreferences())
|
||||
}
|
||||
|
||||
/// Disable all notifications
|
||||
func disableAll() {
|
||||
preferences = NotificationPreferences(
|
||||
newArticles: false,
|
||||
episodeReleases: false,
|
||||
customAlerts: false,
|
||||
badgeCount: false,
|
||||
sound: false,
|
||||
vibration: false
|
||||
)
|
||||
savePreferences(preferences ?? NotificationPreferences())
|
||||
}
|
||||
|
||||
/// Get all preferences as dictionary
|
||||
func allPreferences() -> [String: Bool] {
|
||||
guard let prefs = preferences else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
return [
|
||||
"newArticles": prefs.newArticles,
|
||||
"episodeReleases": prefs.episodeReleases,
|
||||
"customAlerts": prefs.customAlerts,
|
||||
"badgeCount": prefs.badgeCount,
|
||||
"sound": prefs.sound,
|
||||
"vibration": prefs.vibration
|
||||
]
|
||||
}
|
||||
|
||||
/// Set all preferences from dictionary
|
||||
func setAllPreferences(_ prefs: [String: Bool]) {
|
||||
let notificationPrefs = NotificationPreferences(
|
||||
newArticles: prefs["newArticles"] ?? true,
|
||||
episodeReleases: prefs["episodeReleases"] ?? true,
|
||||
customAlerts: prefs["customAlerts"] ?? true,
|
||||
badgeCount: prefs["badgeCount"] ?? true,
|
||||
sound: prefs["sound"] ?? true,
|
||||
vibration: prefs["vibration"] ?? true
|
||||
)
|
||||
|
||||
preferences = notificationPrefs
|
||||
defaults.set(try? JSONEncoder().encode(notificationPrefs), forKey: prefsKey)
|
||||
}
|
||||
|
||||
/// Get preferences key
|
||||
func prefsKey() -> String {
|
||||
return prefsKey
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification preferences data class
|
||||
@objcMembers
|
||||
struct NotificationPreferences: Codable {
|
||||
var newArticles: Bool = true
|
||||
var episodeReleases: Bool = true
|
||||
var customAlerts: Bool = true
|
||||
var badgeCount: Bool = true
|
||||
var sound: Bool = true
|
||||
var vibration: Bool = true
|
||||
}
|
||||
|
||||
276
native-route/ios/RSSuper/Services/NotificationService.swift
Normal file
276
native-route/ios/RSSuper/Services/NotificationService.swift
Normal file
@@ -0,0 +1,276 @@
|
||||
import UserNotifications
|
||||
import Foundation
|
||||
|
||||
/// Main notification service for iOS RSSuper
|
||||
/// Handles push and local notifications using UserNotifications framework
|
||||
class NotificationService {
|
||||
|
||||
static let shared = NotificationService()
|
||||
|
||||
private let unuserNotifications = UNUserNotificationCenter.current()
|
||||
private let notificationCenter = NotificationCenter.default
|
||||
private let defaultNotificationCategory = "Default"
|
||||
private let criticalNotificationCategory = "Critical"
|
||||
private let lowPriorityNotificationCategory = "Low Priority"
|
||||
|
||||
private let defaultIcon: String = "rssuper-icon"
|
||||
private let criticalIcon: String = "rssuper-icon"
|
||||
private let lowPriorityIcon: String = "rssuper-icon"
|
||||
|
||||
private let defaultTitle: String = "RSSuper"
|
||||
|
||||
private var isInitialized = false
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Initialize the notification service
|
||||
/// - Parameter context: Application context for initialization
|
||||
func initialize(_ context: Any) {
|
||||
guard !isInitialized else { return }
|
||||
|
||||
do {
|
||||
// Request authorization
|
||||
try requestAuthorization(context: context)
|
||||
|
||||
// Set default notification settings
|
||||
setDefaultNotificationSettings()
|
||||
|
||||
// Set up notification categories
|
||||
setNotificationCategories()
|
||||
|
||||
isInitialized = true
|
||||
print("NotificationService initialized")
|
||||
} catch {
|
||||
print("Failed to initialize NotificationService: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Request notification authorization
|
||||
/// - Parameter context: Application context
|
||||
private func requestAuthorization(context: Any) throws {
|
||||
let options: UNAuthorizationOptions = [.alert, .sound, .badge]
|
||||
|
||||
switch unuserNotifications.requestAuthorization(options: options) {
|
||||
case .authorized:
|
||||
print("Notification authorization authorized")
|
||||
case .denied:
|
||||
print("Notification authorization denied")
|
||||
case .restricted:
|
||||
print("Notification authorization restricted")
|
||||
case .notDetermined:
|
||||
print("Notification authorization not determined")
|
||||
@unknown default:
|
||||
print("Unknown notification authorization state")
|
||||
}
|
||||
}
|
||||
|
||||
/// Set default notification settings
|
||||
private func setDefaultNotificationSettings() {
|
||||
do {
|
||||
try unuserNotifications.setNotificationCategories([
|
||||
defaultNotificationCategory,
|
||||
criticalNotificationCategory,
|
||||
lowPriorityNotificationCategory
|
||||
], completionHandler: { _, error in
|
||||
if let error = error {
|
||||
print("Failed to set notification categories: \(error)")
|
||||
} else {
|
||||
print("Notification categories set successfully")
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
print("Failed to set default notification settings: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Set notification categories
|
||||
private func setNotificationCategories() {
|
||||
let categories = [
|
||||
UNNotificationCategory(
|
||||
identifier: defaultNotificationCategory,
|
||||
actions: [
|
||||
UNNotificationAction(
|
||||
identifier: "openApp",
|
||||
title: "Open App",
|
||||
options: .foreground
|
||||
),
|
||||
UNNotificationAction(
|
||||
identifier: "markRead",
|
||||
title: "Mark as Read",
|
||||
options: .previewClose
|
||||
)
|
||||
],
|
||||
intentIdentifiers: [],
|
||||
options: .initialDisplayOptions
|
||||
),
|
||||
UNNotificationCategory(
|
||||
identifier: criticalNotificationCategory,
|
||||
actions: [
|
||||
UNNotificationAction(
|
||||
identifier: "openApp",
|
||||
title: "Open App",
|
||||
options: .foreground
|
||||
)
|
||||
],
|
||||
intentIdentifiers: [],
|
||||
options: .criticalAlert
|
||||
),
|
||||
UNNotificationCategory(
|
||||
identifier: lowPriorityNotificationCategory,
|
||||
actions: [
|
||||
UNNotificationAction(
|
||||
identifier: "openApp",
|
||||
title: "Open App",
|
||||
options: .foreground
|
||||
)
|
||||
],
|
||||
intentIdentifiers: [],
|
||||
options: .initialDisplayOptions
|
||||
)
|
||||
]
|
||||
|
||||
do {
|
||||
try unuserNotifications.setNotificationCategories(categories, completionHandler: { _, error in
|
||||
if let error = error {
|
||||
print("Failed to set notification categories: \(error)")
|
||||
} else {
|
||||
print("Notification categories set successfully")
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
print("Failed to set notification categories: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a local notification
|
||||
/// - Parameters:
|
||||
/// - title: Notification title
|
||||
/// - body: Notification body
|
||||
/// - icon: Icon name
|
||||
/// - urgency: Notification urgency
|
||||
/// - contentDate: Scheduled content date
|
||||
/// - userInfo: Additional user info
|
||||
func showNotification(
|
||||
title: String,
|
||||
body: String,
|
||||
icon: String,
|
||||
urgency: NotificationUrgency = .normal,
|
||||
contentDate: Date? = nil,
|
||||
userInfo: [AnyHashable: Any]? = nil
|
||||
) {
|
||||
let urgency = NotificationUrgency(rawValue: urgency.rawValue) ?? .normal
|
||||
let notificationContent = UNMutableNotificationContent()
|
||||
|
||||
notificationContent.title = title
|
||||
notificationContent.body = body
|
||||
notificationContent.sound = UNNotificationSound.default
|
||||
notificationContent.icon = icon
|
||||
notificationContent.categoryIdentifier = urgency.rawValue
|
||||
notificationContent.haptic = .medium
|
||||
|
||||
if let contentDate = contentDate {
|
||||
notificationContent.date = contentDate
|
||||
}
|
||||
|
||||
if let userInfo = userInfo {
|
||||
notificationContent.userInfo = userInfo
|
||||
}
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: UUID().uuidString,
|
||||
content: notificationContent,
|
||||
trigger: contentDate.map { UNNotificationTrigger(dateMatched: $0, repeats: false) } ?? nil,
|
||||
priority: urgency.priority
|
||||
)
|
||||
|
||||
do {
|
||||
try unuserNotifications.add(request)
|
||||
unuserNotifications.presentNotificationRequest(request, completionHandler: nil)
|
||||
print("Notification shown: \(title)")
|
||||
} catch {
|
||||
print("Failed to show notification: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a critical notification
|
||||
/// - Parameters:
|
||||
/// - title: Notification title
|
||||
/// - body: Notification body
|
||||
/// - icon: Icon name
|
||||
func showCriticalNotification(title: String, body: String, icon: String) {
|
||||
showNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
icon: icon,
|
||||
urgency: .critical
|
||||
)
|
||||
}
|
||||
|
||||
/// Show a low priority notification
|
||||
/// - Parameters:
|
||||
/// - title: Notification title
|
||||
/// - body: Notification body
|
||||
/// - icon: Icon name
|
||||
func showLowPriorityNotification(title: String, body: String, icon: String) {
|
||||
showNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
icon: icon,
|
||||
urgency: .low
|
||||
)
|
||||
}
|
||||
|
||||
/// Show a normal priority notification
|
||||
/// - Parameters:
|
||||
/// - title: Notification title
|
||||
/// - body: Notification body
|
||||
/// - icon: Icon name
|
||||
func showNormalNotification(title: String, body: String, icon: String) {
|
||||
showNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
icon: icon,
|
||||
urgency: .normal
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if notification service is available
|
||||
var isAvailable: Bool {
|
||||
return UNUserNotificationCenter.current().isAuthorized(
|
||||
forNotificationTypes: [.alert, .sound, .badge]
|
||||
)
|
||||
}
|
||||
|
||||
/// Get available notification types
|
||||
var availableNotificationTypes: [UNNotificationType] {
|
||||
return unuserNotifications.authorizationStatus(
|
||||
forNotificationTypes: .all
|
||||
)
|
||||
}
|
||||
|
||||
/// Get current authorization status
|
||||
func authorizationStatus(for type: UNNotificationType) -> UNAuthorizationStatus {
|
||||
return unuserNotifications.authorizationStatus(for: type)
|
||||
}
|
||||
|
||||
/// Get the notification center
|
||||
func notificationCenter() -> UNUserNotificationCenter {
|
||||
return unuserNotifications
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification urgency enum
|
||||
enum NotificationUrgency: Int {
|
||||
case critical = 5
|
||||
case normal = 1
|
||||
case low = 0
|
||||
|
||||
var priority: UNNotificationPriority {
|
||||
switch self {
|
||||
case .critical: return .high
|
||||
case .normal: return .default
|
||||
case .low: return .low
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
193
native-route/ios/RSSuper/SyncScheduler.swift
Normal file
193
native-route/ios/RSSuper/SyncScheduler.swift
Normal file
@@ -0,0 +1,193 @@
|
||||
import Foundation
|
||||
import BackgroundTasks
|
||||
|
||||
/// Manages background sync scheduling
|
||||
/// Handles intelligent scheduling based on user behavior and system conditions
|
||||
final class SyncScheduler {
|
||||
// MARK: - Properties
|
||||
|
||||
/// Default sync interval (in seconds)
|
||||
static let defaultSyncInterval: TimeInterval = 6 * 3600 // 6 hours
|
||||
|
||||
/// Minimum sync interval (in seconds)
|
||||
static let minimumSyncInterval: TimeInterval = 15 * 60 // 15 minutes
|
||||
|
||||
/// Maximum sync interval (in seconds)
|
||||
static let maximumSyncInterval: TimeInterval = 24 * 3600 // 24 hours
|
||||
|
||||
/// Key for storing last sync date in UserDefaults
|
||||
private static let lastSyncDateKey = "RSSuperLastSyncDate"
|
||||
|
||||
/// Key for storing preferred sync interval
|
||||
private static let preferredSyncIntervalKey = "RSSuperPreferredSyncInterval"
|
||||
|
||||
/// UserDefaults for persisting sync state
|
||||
private let userDefaults = UserDefaults.standard
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Last sync date from UserDefaults
|
||||
var lastSyncDate: Date? {
|
||||
get { userDefaults.object(forKey: Self.lastSyncDateKey) as? Date }
|
||||
set { userDefaults.set(newValue, forKey: Self.lastSyncDateKey) }
|
||||
}
|
||||
|
||||
/// Preferred sync interval from UserDefaults
|
||||
var preferredSyncInterval: TimeInterval {
|
||||
get {
|
||||
return userDefaults.double(forKey: Self.preferredSyncIntervalKey)
|
||||
?? Self.defaultSyncInterval
|
||||
}
|
||||
set {
|
||||
let clamped = max(Self.minimumSyncInterval, min(newValue, Self.maximumSyncInterval))
|
||||
userDefaults.set(clamped, forKey: Self.preferredSyncIntervalKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Time since last sync
|
||||
var timeSinceLastSync: TimeInterval {
|
||||
guard let lastSync = lastSyncDate else {
|
||||
return .greatestFiniteMagnitude
|
||||
}
|
||||
return Date().timeIntervalSince(lastSync)
|
||||
}
|
||||
|
||||
/// Whether a sync is due
|
||||
var isSyncDue: Bool {
|
||||
return timeSinceLastSync >= preferredSyncInterval
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Schedule the next sync based on current conditions
|
||||
func scheduleNextSync() -> Bool {
|
||||
// Check if we should sync immediately
|
||||
if isSyncDue && timeSinceLastSync >= preferredSyncInterval * 2 {
|
||||
print("📱 Sync is significantly overdue, scheduling immediate sync")
|
||||
return scheduleImmediateSync()
|
||||
}
|
||||
|
||||
// Calculate next sync time
|
||||
let nextSyncTime = calculateNextSyncTime()
|
||||
|
||||
print("📅 Next sync scheduled for: \(nextSyncTime) (in \(nextSyncTime.timeIntervalSinceNow)/3600)h)")
|
||||
|
||||
return scheduleSync(at: nextSyncTime)
|
||||
}
|
||||
|
||||
/// Update preferred sync interval based on user behavior
|
||||
func updateSyncInterval(for numberOfFeeds: Int, userActivityLevel: UserActivityLevel) {
|
||||
var baseInterval: TimeInterval
|
||||
|
||||
// Adjust base interval based on number of feeds
|
||||
switch numberOfFeeds {
|
||||
case 0..<10:
|
||||
baseInterval = 4 * 3600 // 4 hours for small feed lists
|
||||
case 10..<50:
|
||||
baseInterval = 6 * 3600 // 6 hours for medium feed lists
|
||||
case 50..<200:
|
||||
baseInterval = 12 * 3600 // 12 hours for large feed lists
|
||||
default:
|
||||
baseInterval = 24 * 3600 // 24 hours for very large feed lists
|
||||
}
|
||||
|
||||
// Adjust based on user activity
|
||||
switch userActivityLevel {
|
||||
case .high:
|
||||
preferredSyncInterval = baseInterval * 0.5 // Sync more frequently
|
||||
case .medium:
|
||||
preferredSyncInterval = baseInterval
|
||||
case .low:
|
||||
preferredSyncInterval = baseInterval * 2.0 // Sync less frequently
|
||||
}
|
||||
|
||||
print("⚙️ Sync interval updated to: \(preferredSyncInterval/3600)h (feeds: \(numberOfFeeds), activity: \(userActivityLevel))")
|
||||
}
|
||||
|
||||
/// Get recommended sync interval based on current conditions
|
||||
func recommendedSyncInterval() -> TimeInterval {
|
||||
// This could be enhanced with machine learning based on user patterns
|
||||
return preferredSyncInterval
|
||||
}
|
||||
|
||||
/// Reset sync schedule
|
||||
func resetSyncSchedule() {
|
||||
lastSyncDate = nil
|
||||
preferredSyncInterval = Self.defaultSyncInterval
|
||||
print("🔄 Sync schedule reset")
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Schedule immediate sync
|
||||
private func scheduleImmediateSync() -> Bool {
|
||||
let taskRequest = BGAppRefreshTaskRequest(identifier: BackgroundSyncService.backgroundRefreshIdentifier)
|
||||
taskRequest.earliestBeginDate = Date(timeIntervalSinceNow: 60) // 1 minute
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(taskRequest)
|
||||
print("✓ Immediate sync scheduled")
|
||||
return true
|
||||
} catch {
|
||||
print("❌ Failed to schedule immediate sync: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule sync at specific time
|
||||
private func scheduleSync(at date: Date) -> Bool {
|
||||
let taskRequest = BGAppRefreshTaskRequest(identifier: BackgroundSyncService.backgroundRefreshIdentifier)
|
||||
taskRequest.earliestBeginDate = date
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(taskRequest)
|
||||
print("✓ Sync scheduled for \(date)")
|
||||
return true
|
||||
} catch {
|
||||
print("❌ Failed to schedule sync: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate next sync time
|
||||
private func calculateNextSyncTime() -> Date {
|
||||
let baseTime = lastSyncDate ?? Date()
|
||||
return baseTime.addingTimeInterval(preferredSyncInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UserActivityLevel
|
||||
|
||||
/// User activity level for adaptive sync scheduling
|
||||
enum UserActivityLevel: String, Codable {
|
||||
case high // User actively reading, sync more frequently
|
||||
case medium // Normal usage
|
||||
case low // Inactive user, sync less frequently
|
||||
|
||||
/// Calculate activity level based on app usage
|
||||
static func calculate(from dailyOpenCount: Int, lastOpenedAgo: TimeInterval) -> UserActivityLevel {
|
||||
// High activity: opened 5+ times today OR opened within last hour
|
||||
if dailyOpenCount >= 5 || lastOpenedAgo < 3600 {
|
||||
return .high
|
||||
}
|
||||
|
||||
// Medium activity: opened 2+ times today OR opened within last day
|
||||
if dailyOpenCount >= 2 || lastOpenedAgo < 86400 {
|
||||
return .medium
|
||||
}
|
||||
|
||||
// Low activity: otherwise
|
||||
return .low
|
||||
}
|
||||
}
|
||||
|
||||
extension SyncScheduler {
|
||||
static var lastSyncDate: Date? {
|
||||
get {
|
||||
return UserDefaults.standard.object(forKey: Self.lastSyncDateKey) as? Date
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: Self.lastSyncDateKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
227
native-route/ios/RSSuper/SyncWorker.swift
Normal file
227
native-route/ios/RSSuper/SyncWorker.swift
Normal file
@@ -0,0 +1,227 @@
|
||||
import Foundation
|
||||
|
||||
/// Performs the actual sync work
|
||||
/// Fetches updates from feeds and processes new articles
|
||||
final class SyncWorker {
|
||||
// MARK: - Properties
|
||||
|
||||
/// Maximum number of feeds to sync per batch
|
||||
static let maxFeedsPerBatch = 20
|
||||
|
||||
/// Timeout for individual feed fetch (in seconds)
|
||||
static let feedFetchTimeout: TimeInterval = 30
|
||||
|
||||
/// Maximum concurrent feed fetches
|
||||
static let maxConcurrentFetches = 3
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Perform a full sync operation
|
||||
func performSync() async throws -> SyncResult {
|
||||
var feedsSynced = 0
|
||||
var articlesFetched = 0
|
||||
var errors: [Error] = []
|
||||
|
||||
// Get all subscriptions that need syncing
|
||||
// TODO: Replace with actual database query
|
||||
let subscriptions = await fetchSubscriptionsNeedingSync()
|
||||
|
||||
print("📡 Starting sync for \(subscriptions.count) subscriptions")
|
||||
|
||||
// Process subscriptions in batches
|
||||
let batches = subscriptions.chunked(into: Self.maxFeedsPerBatch)
|
||||
|
||||
for batch in batches {
|
||||
let batchResults = try await syncBatch(batch)
|
||||
feedsSynced += batchResults.feedsSynced
|
||||
articlesFetched += batchResults.articlesFetched
|
||||
errors.append(contentsOf: batchResults.errors)
|
||||
|
||||
// Small delay between batches to be battery-friendly
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
|
||||
}
|
||||
|
||||
let result = SyncResult(
|
||||
feedsSynced: feedsSynced,
|
||||
articlesFetched: articlesFetched,
|
||||
errors: errors
|
||||
)
|
||||
|
||||
// Update last sync date
|
||||
SyncScheduler.lastSyncDate = Date()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Perform a partial sync for specific subscriptions
|
||||
func performPartialSync(subscriptionIds: [String]) async throws -> SyncResult {
|
||||
var feedsSynced = 0
|
||||
var articlesFetched = 0
|
||||
var errors: [Error] = []
|
||||
|
||||
// Filter subscriptions by IDs
|
||||
let allSubscriptions = await fetchSubscriptionsNeedingSync()
|
||||
let filteredSubscriptions = allSubscriptions.filter { subscriptionIds.contains($0.id) }
|
||||
|
||||
print("📡 Partial sync for \(filteredSubscriptions.count) subscriptions")
|
||||
|
||||
// Process in batches
|
||||
let batches = filteredSubscriptions.chunked(into: Self.maxFeedsPerBatch)
|
||||
|
||||
for batch in batches {
|
||||
let batchResults = try await syncBatch(batch)
|
||||
feedsSynced += batchResults.feedsSynced
|
||||
articlesFetched += batchResults.articlesFetched
|
||||
errors.append(contentsOf: batchResults.errors)
|
||||
}
|
||||
|
||||
return SyncResult(
|
||||
feedsSynced: feedsSynced,
|
||||
articlesFetched: articlesFetched,
|
||||
errors: errors
|
||||
)
|
||||
}
|
||||
|
||||
/// Cancel ongoing sync operations
|
||||
func cancelSync() {
|
||||
print("⏹️ Sync cancelled")
|
||||
// TODO: Cancel ongoing network requests
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Fetch subscriptions that need syncing
|
||||
private func fetchSubscriptionsNeedingSync() async -> [Subscription] {
|
||||
// TODO: Replace with actual database query
|
||||
// For now, return empty array as placeholder
|
||||
return []
|
||||
}
|
||||
|
||||
/// Sync a batch of subscriptions
|
||||
private func syncBatch(_ subscriptions: [Subscription]) async throws -> SyncResult {
|
||||
var feedsSynced = 0
|
||||
var articlesFetched = 0
|
||||
var errors: [Error] = []
|
||||
|
||||
// Fetch feeds concurrently with limit
|
||||
let feedResults = try await withThrowingTaskGroup(
|
||||
of: (Subscription, Result<FeedData, Error>).self
|
||||
) { group in
|
||||
var results: [(Subscription, Result<FeedData, Error>)] = []
|
||||
|
||||
for subscription in subscriptions {
|
||||
group.addTask {
|
||||
let result = await self.fetchFeedData(for: subscription)
|
||||
return (subscription, result)
|
||||
}
|
||||
}
|
||||
|
||||
while let result = try? await group.next() {
|
||||
results.append(result)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Process results
|
||||
for (subscription, result) in feedResults {
|
||||
switch result {
|
||||
case .success(let feedData):
|
||||
do {
|
||||
try await processFeedData(feedData, subscriptionId: subscription.id)
|
||||
feedsSynced += 1
|
||||
articlesFetched += feedData.articles.count
|
||||
} catch {
|
||||
errors.append(error)
|
||||
print("❌ Error processing feed data for \(subscription.title): \(error)")
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
errors.append(error)
|
||||
print("❌ Error fetching feed \(subscription.title): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
return SyncResult(
|
||||
feedsSynced: feedsSynced,
|
||||
articlesFetched: articlesFetched,
|
||||
errors: errors
|
||||
)
|
||||
}
|
||||
|
||||
/// Fetch feed data for a subscription
|
||||
private func fetchFeedData(for subscription: Subscription) async -> Result<FeedData, Error> {
|
||||
// TODO: Implement actual feed fetching
|
||||
// This is a placeholder implementation
|
||||
|
||||
do {
|
||||
// Create URL session with timeout
|
||||
let url = URL(string: subscription.url)!
|
||||
let (data, _) = try await URLSession.shared.data(
|
||||
from: url,
|
||||
timeoutInterval: Self.feedFetchTimeout
|
||||
)
|
||||
|
||||
// Parse RSS/Atom feed
|
||||
// TODO: Implement actual parsing
|
||||
let feedData = FeedData(
|
||||
title: subscription.title,
|
||||
articles: [], // TODO: Parse articles
|
||||
lastBuildDate: Date()
|
||||
)
|
||||
|
||||
return .success(feedData)
|
||||
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Process fetched feed data
|
||||
private func processFeedData(_ feedData: FeedData, subscriptionId: String) async throws {
|
||||
// TODO: Implement actual feed data processing
|
||||
// - Store new articles
|
||||
// - Update feed metadata
|
||||
// - Handle duplicates
|
||||
|
||||
print("📝 Processing \(feedData.articles.count) articles for \(feedData.title)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Types
|
||||
|
||||
/// Subscription model
|
||||
struct Subscription {
|
||||
let id: String
|
||||
let title: String
|
||||
let url: String
|
||||
let lastSyncDate: Date?
|
||||
}
|
||||
|
||||
/// Feed data model
|
||||
struct FeedData {
|
||||
let title: String
|
||||
let articles: [Article]
|
||||
let lastBuildDate: Date
|
||||
}
|
||||
|
||||
/// Article model
|
||||
struct Article {
|
||||
let id: String
|
||||
let title: String
|
||||
let link: String?
|
||||
let published: Date?
|
||||
let content: String?
|
||||
}
|
||||
|
||||
// MARK: - Array Extensions
|
||||
|
||||
extension Array {
|
||||
/// Split array into chunks of specified size
|
||||
func chunked(into size: Int) -> [[Element]] {
|
||||
return stride(from: 0, to: count, by: size).map { i -> [Element] in
|
||||
let end = min(i + size, count)
|
||||
return self[i..<end]
|
||||
}
|
||||
}
|
||||
}
|
||||
64
native-route/ios/RSSuperTests/SyncWorkerTests.swift
Normal file
64
native-route/ios/RSSuperTests/SyncWorkerTests.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import XCTest
|
||||
@testable import RSSuper
|
||||
|
||||
/// Unit tests for SyncWorker
|
||||
final class SyncWorkerTests: XCTestCase {
|
||||
|
||||
private var worker: SyncWorker!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
worker = SyncWorker()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
worker = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testChunkedArrayExtension() {
|
||||
let array = [1, 2, 3, 4, 5, 6, 7]
|
||||
let chunks = array.chunked(into: 3)
|
||||
|
||||
XCTAssertEqual(chunks.count, 3)
|
||||
XCTAssertEqual(chunks[0], [1, 2, 3])
|
||||
XCTAssertEqual(chunks[1], [4, 5, 6])
|
||||
XCTAssertEqual(chunks[2], [7])
|
||||
}
|
||||
|
||||
func testChunkedArrayExactDivision() {
|
||||
let array = [1, 2, 3, 4]
|
||||
let chunks = array.chunked(into: 2)
|
||||
|
||||
XCTAssertEqual(chunks.count, 2)
|
||||
XCTAssertEqual(chunks[0], [1, 2])
|
||||
XCTAssertEqual(chunks[1], [3, 4])
|
||||
}
|
||||
|
||||
func testChunkedArrayEmpty() {
|
||||
let array: [Int] = []
|
||||
let chunks = array.chunked(into: 3)
|
||||
|
||||
XCTAssertEqual(chunks.count, 0)
|
||||
}
|
||||
|
||||
func testSyncResultInit() {
|
||||
let result = SyncResult(
|
||||
feedsSynced: 5,
|
||||
articlesFetched: 100,
|
||||
errors: []
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.feedsSynced, 5)
|
||||
XCTAssertEqual(result.articlesFetched, 100)
|
||||
XCTAssertEqual(result.errors.count, 0)
|
||||
}
|
||||
|
||||
func testSyncResultDefaultInit() {
|
||||
let result = SyncResult()
|
||||
|
||||
XCTAssertEqual(result.feedsSynced, 0)
|
||||
XCTAssertEqual(result.articlesFetched, 0)
|
||||
XCTAssertEqual(result.errors.count, 0)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user