From 6a7efebdfc8c42299c2fa0999310a594bab56cdd Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 31 Mar 2026 12:08:01 -0400 Subject: [PATCH] drop native-route dir again --- android/build.gradle.kts | 9 + .../integration/FeedIntegrationTest.kt | 57 +- .../com/rssuper/sync/SyncConfiguration.kt | 19 + .../java/com/rssuper/sync/SyncScheduler.kt | 109 ++++ .../main/java/com/rssuper/sync/SyncWorker.kt | 172 ++++++ .../com/rssuper/sync/SyncConfigurationTest.kt | 54 ++ .../com/rssuper/sync/SyncSchedulerTest.kt | 43 ++ .../java/com/rssuper/sync/SyncWorkerTest.kt | 88 +++ .../android/app/src/main/AndroidManifest.xml | 72 --- .../src/main/java/com/rssuper/BootReceiver.kt | 49 -- .../src/main/java/com/rssuper/MainActivity.kt | 171 ------ .../com/rssuper/NotificationActionReceiver.kt | 48 -- .../java/com/rssuper/NotificationManager.kt | 246 -------- .../rssuper/NotificationPreferencesStore.kt | 181 ------ .../java/com/rssuper/NotificationService.kt | 219 ------- .../java/com/rssuper/RssuperApplication.kt | 82 --- .../java/com/rssuper/SyncConfiguration.kt | 134 ---- .../main/java/com/rssuper/SyncScheduler.kt | 217 ------- .../src/main/java/com/rssuper/SyncWorker.kt | 268 -------- .../res/drawable/ic_launcher_foreground.xml | 13 - .../src/main/res/drawable/ic_notification.xml | 5 - .../drawable/ic_notification_foreground.xml | 13 - .../app/src/main/res/values/colors.xml | 15 - .../app/src/main/res/values/resources.xml | 5 - .../app/src/main/res/values/strings.xml | 12 - .../app/src/main/res/values/styles.xml | 10 - .../com/rssuper/NotificationServiceTests.kt | 287 --------- .../test/java/com/rssuper/SyncWorkerTests.kt | 168 ----- native-route/ios/RSSuper/AppDelegate.swift | 124 ---- .../ios/RSSuper/BackgroundSyncService.swift | 234 ------- .../ios/RSSuper/CoreData/CoreDataModel.ent | 201 ------ native-route/ios/RSSuper/Info.plist | 57 -- .../ios/RSSuper/Models/SearchFilters.swift | 98 --- .../ios/RSSuper/Models/SearchQuery.swift | 212 ------- .../ios/RSSuper/Models/SearchResult.swift | 331 ---------- .../ios/RSSuper/RefreshFeedsAppIntent.swift | 109 ---- .../RSSuper/Services/CoreDataDatabase.swift | 572 ------------------ .../ios/RSSuper/Services/FeedItemStore.swift | 190 ------ .../ios/RSSuper/Services/FullTextSearch.swift | 221 ------- .../Services/NotificationManager.swift | 186 ------ .../NotificationPreferencesStore.swift | 185 ------ .../Services/NotificationService.swift | 209 ------- .../RSSuper/Services/SearchHistoryStore.swift | 65 -- .../ios/RSSuper/Services/SearchService.swift | 252 -------- .../ios/RSSuper/Settings/AppSettings.swift | 284 --------- .../Settings/NotificationPreferences.swift | 28 - .../RSSuper/Settings/ReadingPreferences.swift | 41 -- .../RSSuper/Settings/SettingsMigration.swift | 77 --- .../ios/RSSuper/Settings/SettingsStore.swift | 141 ----- native-route/ios/RSSuper/SyncScheduler.swift | 193 ------ native-route/ios/RSSuper/SyncWorker.swift | 227 ------- .../ios/RSSuperTests/SettingsStoreTests.swift | 162 ----- .../ios/RSSuperTests/SyncWorkerTests.swift | 64 -- ...super.notification.preferences.gschema.xml | 74 --- .../gsettings/org.rssuper.sync.gschema.xml | 25 - native-route/linux/org.rssuper.sync.desktop | 10 - native-route/linux/rssuper-sync.service | 23 - native-route/linux/rssuper-sync.timer | 23 - native-route/linux/src/background-sync.vala | 514 ---------------- .../linux/src/notification-manager.vala | 280 --------- .../src/notification-preferences-store.vala | 258 -------- .../linux/src/notification-service.vala | 199 ------ .../linux/src/sync-scheduler-tests.vala | 218 ------- native-route/linux/src/sync-scheduler.vala | 325 ---------- 64 files changed, 536 insertions(+), 8642 deletions(-) create mode 100644 android/src/main/java/com/rssuper/sync/SyncConfiguration.kt create mode 100644 android/src/main/java/com/rssuper/sync/SyncScheduler.kt create mode 100644 android/src/main/java/com/rssuper/sync/SyncWorker.kt create mode 100644 android/src/test/java/com/rssuper/sync/SyncConfigurationTest.kt create mode 100644 android/src/test/java/com/rssuper/sync/SyncSchedulerTest.kt create mode 100644 android/src/test/java/com/rssuper/sync/SyncWorkerTest.kt delete mode 100644 native-route/android/app/src/main/AndroidManifest.xml delete mode 100644 native-route/android/app/src/main/java/com/rssuper/BootReceiver.kt delete mode 100644 native-route/android/app/src/main/java/com/rssuper/MainActivity.kt delete mode 100644 native-route/android/app/src/main/java/com/rssuper/NotificationActionReceiver.kt delete mode 100644 native-route/android/app/src/main/java/com/rssuper/NotificationManager.kt delete mode 100644 native-route/android/app/src/main/java/com/rssuper/NotificationPreferencesStore.kt delete mode 100644 native-route/android/app/src/main/java/com/rssuper/NotificationService.kt delete mode 100644 native-route/android/app/src/main/java/com/rssuper/RssuperApplication.kt delete mode 100644 native-route/android/app/src/main/java/com/rssuper/SyncConfiguration.kt delete mode 100644 native-route/android/app/src/main/java/com/rssuper/SyncScheduler.kt delete mode 100644 native-route/android/app/src/main/java/com/rssuper/SyncWorker.kt delete mode 100644 native-route/android/app/src/main/res/drawable/ic_launcher_foreground.xml delete mode 100644 native-route/android/app/src/main/res/drawable/ic_notification.xml delete mode 100644 native-route/android/app/src/main/res/drawable/ic_notification_foreground.xml delete mode 100644 native-route/android/app/src/main/res/values/colors.xml delete mode 100644 native-route/android/app/src/main/res/values/resources.xml delete mode 100644 native-route/android/app/src/main/res/values/strings.xml delete mode 100644 native-route/android/app/src/main/res/values/styles.xml delete mode 100644 native-route/android/app/src/test/java/com/rssuper/NotificationServiceTests.kt delete mode 100644 native-route/android/app/src/test/java/com/rssuper/SyncWorkerTests.kt delete mode 100644 native-route/ios/RSSuper/AppDelegate.swift delete mode 100644 native-route/ios/RSSuper/BackgroundSyncService.swift delete mode 100644 native-route/ios/RSSuper/CoreData/CoreDataModel.ent delete mode 100644 native-route/ios/RSSuper/Info.plist delete mode 100644 native-route/ios/RSSuper/Models/SearchFilters.swift delete mode 100644 native-route/ios/RSSuper/Models/SearchQuery.swift delete mode 100644 native-route/ios/RSSuper/Models/SearchResult.swift delete mode 100644 native-route/ios/RSSuper/RefreshFeedsAppIntent.swift delete mode 100644 native-route/ios/RSSuper/Services/CoreDataDatabase.swift delete mode 100644 native-route/ios/RSSuper/Services/FeedItemStore.swift delete mode 100644 native-route/ios/RSSuper/Services/FullTextSearch.swift delete mode 100644 native-route/ios/RSSuper/Services/NotificationManager.swift delete mode 100644 native-route/ios/RSSuper/Services/NotificationPreferencesStore.swift delete mode 100644 native-route/ios/RSSuper/Services/NotificationService.swift delete mode 100644 native-route/ios/RSSuper/Services/SearchHistoryStore.swift delete mode 100644 native-route/ios/RSSuper/Services/SearchService.swift delete mode 100644 native-route/ios/RSSuper/Settings/AppSettings.swift delete mode 100644 native-route/ios/RSSuper/Settings/NotificationPreferences.swift delete mode 100644 native-route/ios/RSSuper/Settings/ReadingPreferences.swift delete mode 100644 native-route/ios/RSSuper/Settings/SettingsMigration.swift delete mode 100644 native-route/ios/RSSuper/Settings/SettingsStore.swift delete mode 100644 native-route/ios/RSSuper/SyncScheduler.swift delete mode 100644 native-route/ios/RSSuper/SyncWorker.swift delete mode 100644 native-route/ios/RSSuperTests/SettingsStoreTests.swift delete mode 100644 native-route/ios/RSSuperTests/SyncWorkerTests.swift delete mode 100644 native-route/linux/gsettings/org.rssuper.notification.preferences.gschema.xml delete mode 100644 native-route/linux/gsettings/org.rssuper.sync.gschema.xml delete mode 100644 native-route/linux/org.rssuper.sync.desktop delete mode 100644 native-route/linux/rssuper-sync.service delete mode 100644 native-route/linux/rssuper-sync.timer delete mode 100644 native-route/linux/src/background-sync.vala delete mode 100644 native-route/linux/src/notification-manager.vala delete mode 100644 native-route/linux/src/notification-preferences-store.vala delete mode 100644 native-route/linux/src/notification-service.vala delete mode 100644 native-route/linux/src/sync-scheduler-tests.vala delete mode 100644 native-route/linux/src/sync-scheduler.vala diff --git a/android/build.gradle.kts b/android/build.gradle.kts index d0531bb..11c25f4 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -45,6 +45,9 @@ dependencies { // AndroidX implementation("androidx.core:core-ktx:1.12.0") + + // WorkManager for background sync + implementation("androidx.work:work-runtime-ktx:2.9.0") // XML Parsing - built-in XmlPullParser implementation("androidx.room:room-runtime:2.6.1") @@ -74,4 +77,10 @@ dependencies { testImplementation("androidx.test:runner:1.5.2") testImplementation("org.robolectric:robolectric:4.11.1") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + + // WorkManager testing + testImplementation("androidx.work:work-testing:2.9.0") + + // Android test dependencies + androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") } diff --git a/android/src/androidTest/java/com/rssuper/integration/FeedIntegrationTest.kt b/android/src/androidTest/java/com/rssuper/integration/FeedIntegrationTest.kt index 7404b80..08bfc8d 100644 --- a/android/src/androidTest/java/com/rssuper/integration/FeedIntegrationTest.kt +++ b/android/src/androidTest/java/com/rssuper/integration/FeedIntegrationTest.kt @@ -20,6 +20,7 @@ import org.junit.runner.RunWith import java.io.File import java.io.FileReader import java.util.concurrent.TimeUnit +import kotlinx.coroutines.test.runTest /** * Integration tests for cross-platform feed functionality. @@ -36,6 +37,36 @@ class FeedIntegrationTest { private lateinit var feedParser: FeedParser private lateinit var mockServer: MockWebServer + // Sample RSS feed content embedded directly + private val sampleRssContent = """ + + + + Test RSS Feed + https://example.com + Test feed + + Article 1 + https://example.com/1 + Content 1 + Mon, 31 Mar 2026 10:00:00 GMT + + + Article 2 + https://example.com/2 + Content 2 + Mon, 31 Mar 2026 11:00:00 GMT + + + Article 3 + https://example.com/3 + Content 3 + Mon, 31 Mar 2026 12:00:00 GMT + + + + """.trimIndent() + @Before fun setUp() { context = ApplicationProvider.getApplicationContext() @@ -60,8 +91,7 @@ class FeedIntegrationTest { @Test fun testFetchParseAndStoreFlow() = runBlockingTest { // Setup mock server to return sample RSS feed - val rssContent = File("tests/fixtures/sample-rss.xml").readText() - mockServer.enqueue(MockResponse().setBody(rssContent).setResponseCode(200)) + mockServer.enqueue(MockResponse().setBody(sampleRssContent).setResponseCode(200)) val feedUrl = mockServer.url("/feed.xml").toString() @@ -72,15 +102,13 @@ class FeedIntegrationTest { // 2. Parse the feed val parseResult = feedParser.parse(fetchResult.getOrNull()!!.feedXml, feedUrl) - assertTrue("Parse should succeed", parseResult is ParseResult.Success) - assertNotNull("Parse result should have feeds", (parseResult as ParseResult.Success).feeds) + assertNotNull("Parse result should not be null", parseResult) // 3. Store the subscription - val feed = (parseResult as ParseResult.Success).feeds!!.first() - database.subscriptionDao().insert(feed.subscription) + database.subscriptionDao().insert(parseResult.feed.subscription) // 4. Store the feed items - feed.items.forEach { item -> + parseResult.feed.items.forEach { item -> database.feedItemDao().insert(item) } @@ -89,7 +117,7 @@ class FeedIntegrationTest { assertEquals("Should have 3 feed items", 3, storedItems.size) val storedSubscription = database.subscriptionDao().getAll().first() - assertEquals("Subscription title should match", feed.subscription.title, storedSubscription.title) + assertEquals("Subscription title should match", parseResult.feed.subscription.title, storedSubscription.title) } @Test @@ -135,9 +163,8 @@ class FeedIntegrationTest { @Test fun testBackgroundSyncIntegration() = runBlockingTest { // Setup mock server with multiple feeds - val feed1Content = File("tests/fixtures/sample-rss.xml").readText() - mockServer.enqueue(MockResponse().setBody(feed1Content).setResponseCode(200)) - mockServer.enqueue(MockResponse().setBody(feed1Content).setResponseCode(200)) + mockServer.enqueue(MockResponse().setBody(sampleRssContent).setResponseCode(200)) + mockServer.enqueue(MockResponse().setBody(sampleRssContent).setResponseCode(200)) val feed1Url = mockServer.url("/feed1.xml").toString() val feed2Url = mockServer.url("/feed2.xml").toString() @@ -302,9 +329,9 @@ class FeedIntegrationTest { val fetchResult = feedFetcher.fetch(feedUrl) assertTrue("Fetch should succeed", fetchResult.isSuccess()) - val parseResult = feedParser.parse(fetchResult.getOrNull()!!.feedXml, feedUrl) - // Parser should handle invalid XML gracefully - assertTrue("Parse should handle error", parseResult is ParseResult.Failure) + assertThrows { + feedParser.parse(fetchResult.getOrNull()!!.feedXml, feedUrl) + } } @Test @@ -383,6 +410,6 @@ class FeedIntegrationTest { } private suspend fun runBlockingTest(block: suspend () -> T): T { - return block() + return kotlinx.coroutines.test.runTest { block() } } } diff --git a/android/src/main/java/com/rssuper/sync/SyncConfiguration.kt b/android/src/main/java/com/rssuper/sync/SyncConfiguration.kt new file mode 100644 index 0000000..c86a241 --- /dev/null +++ b/android/src/main/java/com/rssuper/sync/SyncConfiguration.kt @@ -0,0 +1,19 @@ +package com.rssuper.sync + +import java.util.concurrent.TimeUnit + +data class SyncConfiguration( + val minSyncIntervalMinutes: Long = 15, + val defaultSyncIntervalMinutes: Long = 30, + val maxSyncIntervalMinutes: Long = 1440, + val syncTimeoutMinutes: Long = 10, + val requiresCharging: Boolean = false, + val requiresUnmeteredNetwork: Boolean = true, + val requiresDeviceIdle: Boolean = false +) { + companion object { + fun default(): SyncConfiguration { + return SyncConfiguration() + } + } +} diff --git a/android/src/main/java/com/rssuper/sync/SyncScheduler.kt b/android/src/main/java/com/rssuper/sync/SyncScheduler.kt new file mode 100644 index 0000000..1cc31a4 --- /dev/null +++ b/android/src/main/java/com/rssuper/sync/SyncScheduler.kt @@ -0,0 +1,109 @@ +package com.rssuper.sync + +import android.content.Context +import androidx.work.* +import com.rssuper.database.RssDatabase +import com.rssuper.repository.SubscriptionRepository +import kotlinx.coroutines.flow.Flow +import java.util.concurrent.TimeUnit + +class SyncScheduler(private val context: Context) { + + private val database = RssDatabase.getDatabase(context) + private val subscriptionRepository = SubscriptionRepository(database.subscriptionDao()) + private val workManager = WorkManager.getInstance(context) + + companion object { + private const val SYNC_WORK_NAME = "feed_sync_work" + private const val SYNC_PERIOD_MINUTES = 15L + } + + fun schedulePeriodicSync(config: SyncConfiguration = SyncConfiguration.default()) { + val constraints = Constraints.Builder() + .setRequiresCharging(config.requiresCharging) + .setRequiresUnmeteredNetwork(config.requiresUnmeteredNetwork) + .setRequiresDeviceIdle(config.requiresDeviceIdle) + .build() + + val periodicWorkRequest = PeriodicWorkRequestBuilder( + config.minSyncIntervalMinutes, TimeUnit.MINUTES + ) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + config.minSyncIntervalMinutes, TimeUnit.MINUTES + ) + .addTag(SYNC_WORK_NAME) + .build() + + workManager.enqueueUniquePeriodicWork( + SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + periodicWorkRequest + ) + } + + fun scheduleSyncForSubscription(subscriptionId: String, config: SyncConfiguration = SyncConfiguration.default()) { + val constraints = Constraints.Builder() + .setRequiresCharging(config.requiresCharging) + .setRequiresUnmeteredNetwork(config.requiresUnmeteredNetwork) + .setRequiresDeviceIdle(config.requiresDeviceIdle) + .build() + + val oneOffWorkRequest = OneTimeWorkRequestBuilder( + config.syncTimeoutMinutes, TimeUnit.MINUTES + ) + .setConstraints(constraints) + .setInputData(SyncWorker.buildSyncData(subscriptionId)) + .addTag("sync_$subscriptionId") + .build() + + workManager.enqueue(oneOffWorkRequest) + } + + fun scheduleSyncForSubscription( + subscriptionId: String, + feedTitle: String, + config: SyncConfiguration = SyncConfiguration.default() + ) { + val constraints = Constraints.Builder() + .setRequiresCharging(config.requiresCharging) + .setRequiresUnmeteredNetwork(config.requiresUnmeteredNetwork) + .setRequiresDeviceIdle(config.requiresDeviceIdle) + .build() + + val oneOffWorkRequest = OneTimeWorkRequestBuilder( + config.syncTimeoutMinutes, TimeUnit.MINUTES + ) + .setConstraints(constraints) + .setInputData(SyncWorker.buildSyncData(subscriptionId, feedTitle)) + .addTag("sync_$subscriptionId") + .build() + + workManager.enqueue(oneOffWorkRequest) + } + + fun cancelSyncForSubscription(subscriptionId: String) { + workManager.cancelWorkByIds(listOf("sync_$subscriptionId")) + } + + fun cancelAllSyncs() { + workManager.cancelAllWork() + } + + fun cancelPeriodicSync() { + workManager.cancelUniqueWork(SYNC_WORK_NAME) + } + + fun getSyncWorkInfo(): Flow> { + return workManager.getWorkInfosForUniqueWorkFlow(SYNC_WORK_NAME) + } + + fun getSyncWorkInfoForSubscription(subscriptionId: String): Flow> { + return workManager.getWorkInfosForTagFlow("sync_$subscriptionId") + } + + fun syncAllSubscriptionsNow(config: SyncConfiguration = SyncConfiguration.default()) { + TODO("Implementation needed: fetch all subscriptions and schedule sync for each") + } +} diff --git a/android/src/main/java/com/rssuper/sync/SyncWorker.kt b/android/src/main/java/com/rssuper/sync/SyncWorker.kt new file mode 100644 index 0000000..1f1e3d7 --- /dev/null +++ b/android/src/main/java/com/rssuper/sync/SyncWorker.kt @@ -0,0 +1,172 @@ +package com.rssuper.sync + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.WorkerParameters +import androidx.work.WorkerParameters.ListenableWorker +import com.rssuper.database.RssDatabase +import com.rssuper.models.FeedSubscription +import com.rssuper.parsing.ParseResult +import com.rssuper.repository.FeedRepository +import com.rssuper.repository.SubscriptionRepository +import com.rssuper.services.FeedFetcher +import com.rssuper.services.FeedFetcher.NetworkResult +import kotlinx.coroutines.delay +import java.util.Date + +class SyncWorker( + context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams) { + + private val database = RssDatabase.getDatabase(context) + private val subscriptionRepository = SubscriptionRepository(database.subscriptionDao()) + private val feedRepository = FeedRepository(database.feedItemDao(), database.subscriptionDao()) + private val feedFetcher = FeedFetcher() + + companion object { + private const val KEY_SUBSCRIPTION_ID = "subscription_id" + private const val KEY_SYNC_SUCCESS = "sync_success" + private const val KEY_ITEMS_FETCHE = "items_fetched" + private const val KEY_ERROR_MESSAGE = "error_message" + private const val KEY_FEED_TITLE = "feed_title" + + fun buildSyncData(subscriptionId: String): Data { + return Data.Builder() + .putString(KEY_SUBSCRIPTION_ID, subscriptionId) + .build() + } + + fun buildSyncData(subscriptionId: String, feedTitle: String): Data { + return Data.Builder() + .putString(KEY_SUBSCRIPTION_ID, subscriptionId) + .putString(KEY_FEED_TITLE, feedTitle) + .build() + } + } + + override suspend fun doWork(): Result { + val subscriptionId = inputData.getString(KEY_SUBSCRIPTION_ID) + + if (subscriptionId == null) { + return Result.failure( + Data.Builder() + .putString(KEY_ERROR_MESSAGE, "No subscription ID provided") + .build() + ) + } + + return try { + val subscription = subscriptionRepository.getSubscriptionById(subscriptionId).getOrNull() + + if (subscription == null) { + Result.failure( + Data.Builder() + .putString(KEY_ERROR_MESSAGE, "Subscription not found: $subscriptionId") + .build() + ) + } + + if (!subscription.enabled) { + return Result.success( + Data.Builder() + .putBoolean(KEY_SYNC_SUCCESS, true) + .putInt(KEY_ITEMS_FETCHE, 0) + .build() + ) + } + + val nextFetchAt = subscription.nextFetchAt + if (nextFetchAt != null && nextFetchAt.after(Date())) { + val delayMillis = nextFetchAt.time - Date().time + if (delayMillis > 0) { + delay(delayMillis) + } + } + + val fetchResult = feedFetcher.fetchAndParse( + url = subscription.url, + httpAuth = if (subscription.httpAuthUsername != null || subscription.httpAuthPassword != null) { + com.rssuper.services.HTTPAuthCredentials(subscription.httpAuthUsername!!, subscription.httpAuthPassword!!) + } else null + ) + + when (fetchResult) { + is NetworkResult.Success -> { + val parseResult = fetchResult.value + val itemsFetched = processParseResult(parseResult, subscription.id) + + subscriptionRepository.updateLastFetchedAt(subscription.id, Date()) + + val nextFetchInterval = subscription.fetchInterval?.toLong() ?: 30L + val nextFetchAtDate = Date(Date().time + nextFetchInterval * 60 * 1000) + subscriptionRepository.updateNextFetchAt(subscription.id, nextFetchAtDate) + + Result.success( + Data.Builder() + .putBoolean(KEY_SYNC_SUCCESS, true) + .putInt(KEY_ITEMS_FETCHE, itemsFetched) + .putString(KEY_FEED_TITLE, parseResult.title) + .build() + ) + } + is NetworkResult.Failure -> { + val errorMessage = fetchResult.error.message ?: "Unknown error" + subscriptionRepository.updateError(subscription.id, errorMessage) + + Result.retry( + Data.Builder() + .putString(KEY_ERROR_MESSAGE, errorMessage) + .build() + ) + } + } + } catch (e: Exception) { + Result.failure( + Data.Builder() + .putString(KEY_ERROR_MESSAGE, e.message ?: "Unknown exception") + .build() + ) + } + } + + private suspend fun processParseResult(parseResult: ParseResult, subscriptionId: String): Int { + return when (parseResult) { + is ParseResult.RSS, + is ParseResult.Atom -> { + val items = parseResult.items + var inserted = 0 + + for (item in items) { + val feedItem = com.rssuper.models.FeedItem( + id = item.guid ?: item.link ?: "${subscriptionId}-${item.title.hashCode()}", + title = item.title, + link = item.link, + author = item.author, + published = item.published, + content = item.content, + summary = item.summary, + feedId = subscriptionId, + feedTitle = parseResult.title, + feedUrl = parseResult.link, + createdAt = Date(), + updatedAt = Date(), + isRead = false, + isBookmarked = false, + tags = emptyList() + ) + + if (feedRepository.addFeedItem(feedItem)) { + inserted++ + } + } + + inserted + } + is ParseResult.Error -> { + 0 + } + } + } +} diff --git a/android/src/test/java/com/rssuper/sync/SyncConfigurationTest.kt b/android/src/test/java/com/rssuper/sync/SyncConfigurationTest.kt new file mode 100644 index 0000000..d529cbc --- /dev/null +++ b/android/src/test/java/com/rssuper/sync/SyncConfigurationTest.kt @@ -0,0 +1,54 @@ +package com.rssuper.sync + +import org.junit.Test +import org.junit.Assert.* + +import java.util.concurrent.TimeUnit + +class SyncConfigurationTest { + + @Test + fun testDefaultConfiguration_hasExpectedValues() { + val config = SyncConfiguration.default() + + assertEquals("Default min sync interval", 15L, config.minSyncIntervalMinutes) + assertEquals("Default sync interval", 30L, config.defaultSyncIntervalMinutes) + assertEquals("Default max sync interval", 1440L, config.maxSyncIntervalMinutes) + assertEquals("Default sync timeout", 10L, config.syncTimeoutMinutes) + assertFalse("Default requires charging", config.requiresCharging) + assertTrue("Default requires unmetered network", config.requiresUnmeteredNetwork) + assertFalse("Default requires device idle", config.requiresDeviceIdle) + } + + @Test + fun testCustomConfiguration_allowsCustomValues() { + val config = SyncConfiguration( + minSyncIntervalMinutes = 5, + defaultSyncIntervalMinutes = 15, + maxSyncIntervalMinutes = 720, + syncTimeoutMinutes = 5, + requiresCharging = true, + requiresUnmeteredNetwork = false, + requiresDeviceIdle = true + ) + + assertEquals("Custom min sync interval", 5L, config.minSyncIntervalMinutes) + assertEquals("Custom sync interval", 15L, config.defaultSyncIntervalMinutes) + assertEquals("Custom max sync interval", 720L, config.maxSyncIntervalMinutes) + assertEquals("Custom sync timeout", 5L, config.syncTimeoutMinutes) + assertTrue("Custom requires charging", config.requiresCharging) + assertFalse("Custom requires unmetered network", config.requiresUnmeteredNetwork) + assertTrue("Custom requires device idle", config.requiresDeviceIdle) + } + + @Test + fun testConfiguration_isImmutable() { + val config = SyncConfiguration.default() + + // Verify that the configuration is a data class and thus immutable + val modifiedConfig = config.copy(minSyncIntervalMinutes = 5) + + assertEquals("Original config unchanged", 15L, config.minSyncIntervalMinutes) + assertEquals("Modified config has new value", 5L, modifiedConfig.minSyncIntervalMinutes) + } +} diff --git a/android/src/test/java/com/rssuper/sync/SyncSchedulerTest.kt b/android/src/test/java/com/rssuper/sync/SyncSchedulerTest.kt new file mode 100644 index 0000000..d881145 --- /dev/null +++ b/android/src/test/java/com/rssuper/sync/SyncSchedulerTest.kt @@ -0,0 +1,43 @@ +package com.rssuper.sync + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith + +import com.rssuper.database.RssDatabase +import com.rssuper.database.daos.SubscriptionDao +import com.rssuper.repository.SubscriptionRepository + +@RunWith(AndroidJUnit4::class) +class SyncSchedulerTest { + + @Test + fun testSchedulePeriodicSync_schedulesWork() { + // This test requires Android instrumentation + assertTrue("Test placeholder - requires Android instrumentation", true) + } + + @Test + fun testScheduleSyncForSubscription_schedulesOneOffWork() { + // This test requires Android instrumentation + assertTrue("Test placeholder - requires Android instrumentation", true) + } + + @Test + fun testCancelSyncForSubscription_cancelsWork() { + // This test requires Android instrumentation + assertTrue("Test placeholder - requires Android instrumentation", true) + } + + @Test + fun testCancelAllSyncs_cancelsAllWork() { + // This test requires Android instrumentation + assertTrue("Test placeholder - requires Android instrumentation", true) + } + + @Test + fun testCancelPeriodicSync_cancelsPeriodicWork() { + // This test requires Android instrumentation + assertTrue("Test placeholder - requires Android instrumentation", true) + } +} diff --git a/android/src/test/java/com/rssuper/sync/SyncWorkerTest.kt b/android/src/test/java/com/rssuper/sync/SyncWorkerTest.kt new file mode 100644 index 0000000..6fbe0af --- /dev/null +++ b/android/src/test/java/com/rssuper/sync/SyncWorkerTest.kt @@ -0,0 +1,88 @@ +package com.rssuper.sync + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.ListenableWorker.Result +import androidx.work.WorkerParameters +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito.* +import org.mockito.MockitoAnnotations + +import com.rssuper.database.RssDatabase +import com.rssuper.database.daos.SubscriptionDao +import com.rssuper.database.entities.SubscriptionEntity +import com.rssuper.repository.FeedRepository +import com.rssuper.repository.SubscriptionRepository +import com.rssuper.services.FeedFetcher +import com.rssuper.parsing.ParseResult +import java.util.Date + +@RunWith(AndroidJUnit4::class) +class SyncWorkerTest { + + @Mock + private lateinit var subscriptionDao: SubscriptionDao + + @Mock + private lateinit var feedRepository: FeedRepository + + @Mock + private lateinit var feedFetcher: FeedFetcher + + private lateinit var subscriptionRepository: SubscriptionRepository + + @Test + fun testDoWork_withValidSubscription_returnsSuccess() { + // Setup + val subscriptionId = "test-subscription-id" + val subscription = SubscriptionEntity( + id = subscriptionId, + url = "https://example.com/feed", + title = "Test Feed", + category = null, + enabled = true, + fetchInterval = 30, + createdAt = Date(), + updatedAt = Date(), + lastFetchedAt = null, + nextFetchAt = null, + error = null, + httpAuthUsername = null, + httpAuthPassword = null + ) + + // Mock the subscription repository to return our test subscription + // Note: In a real test, we would use a proper mock setup + + // This test would need proper Android instrumentation to run + // as SyncWorker requires a Context + assertTrue("Test placeholder - requires Android instrumentation", true) + } + + @Test + fun testDoWork_withDisabledSubscription_returnsSuccessWithZeroItems() { + // Disabled subscriptions should return success with 0 items fetched + assertTrue("Test placeholder - requires Android instrumentation", true) + } + + @Test + fun testDoWork_withMissingSubscriptionId_returnsFailure() { + // Missing subscription ID should return failure + assertTrue("Test placeholder - requires Android instrumentation", true) + } + + @Test + fun testDoWork_withNetworkError_returnsRetry() { + // Network errors should return retry + assertTrue("Test placeholder - requires Android instrumentation", true) + } + + @Test + fun testDoWork_withParseError_returnsSuccessWithZeroItems() { + // Parse errors should return success with 0 items + assertTrue("Test placeholder - requires Android instrumentation", true) + } +} diff --git a/native-route/android/app/src/main/AndroidManifest.xml b/native-route/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 6c162ec..0000000 --- a/native-route/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/native-route/android/app/src/main/java/com/rssuper/BootReceiver.kt b/native-route/android/app/src/main/java/com/rssuper/BootReceiver.kt deleted file mode 100644 index ee7caad..0000000 --- a/native-route/android/app/src/main/java/com/rssuper/BootReceiver.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.rssuper - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.util.Log - -/** - * BootReceiver - Receives boot completed broadcast - * - * Starts notification service when device boots. - */ -class BootReceiver : BroadcastReceiver() { - - companion object { - private const val TAG = "BootReceiver" - } - - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - - val action = intent.action - - when { - action == Intent.ACTION_BOOT_COMPLETED -> { - Log.d(TAG, "Device boot completed, starting notification service") - startNotificationService(context) - } - action == Intent.ACTION_QUICKBOOT_POWERON -> { - Log.d(TAG, "Quick boot power on, starting notification service") - startNotificationService(context) - } - else -> { - Log.d(TAG, "Received unknown action: $action") - } - } - } - - /** - * Start notification service - */ - private fun startNotificationService(context: Context) { - val notificationService = NotificationService.getInstance() - notificationService.initialize(context) - - Log.d(TAG, "Notification service started") - } -} - diff --git a/native-route/android/app/src/main/java/com/rssuper/MainActivity.kt b/native-route/android/app/src/main/java/com/rssuper/MainActivity.kt deleted file mode 100644 index f3333c3..0000000 --- a/native-route/android/app/src/main/java/com/rssuper/MainActivity.kt +++ /dev/null @@ -1,171 +0,0 @@ -package com.rssuper - -import android.content.Context -import android.os.Bundle -import android.util.Log -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch - -/** - * MainActivity - Main activity for RSSuper - * - * Integrates notification manager and handles app lifecycle. - */ -class MainActivity : AppCompatActivity() { - - companion object { - private const val TAG = "MainActivity" - } - - private lateinit var notificationManager: NotificationManager - private lateinit var notificationPreferencesStore: NotificationPreferencesStore - private var lifecycleOwner: LifecycleOwner? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // Set up notification manager - notificationManager = NotificationManager(this) - notificationPreferencesStore = NotificationPreferencesStore(this) - - // Initialize notification manager - notificationManager.initialize() - - // Set up lifecycle observer - lifecycleOwner = this - lifecycleOwner?.lifecycleOwner = this - - // Start notification service - NotificationService.getInstance().initialize(this) - - Log.d(TAG, "MainActivity created") - } - - override fun onResume() { - super.onResume() - - // Update badge count when app is in foreground - updateBadgeCount() - - Log.d(TAG, "MainActivity resumed") - } - - override fun onPause() { - super.onPause() - - Log.d(TAG, "MainActivity paused") - } - - override fun onDestroy() { - super.onDestroy() - - // Clear lifecycle owner before destroying - lifecycleOwner = null - - Log.d(TAG, "MainActivity destroyed") - } - - /** - * Update badge count - */ - private fun updateBadgeCount() { - lifecycleOwner?.lifecycleScope?.launch { - val unreadCount = notificationManager.getUnreadCount() - notificationManager.updateBadge(unreadCount) - } - } - - /** - * Show notification from background - */ - fun showNotification(title: String, text: String, icon: Int, urgency: NotificationUrgency = NotificationUrgency.NORMAL) { - lifecycleOwner?.lifecycleScope?.launch { - notificationManager.getNotificationService().showNotification( - title = title, - text = text, - icon = icon, - urgency = urgency - ) - } - } - - /** - * Show critical notification - */ - fun showCriticalNotification(title: String, text: String, icon: Int) { - lifecycleOwner?.lifecycleScope?.launch { - notificationManager.getNotificationService().showCriticalNotification( - title = title, - text = text, - icon = icon - ) - } - } - - /** - * Show low priority notification - */ - fun showLowNotification(title: String, text: String, icon: Int) { - lifecycleOwner?.lifecycleScope?.launch { - notificationManager.getNotificationService().showLowNotification( - title = title, - text = text, - icon = icon - ) - } - } - - /** - * Show normal notification - */ - fun showNormalNotification(title: String, text: String, icon: Int) { - lifecycleOwner?.lifecycleScope?.launch { - notificationManager.getNotificationService().showNormalNotification( - title = title, - text = text, - icon = icon - ) - } - } - - /** - * Get notification manager - */ - fun getNotificationManager(): NotificationManager = notificationManager - - /** - * Get notification preferences store - */ - fun getNotificationPreferencesStore(): NotificationPreferencesStore = notificationPreferencesStore - - /** - * Get notification service - */ - fun getNotificationService(): NotificationService = notificationManager.getNotificationService() - - /** - * Get preferences - */ - fun getPreferences(): NotificationPreferences = notificationManager.getPreferences() - - /** - * Set preferences - */ - fun setPreferences(preferences: NotificationPreferences) { - notificationManager.setPreferences(preferences) - notificationPreferencesStore.setPreferences(preferences) - } - - /** - * Get unread count - */ - fun getUnreadCount(): Int = notificationManager.getUnreadCount() - - /** - * Get badge count - */ - fun getBadgeCount(): Int = notificationManager.getBadgeCount() -} - diff --git a/native-route/android/app/src/main/java/com/rssuper/NotificationActionReceiver.kt b/native-route/android/app/src/main/java/com/rssuper/NotificationActionReceiver.kt deleted file mode 100644 index 9b3f993..0000000 --- a/native-route/android/app/src/main/java/com/rssuper/NotificationActionReceiver.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.rssuper - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.util.Log -import androidx.core.app.NotificationCompat - -/** - * NotificationActionReceiver - Receives notification action broadcasts - * - * Handles notification clicks and actions. - */ -class NotificationActionReceiver : BroadcastReceiver() { - - companion object { - private const val TAG = "NotificationActionReceiver" - private const val ACTION = "com.rssuper.notification.ACTION" - } - - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - - val action = intent.action ?: return - val notificationId = intent.getIntExtra("notification_id", -1) - - Log.d(TAG, "Received action: $action, notificationId: $notificationId") - - // Handle notification click - if (action == ACTION) { - handleNotificationClick(context, notificationId) - } - } - - /** - * Handle notification click - */ - private fun handleNotificationClick(context: Context, notificationId: Int) { - val appIntent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - - context.startActivity(appIntent) - - Log.d(TAG, "Opened MainActivity from notification") - } -} - diff --git a/native-route/android/app/src/main/java/com/rssuper/NotificationManager.kt b/native-route/android/app/src/main/java/com/rssuper/NotificationManager.kt deleted file mode 100644 index 616d1b7..0000000 --- a/native-route/android/app/src/main/java/com/rssuper/NotificationManager.kt +++ /dev/null @@ -1,246 +0,0 @@ -package com.rssuper - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.os.Build -import android.os.IBinder -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.core.app.ServiceCompat - -/** - * NotificationManager - Manager for coordinating notifications - * - * Handles badge management, preference storage, and notification coordination. - */ -class NotificationManager(private val context: Context) { - - companion object { - private const val TAG = "NotificationManager" - private const val PREFS_NAME = "notification_prefs" - private const val KEY_BADGE_COUNT = "badge_count" - private const val KEY_NOTIFICATIONS_ENABLED = "notifications_enabled" - private const val KEY_CRITICAL_ENABLED = "critical_enabled" - private const val KEY_LOW_ENABLED = "low_enabled" - private const val KEY_NORMAL_ENABLED = "normal_enabled" - private const val KEY_BADGE_ENABLED = "badge_enabled" - private const val KEY_SOUND_ENABLED = "sound_enabled" - private const val KEY_VIBRATION_ENABLED = "vibration_enabled" - private const val KEY_UNREAD_COUNT = "unread_count" - } - - private val notificationManager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val notificationService: NotificationService = NotificationService.getInstance() - private val appIntent: Intent = Intent(context, MainActivity::class.java) - - /** - * Initialize the notification manager - */ - fun initialize() { - // Create notification channels (Android 8.0+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannels() - } - - // Load saved preferences - loadPreferences() - - Log.d(TAG, "NotificationManager initialized") - } - - /** - * Create notification channels - */ - private fun createNotificationChannels() { - val criticalChannel = NotificationChannel( - "rssuper_critical", - "Critical", - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = "Critical notifications" - enableVibration(true) - enableLights(true) - setShowBadge(true) - } - - val lowChannel = NotificationChannel( - "rssuper_low", - "Low Priority", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "Low priority notifications" - enableVibration(false) - enableLights(false) - setShowBadge(true) - } - - val regularChannel = NotificationChannel( - "rssuper_notifications", - "RSSuper Notifications", - NotificationManager.IMPORTANCE_DEFAULT - ).apply { - description = "General RSSuper notifications" - enableVibration(false) - enableLights(false) - setShowBadge(true) - } - - notificationManager.createNotificationChannels( - listOf(criticalChannel, lowChannel, regularChannel) - ) - } - - /** - * Load saved preferences - */ - private fun loadPreferences() { - val unreadCount = prefs.getInt(KEY_UNREAD_COUNT, 0) - saveBadge(unreadCount) - - Log.d(TAG, "Loaded preferences: unreadCount=$unreadCount") - } - - /** - * Save badge count - */ - private fun saveBadge(count: Int) { - prefs.edit().putInt(KEY_UNREAD_COUNT, count).apply() - updateBadge(count) - } - - /** - * Update badge count - */ - fun updateBadge(count: Int) { - saveBadge(count) - - if (count > 0) { - showBadge(count) - } else { - hideBadge() - } - } - - /** - * Show badge - */ - fun showBadge(count: Int) { - val prefs = prefs.edit().apply { putInt(KEY_BADGE_COUNT, count) } - - val intent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - - val pendingIntent = PendingIntent.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val notification = NotificationCompat.Builder(context, "rssuper_notifications") - .setContentIntent(pendingIntent) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle("RSSuper") - .setContentText("$count unread notification(s)") - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setAutoCancel(true) - .build() - - notificationManager.notify(1002, notification) - - Log.d(TAG, "Badge shown: $count") - } - - /** - * Hide badge - */ - fun hideBadge() { - val prefs = prefs.edit().apply { putInt(KEY_BADGE_COUNT, 0) } - notificationManager.cancel(1002) - Log.d(TAG, "Badge hidden") - } - - /** - * Get unread count - */ - fun getUnreadCount(): Int = prefs.getInt(KEY_UNREAD_COUNT, 0) - - /** - * Get badge count - */ - fun getBadgeCount(): Int = prefs.getInt(KEY_BADGE_COUNT, 0) - - /** - * Get preferences - */ - fun getPreferences(): NotificationPreferences { - return NotificationPreferences( - newArticles = prefs.getBoolean("newArticles", true), - episodeReleases = prefs.getBoolean("episodeReleases", true), - customAlerts = prefs.getBoolean("customAlerts", true), - badgeCount = prefs.getBoolean(KEY_BADGE_ENABLED, true), - sound = prefs.getBoolean(KEY_SOUND_ENABLED, true), - vibration = prefs.getBoolean(KEY_VIBRATION_ENABLED, true) - ) - } - - /** - * Set preferences - */ - fun setPreferences(preferences: NotificationPreferences) { - prefs.edit().apply { - putBoolean("newArticles", preferences.newArticles) - putBoolean("episodeReleases", preferences.episodeReleases) - putBoolean("customAlerts", preferences.customAlerts) - putBoolean(KEY_BADGE_ENABLED, preferences.badgeCount) - putBoolean(KEY_SOUND_ENABLED, preferences.sound) - putBoolean(KEY_VIBRATION_ENABLED, preferences.vibration) - apply() - } - } - - /** - * Get notification service - */ - fun getNotificationService(): NotificationService = notificationService - - /** - * Get context - */ - fun getContext(): Context = context - - /** - * Get notification manager - */ - fun getNotificationManager(): NotificationManager = notificationManager - - /** - * Get app intent - */ - fun getAppIntent(): Intent = appIntent - - /** - * Get preferences key - */ - fun getPrefsName(): String = PREFS_NAME -} - -/** - * Notification preferences - */ -data class NotificationPreferences( - val newArticles: Boolean = true, - val episodeReleases: Boolean = true, - val customAlerts: Boolean = true, - val badgeCount: Boolean = true, - val sound: Boolean = true, - val vibration: Boolean = true -) - diff --git a/native-route/android/app/src/main/java/com/rssuper/NotificationPreferencesStore.kt b/native-route/android/app/src/main/java/com/rssuper/NotificationPreferencesStore.kt deleted file mode 100644 index 46d9261..0000000 --- a/native-route/android/app/src/main/java/com/rssuper/NotificationPreferencesStore.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.rssuper - -import android.content.Context -import android.content.SharedPreferences -import android.util.Log -import kotlinx.serialization.Serializable - -/** - * NotificationPreferencesStore - Persistent storage for notification preferences - * - * Uses SharedPreferences for persistent storage following Android conventions. - */ -class NotificationPreferencesStore(private val context: Context) { - - companion object { - private const val TAG = "NotificationPreferencesStore" - private const val PREFS_NAME = "notification_prefs" - private const val KEY_NEW_ARTICLES = "newArticles" - private const val KEY_EPISODE_RELEASES = "episodeReleases" - private const val KEY_CUSTOM_ALERTS = "customAlerts" - private const val KEY_BADGE_COUNT = "badgeCount" - private const val KEY_SOUND = "sound" - private const val KEY_VIBRATION = "vibration" - private const val KEY_NOTIFICATIONS_ENABLED = "notificationsEnabled" - } - - private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val editor = prefs.edit() - - /** - * Get notification preferences - */ - fun getPreferences(): NotificationPreferences { - return NotificationPreferences( - newArticles = prefs.getBoolean(KEY_NEW_ARTICLES, true), - episodeReleases = prefs.getBoolean(KEY_EPISODE_RELEASES, true), - customAlerts = prefs.getBoolean(KEY_CUSTOM_ALERTS, true), - badgeCount = prefs.getBoolean(KEY_BADGE_COUNT, true), - sound = prefs.getBoolean(KEY_SOUND, true), - vibration = prefs.getBoolean(KEY_VIBRATION, true) - ) - } - - /** - * Set notification preferences - */ - fun setPreferences(preferences: NotificationPreferences) { - editor.apply { - putBoolean(KEY_NEW_ARTICLES, preferences.newArticles) - putBoolean(KEY_EPISODE_RELEASES, preferences.episodeReleases) - putBoolean(KEY_CUSTOM_ALERTS, preferences.customAlerts) - putBoolean(KEY_BADGE_COUNT, preferences.badgeCount) - putBoolean(KEY_SOUND, preferences.sound) - putBoolean(KEY_VIBRATION, preferences.vibration) - apply() - } - Log.d(TAG, "Preferences saved: $preferences") - } - - /** - * Get new articles preference - */ - fun isNewArticlesEnabled(): Boolean = prefs.getBoolean(KEY_NEW_ARTICLES, true) - - /** - * Set new articles preference - */ - fun setNewArticlesEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_NEW_ARTICLES, enabled).apply() - } - - /** - * Get episode releases preference - */ - fun isEpisodeReleasesEnabled(): Boolean = prefs.getBoolean(KEY_EPISODE_RELEASES, true) - - /** - * Set episode releases preference - */ - fun setEpisodeReleasesEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_EPISODE_RELEASES, enabled).apply() - } - - /** - * Get custom alerts preference - */ - fun isCustomAlertsEnabled(): Boolean = prefs.getBoolean(KEY_CUSTOM_ALERTS, true) - - /** - * Set custom alerts preference - */ - fun setCustomAlertsEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_CUSTOM_ALERTS, enabled).apply() - } - - /** - * Get badge count preference - */ - fun isBadgeCountEnabled(): Boolean = prefs.getBoolean(KEY_BADGE_COUNT, true) - - /** - * Set badge count preference - */ - fun setBadgeCountEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_BADGE_COUNT, enabled).apply() - } - - /** - * Get sound preference - */ - fun isSoundEnabled(): Boolean = prefs.getBoolean(KEY_SOUND, true) - - /** - * Set sound preference - */ - fun setSoundEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_SOUND, enabled).apply() - } - - /** - * Get vibration preference - */ - fun isVibrationEnabled(): Boolean = prefs.getBoolean(KEY_VIBRATION, true) - - /** - * Set vibration preference - */ - fun setVibrationEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_VIBRATION, enabled).apply() - } - - /** - * Enable all notifications - */ - fun enableAll() { - setPreferences(NotificationPreferences()) - } - - /** - * Disable all notifications - */ - fun disableAll() { - setPreferences(NotificationPreferences( - newArticles = false, - episodeReleases = false, - customAlerts = false, - badgeCount = false, - sound = false, - vibration = false - )) - } - - /** - * Get all preferences as map - */ - fun getAllPreferences(): Map = prefs.allMap - - /** - * Get preferences key - */ - fun getPrefsName(): String = PREFS_NAME - - /** - * Get preferences name - */ - fun getPreferencesName(): String = PREFS_NAME -} - -/** - * Serializable data class for notification preferences - */ -@Serializable -data class NotificationPreferences( - val newArticles: Boolean = true, - val episodeReleases: Boolean = true, - val customAlerts: Boolean = true, - val badgeCount: Boolean = true, - val sound: Boolean = true, - val vibration: Boolean = true -) - diff --git a/native-route/android/app/src/main/java/com/rssuper/NotificationService.kt b/native-route/android/app/src/main/java/com/rssuper/NotificationService.kt deleted file mode 100644 index a980227..0000000 --- a/native-route/android/app/src/main/java/com/rssuper/NotificationService.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.rssuper - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.IBinder -import android.util.Log -import androidx.core.app.NotificationCompat - -/** - * NotificationService - Main notification service for Android RSSuper - * - * Handles push notifications and local notifications using Android NotificationCompat. - * Supports notification channels, badge management, and permission handling. - */ -class NotificationService : Service() { - - companion object { - private const val TAG = "NotificationService" - private const val NOTIFICATION_CHANNEL_ID = "rssuper_notifications" - private const val NOTIFICATION_CHANNEL_ID_CRITICAL = "rssuper_critical" - private const val NOTIFICATION_CHANNEL_ID_LOW = "rssuper_low" - private const val NOTIFICATION_ID = 1001 - } - - /** - * Get singleton instance - */ - fun getInstance(): NotificationService = instance - - private var instance: NotificationService? = null - - private var notificationManager: NotificationManager? = null - private var context: Context? = null - - /** - * Initialize the notification service - */ - fun initialize(context: Context) { - this.context = context - this.notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? - - // Create notification channels (Android 8.0+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannels() - } - - instance = this - Log.d(TAG, "NotificationService initialized") - } - - /** - * Create notification channels - */ - private fun createNotificationChannels() { - val notificationManager = context?.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager - - // Critical notifications channel - val criticalChannel = NotificationChannel( - NOTIFICATION_CHANNEL_ID_CRITICAL, - "Critical", // Display name - NotificationManager.IMPORTANCE_HIGH // Importance - ).apply { - description = "Critical notifications (e.g., errors, alerts)" - enableVibration(true) - enableLights(true) - setShowBadge(true) - } - - // Low priority notifications channel - val lowChannel = NotificationChannel( - NOTIFICATION_CHANNEL_ID_LOW, - "Low Priority", // Display name - NotificationManager.IMPORTANCE_LOW // Importance - ).apply { - description = "Low priority notifications (e.g., reminders)" - enableVibration(false) - enableLights(false) - setShowBadge(true) - } - - // Regular notifications channel - val regularChannel = NotificationChannel( - NOTIFICATION_CHANNEL_ID, - "RSSuper Notifications", // Display name - NotificationManager.IMPORTANCE_DEFAULT // Importance - ).apply { - description = "General RSSuper notifications" - enableVibration(false) - enableLights(false) - setShowBadge(true) - } - - // Register channels - notificationManager?.createNotificationChannels( - listOf(criticalChannel, lowChannel, regularChannel) - ) - - Log.d(TAG, "Notification channels created") - } - - /** - * Show a local notification - * - * @param title Notification title - * @param text Notification text - * @param icon Resource ID for icon - * @param urgency Urgency level (LOW, NORMAL, CRITICAL) - */ - fun showNotification( - title: String, - text: String, - icon: Int, - urgency: NotificationUrgency = NotificationUrgency.NORMAL - ) { - val notificationManager = notificationManager ?: return - - val channelId = when (urgency) { - NotificationUrgency.CRITICAL -> NOTIFICATION_CHANNEL_ID_CRITICAL - else -> NOTIFICATION_CHANNEL_ID - } - - // Create notification intent - val notificationIntent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - - val pendingIntent = PendingIntent.getActivity( - this, - 0, - notificationIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - // Create notification builder - val builder = NotificationCompat.Builder(this, channelId) - .setSmallIcon(icon) - .setAutoCancel(true) - .setPriority(when (urgency) { - NotificationUrgency.CRITICAL -> NotificationCompat.PRIORITY_HIGH - NotificationUrgency.LOW -> NotificationCompat.PRIORITY_LOW - else -> NotificationCompat.PRIORITY_DEFAULT - }) - .setContentTitle(title) - .setContentText(text) - .setStyle(NotificationCompat.BigTextStyle().bigText(text)) - - builder.setCategory(NotificationCompat.CATEGORY_MESSAGE) - builder.setSound(null) - - // Show notification - val notification = builder.build() - notificationManager.notify(NOTIFICATION_ID, notification) - - Log.d(TAG, "Notification shown: $title") - } - - /** - * Show a critical notification - */ - fun showCriticalNotification(title: String, text: String, icon: Int) { - showNotification(title, text, icon, NotificationUrgency.CRITICAL) - } - - /** - * Show a low priority notification - */ - fun showLowNotification(title: String, text: String, icon: Int) { - showNotification(title, text, icon, NotificationUrgency.LOW) - } - - /** - * Show a normal notification - */ - fun showNormalNotification(title: String, text: String, icon: Int) { - showNotification(title, text, icon, NotificationUrgency.NORMAL) - } - - /** - * Get notification ID - */ - fun getNotificationId(): Int = NOTIFICATION_ID - - /** - * Get service instance - */ - fun getService(): NotificationService = instance ?: this - - /** - * Get context - */ - fun getContext(): Context = context ?: throw IllegalStateException("Context not initialized") - - /** - * Get notification manager - */ - fun getNotificationManager(): NotificationManager = notificationManager ?: throw IllegalStateException("Notification manager not initialized") - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onDestroy() { - super.onDestroy() - Log.d(TAG, "NotificationService destroyed") - } -} - -/** - * Notification urgency levels - */ -enum class NotificationUrgency { - CRITICAL, - LOW, - NORMAL -} - diff --git a/native-route/android/app/src/main/java/com/rssuper/RssuperApplication.kt b/native-route/android/app/src/main/java/com/rssuper/RssuperApplication.kt deleted file mode 100644 index c965fb9..0000000 --- a/native-route/android/app/src/main/java/com/rssuper/RssuperApplication.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.rssuper - -import android.app.Application -import android.util.Log -import androidx.work.Configuration -import androidx.work.WorkManager -import java.util.concurrent.Executors - -/** - * RssuperApplication - Application class - * - * Provides global context for the app and initializes WorkManager for background sync. - */ -class RssuperApplication : Application(), Configuration.Provider { - - companion object { - private const val TAG = "RssuperApplication" - - /** - * Get application instance - */ - fun getInstance(): RssuperApplication = instance - - private var instance: RssuperApplication? = null - - /** - * Get sync scheduler instance - */ - fun getSyncScheduler(): SyncScheduler { - return instance?.let { SyncScheduler(it) } ?: SyncScheduler(getInstance()) - } - } - - override fun onCreate() { - super.onCreate() - instance = this - - // Initialize WorkManager - initializeWorkManager() - - // Schedule initial sync - scheduleInitialSync() - - Log.d(TAG, "RssuperApplication created") - } - - /** - * WorkManager configuration - */ - override val workManagerConfiguration: Configuration - get() = Configuration.Builder() - .setMinimumLoggingLevel(Log.DEBUG) - .setTaskExecutor(Executors.newFixedThreadPool(3).asExecutor()) - .build() - - /** - * Initialize WorkManager - */ - private fun initializeWorkManager() { - WorkManager.initialize(this, workManagerConfiguration) - Log.d(TAG, "WorkManager initialized") - } - - /** - * Schedule initial background sync - */ - private fun scheduleInitialSync() { - val syncScheduler = SyncScheduler(this) - - // Check if sync is already scheduled - if (!syncScheduler.isSyncScheduled()) { - syncScheduler.scheduleNextSync() - Log.d(TAG, "Initial sync scheduled") - } - } - - /** - * Get application instance - */ - fun getApplication(): RssuperApplication = instance ?: this -} - diff --git a/native-route/android/app/src/main/java/com/rssuper/SyncConfiguration.kt b/native-route/android/app/src/main/java/com/rssuper/SyncConfiguration.kt deleted file mode 100644 index acd5010..0000000 --- a/native-route/android/app/src/main/java/com/rssuper/SyncConfiguration.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.rssuper - -import android.content.Context -import androidx.work.Constraints -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequest -import androidx.work.PeriodicWorkRequestBuilder -import java.util.concurrent.TimeUnit - -/** - * SyncConfiguration - Configuration for background sync - * - * Defines sync intervals, constraints, and other configuration values. - */ -object SyncConfiguration { - - companion object { - private const val TAG = "SyncConfiguration" - - /** - * Work name for periodic sync - */ - const val SYNC_WORK_NAME = "rssuper_periodic_sync" - - /** - * Default sync interval (6 hours) - */ - const val DEFAULT_SYNC_INTERVAL_HOURS: Long = 6 - - /** - * Minimum sync interval (15 minutes) - for testing - */ - const val MINIMUM_SYNC_INTERVAL_MINUTES: Long = 15 - - /** - * Maximum sync interval (24 hours) - */ - const val MAXIMUM_SYNC_INTERVAL_HOURS: Long = 24 - - /** - * Sync interval flexibility (20% of interval) - */ - fun getFlexibility(intervalHours: Long): Long { - return (intervalHours * 60 * 0.2).toLong() // 20% flexibility in minutes - } - - /** - * Maximum feeds to sync per batch - */ - const val MAX_FEEDS_PER_BATCH = 20 - - /** - * Maximum concurrent feed fetches - */ - const val MAX_CONCURRENT_FETCHES = 3 - - /** - * Feed fetch timeout (30 seconds) - */ - const val FEED_FETCH_TIMEOUT_SECONDS: Long = 30 - - /** - * Delay between batches (500ms) - */ - const val BATCH_DELAY_MILLIS: Long = 500 - - /** - * SharedPreferences key for last sync date - */ - const val PREFS_NAME = "RSSuperSyncPrefs" - const val PREF_LAST_SYNC_DATE = "last_sync_date" - const val PREF_PREFERRED_SYNC_INTERVAL = "preferred_sync_interval" - - /** - * Create periodic work request with default configuration - */ - fun createPeriodicWorkRequest(context: Context): PeriodicWorkRequest { - return PeriodicWorkRequestBuilder( - DEFAULT_SYNC_INTERVAL_HOURS, - TimeUnit.HOURS - ).setConstraints(getDefaultConstraints()) - .setBackoffCriteria( - androidx.work.BackoffPolicy.EXPONENTIAL, - 15, TimeUnit.MINUTES - ) - .build() - } - - /** - * Create periodic work request with custom interval - */ - fun createPeriodicWorkRequest( - context: Context, - intervalHours: Long - ): PeriodicWorkRequest { - val clampedInterval = intervalHours.coerceIn( - MINIMUM_SYNC_INTERVAL_MINUTES / 60, - MAXIMUM_SYNC_INTERVAL_HOURS - ) - - return PeriodicWorkRequestBuilder( - clampedInterval, - TimeUnit.HOURS - ).setConstraints(getDefaultConstraints()) - .setBackoffCriteria( - androidx.work.BackoffPolicy.EXPONENTIAL, - 15, TimeUnit.MINUTES - ) - .build() - } - - /** - * Get default constraints for sync work - */ - fun getDefaultConstraints(): Constraints { - return Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .setRequiresBatteryNotLow(false) - .setRequiresCharging(false) - .build() - } - - /** - * Get strict constraints (only on Wi-Fi and charging) - */ - fun getStrictConstraints(): Constraints { - return Constraints.Builder() - .setRequiredNetworkType(NetworkType.UNMETERED) - .setRequiresBatteryNotLow(true) - .setRequiresCharging(true) - .build() - } - } -} diff --git a/native-route/android/app/src/main/java/com/rssuper/SyncScheduler.kt b/native-route/android/app/src/main/java/com/rssuper/SyncScheduler.kt deleted file mode 100644 index bf1b32d..0000000 --- a/native-route/android/app/src/main/java/com/rssuper/SyncScheduler.kt +++ /dev/null @@ -1,217 +0,0 @@ -package com.rssuper - -import android.content.Context -import android.util.Log -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.WorkManager -import java.util.concurrent.TimeUnit - -/** - * SyncScheduler - Manages background sync scheduling - * - * Handles intelligent scheduling based on user behavior and system conditions. - */ -class SyncScheduler(private val context: Context) { - - companion object { - private const val TAG = "SyncScheduler" - } - - private val workManager = WorkManager.getInstance(context) - private val prefs = context.getSharedPreferences( - SyncConfiguration.PREFS_NAME, - Context.MODE_PRIVATE - ) - - /** - * Last sync date from SharedPreferences - */ - val lastSyncDate: Long? - get() = prefs.getLong(SyncConfiguration.PREF_LAST_SYNC_DATE, 0L).takeIf { it > 0 } - - /** - * Preferred sync interval in hours - */ - var preferredSyncIntervalHours: Long - get() = prefs.getLong( - SyncConfiguration.PREF_PREFERRED_SYNC_INTERVAL, - SyncConfiguration.DEFAULT_SYNC_INTERVAL_HOURS - ) - set(value) { - val clamped = value.coerceIn( - 1, - SyncConfiguration.MAXIMUM_SYNC_INTERVAL_HOURS - ) - prefs.edit() - .putLong(SyncConfiguration.PREF_PREFERRED_SYNC_INTERVAL, clamped) - .apply() - } - - /** - * Time since last sync in seconds - */ - val timeSinceLastSync: Long - get() { - val lastSync = lastSyncDate ?: return Long.MAX_VALUE - return (System.currentTimeMillis() - lastSync) / 1000 - } - - /** - * Whether a sync is due - */ - val isSyncDue: Boolean - get() { - val intervalSeconds = preferredSyncIntervalHours * 3600 - return timeSinceLastSync >= intervalSeconds - } - - /** - * Schedule the next sync based on current conditions - */ - fun scheduleNextSync(): Boolean { - // Check if we should sync immediately - if (isSyncDue && timeSinceLastSync >= preferredSyncIntervalHours * 3600 * 2) { - Log.d(TAG, "Sync is significantly overdue, scheduling immediate sync") - return scheduleImmediateSync() - } - - // Schedule periodic sync - val workRequest = SyncConfiguration.createPeriodicWorkRequest( - context, - preferredSyncIntervalHours - ) - - workManager.enqueueUniquePeriodicWork( - SyncConfiguration.SYNC_WORK_NAME, - ExistingPeriodicWorkPolicy.UPDATE, - workRequest - ) - - Log.d(TAG, "Next sync scheduled for ${preferredSyncIntervalHours}h interval") - return true - } - - /** - * Update preferred sync interval based on user behavior - */ - fun updateSyncInterval( - numberOfFeeds: Int, - userActivityLevel: UserActivityLevel - ) { - var baseInterval: Long - - // Adjust base interval based on number of feeds - baseInterval = when { - numberOfFeeds < 10 -> 4 // 4 hours for small feed lists - numberOfFeeds < 50 -> 6 // 6 hours for medium feed lists - numberOfFeeds < 200 -> 12 // 12 hours for large feed lists - else -> 24 // 24 hours for very large feed lists - } - - // Adjust based on user activity - preferredSyncIntervalHours = when (userActivityLevel) { - UserActivityLevel.HIGH -> (baseInterval * 0.5).toLong() // Sync more frequently - UserActivityLevel.MEDIUM -> baseInterval - UserActivityLevel.LOW -> baseInterval * 2 // Sync less frequently - } - - Log.d(TAG, "Sync interval updated to: ${preferredSyncIntervalHours}h (feeds: $numberOfFeeds, activity: $userActivityLevel)") - - // Re-schedule with new interval - scheduleNextSync() - } - - /** - * Get recommended sync interval based on current conditions - */ - fun recommendedSyncInterval(): Long = preferredSyncIntervalHours - - /** - * Reset sync schedule - */ - fun resetSyncSchedule() { - prefs.edit() - .remove(SyncConfiguration.PREF_LAST_SYNC_DATE) - .putLong(SyncConfiguration.PREF_PREFERRED_SYNC_INTERVAL, SyncConfiguration.DEFAULT_SYNC_INTERVAL_HOURS) - .apply() - - preferredSyncIntervalHours = SyncConfiguration.DEFAULT_SYNC_INTERVAL_HOURS - Log.d(TAG, "Sync schedule reset") - } - - /** - * Cancel all pending sync work - */ - fun cancelSync() { - workManager.cancelUniqueWork(SyncConfiguration.SYNC_WORK_NAME) - Log.d(TAG, "Sync cancelled") - } - - /** - * Check if sync work is currently scheduled - */ - fun isSyncScheduled(): Boolean { - val workInfos = workManager.getWorkInfosForUniqueWork( - SyncConfiguration.SYNC_WORK_NAME - ).get() - return workInfos.isNotEmpty() - } - - /** - * Get the state of the sync work - */ - fun getSyncWorkState(): androidx.work.WorkInfo.State? { - val workInfos = workManager.getWorkInfosForUniqueWork( - SyncConfiguration.SYNC_WORK_NAME - ).get() - return workInfos.lastOrNull()?.state - } - - /** - * Schedule immediate sync (for testing or user-initiated) - */ - private fun scheduleImmediateSync(): Boolean { - val immediateWork = androidx.work.OneTimeWorkRequestBuilder() - .setConstraints(SyncConfiguration.getDefaultConstraints()) - .addTag("immediate_sync") - .build() - - workManager.enqueue(immediateWork) - Log.d(TAG, "Immediate sync scheduled") - return true - } -} - -/** - * UserActivityLevel - User activity level for adaptive sync scheduling - */ -enum class UserActivityLevel { - /** High activity: user actively reading, sync more frequently */ - HIGH, - - /** Medium activity: normal usage */ - MEDIUM, - - /** Low activity: inactive user, sync less frequently */ - LOW; - - companion object { - /** - * Calculate activity level based on app usage - */ - fun calculate(dailyOpenCount: Int, lastOpenedAgoSeconds: Long): UserActivityLevel { - // High activity: opened 5+ times today OR opened within last hour - if (dailyOpenCount >= 5 || lastOpenedAgoSeconds < 3600) { - return HIGH - } - - // Medium activity: opened 2+ times today OR opened within last day - if (dailyOpenCount >= 2 || lastOpenedAgoSeconds < 86400) { - return MEDIUM - } - - // Low activity: otherwise - return LOW - } - } -} diff --git a/native-route/android/app/src/main/java/com/rssuper/SyncWorker.kt b/native-route/android/app/src/main/java/com/rssuper/SyncWorker.kt deleted file mode 100644 index 9fa6a87..0000000 --- a/native-route/android/app/src/main/java/com/rssuper/SyncWorker.kt +++ /dev/null @@ -1,268 +0,0 @@ -package com.rssuper - -import android.content.Context -import android.util.Log -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.takeWhile -import kotlinx.coroutines.tasks.await -import kotlinx.coroutines.withContext -import java.util.concurrent.CancellationException - -/** - * SyncWorker - Performs the actual background sync work - * - * Fetches updates from feeds and processes new articles. - * Uses WorkManager for reliable, deferrable background processing. - */ -class SyncWorker( - context: Context, - params: WorkerParameters -) : CoroutineWorker(context, params) { - - companion object { - private const val TAG = "SyncWorker" - - /** - * Key for feeds synced count in result - */ - const val KEY_FEEDS_SYNCED = "feeds_synced" - - /** - * Key for articles fetched count in result - */ - const val KEY_ARTICLES_FETCHED = "articles_fetched" - - /** - * Key for error count in result - */ - const val KEY_ERROR_COUNT = "error_count" - - /** - * Key for error details in result - */ - const val KEY_ERRORS = "errors" - } - - private val syncScheduler = SyncScheduler(applicationContext) - - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - var feedsSynced = 0 - var articlesFetched = 0 - val errors = mutableListOf() - - Log.d(TAG, "Starting background sync") - - try { - // Get all subscriptions that need syncing - val subscriptions = fetchSubscriptionsNeedingSync() - - Log.d(TAG, "Syncing ${subscriptions.size} subscriptions") - - if (subscriptions.isEmpty()) { - Log.d(TAG, "No subscriptions to sync") - return@withContext Result.success(buildResult(feedsSynced, articlesFetched, errors)) - } - - // Process subscriptions in batches - val batches = subscriptions.chunked(SyncConfiguration.MAX_FEEDS_PER_BATCH) - - for ((batchIndex, batch) in batches.withIndex()) { - // Check if work is cancelled - if (isStopped) { - Log.w(TAG, "Sync cancelled by system") - return@withContext Result.retry() - } - - Log.d(TAG, "Processing batch ${batchIndex + 1}/${batches.size} (${batch.size} feeds)") - - val batchResult = syncBatch(batch) - feedsSynced += batchResult.feedsSynced - articlesFetched += batchResult.articlesFetched - errors.addAll(batchResult.errors) - - // Small delay between batches to be battery-friendly - if (batchIndex < batches.size - 1) { - kotlinx.coroutines.delay(SyncConfiguration.BATCH_DELAY_MILLIS) - } - } - - // Update last sync date - applicationContext.getSharedPreferences( - SyncConfiguration.PREFS_NAME, - Context.MODE_PRIVATE - ).edit() - .putLong(SyncConfiguration.PREF_LAST_SYNC_DATE, System.currentTimeMillis()) - .apply() - - Log.d(TAG, "Sync completed: $feedsSynced feeds, $articlesFetched articles, ${errors.size} errors") - - // Return failure if there were errors, but still mark as success if some work was done - val result = if (errors.isNotEmpty() && feedsSynced == 0) { - Result.retry() - } else { - Result.success(buildResult(feedsSynced, articlesFetched, errors)) - } - - return@withContext result - - } catch (e: CancellationException) { - Log.w(TAG, "Sync cancelled", e) - throw e - } catch (e: Exception) { - Log.e(TAG, "Sync failed", e) - errors.add(e) - Result.failure(buildResult(feedsSynced, articlesFetched, errors)) - } - } - - /** - * Fetch subscriptions that need syncing - */ - private suspend fun fetchSubscriptionsNeedingSync(): List = withContext(Dispatchers.IO) { - // TODO: Replace with actual database query - // For now, return empty list as placeholder - // Example: return database.subscriptionDao().getAllActiveSubscriptions() - emptyList() - } - - /** - * Sync a batch of subscriptions - */ - private suspend fun syncBatch(subscriptions: List): SyncResult = withContext(Dispatchers.IO) { - var feedsSynced = 0 - var articlesFetched = 0 - val errors = mutableListOf() - - // Process subscriptions with concurrency limit - subscriptions.forEach { subscription -> - // Check if work is cancelled - if (isStopped) return@forEach - - try { - val feedData = fetchFeedData(subscription) - - if (feedData != null) { - processFeedData(feedData, subscription.id) - feedsSynced++ - articlesFetched += feedData.articles.count() - - Log.d(TAG, "Synced ${subscription.title}: ${feedData.articles.count()} articles") - } - - } catch (e: Exception) { - errors.add(e) - Log.e(TAG, "Error syncing ${subscription.title}", e) - } - } - - SyncResult(feedsSynced, articlesFetched, errors) - } - - /** - * Fetch feed data for a subscription - */ - private suspend fun fetchFeedData(subscription: Subscription): FeedData? = withContext(Dispatchers.IO) { - // TODO: Implement actual feed fetching - // Example implementation: - // - // val url = URL(subscription.url) - // val request = HttpRequest.newBuilder() - // .uri(url) - // .timeout(Duration.ofSeconds(SyncConfiguration.FEED_FETCH_TIMEOUT_SECONDS)) - // .GET() - // .build() - // - // val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()) - // val feedContent = response.body() - // - // Parse RSS/Atom feed - // val feedData = rssParser.parse(feedContent) - // return@withContext feedData - - // Placeholder - return null for now - null - } - - /** - * Process fetched feed data - */ - private suspend fun processFeedData(feedData: FeedData, subscriptionId: String) = withContext(Dispatchers.IO) { - // TODO: Implement actual feed data processing - // - Store new articles - // - Update feed metadata - // - Handle duplicates - // - // Example: - // val newArticles = feedData.articles.filter { article -> - // database.articleDao().getArticleById(article.id) == null - // } - // database.articleDao().insertAll(newArticles.map { it.toEntity(subscriptionId) }) - - Log.d(TAG, "Processing ${feedData.articles.count()} articles for ${feedData.title}") - } - - /** - * Build output data for the work result - */ - private fun buildResult( - feedsSynced: Int, - articlesFetched: Int, - errors: List - ): android.content.Intent { - val intent = android.content.Intent() - intent.putExtra(KEY_FEEDS_SYNCED, feedsSynced) - intent.putExtra(KEY_ARTICLES_FETCHED, articlesFetched) - intent.putExtra(KEY_ERROR_COUNT, errors.size) - - if (errors.isNotEmpty()) { - val errorMessages = errors.map { it.message ?: it.toString() } - intent.putStringArrayListExtra(KEY_ERRORS, ArrayList(errorMessages)) - } - - return intent - } -} - -/** - * SyncResult - Result of a sync operation - */ -data class SyncResult( - val feedsSynced: Int, - val articlesFetched: Int, - val errors: List -) - -/** - * Subscription - Model for a feed subscription - */ -data class Subscription( - val id: String, - val title: String, - val url: String, - val lastSyncDate: Long? -) - -/** - * FeedData - Parsed feed data - */ -data class FeedData( - val title: String, - val articles: List
-) - -/** - * Article - Model for a feed article - */ -data class Article( - val id: String, - val title: String, - val link: String?, - val published: Long?, - val content: String? -) - - diff --git a/native-route/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/native-route/android/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 6d7952d..0000000 --- a/native-route/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/native-route/android/app/src/main/res/drawable/ic_notification.xml b/native-route/android/app/src/main/res/drawable/ic_notification.xml deleted file mode 100644 index 1bd6b70..0000000 --- a/native-route/android/app/src/main/res/drawable/ic_notification.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/native-route/android/app/src/main/res/drawable/ic_notification_foreground.xml b/native-route/android/app/src/main/res/drawable/ic_notification_foreground.xml deleted file mode 100644 index 6d7952d..0000000 --- a/native-route/android/app/src/main/res/drawable/ic_notification_foreground.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/native-route/android/app/src/main/res/values/colors.xml b/native-route/android/app/src/main/res/values/colors.xml deleted file mode 100644 index b215403..0000000 --- a/native-route/android/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - #6200EE - #3700B3 - #BB86FC - #03DAC6 - #6200EE - #FF1744 - #4CAF50 - #2196F3 - #FFFFFF - #000000 - #757575 - #F5F5F5 - diff --git a/native-route/android/app/src/main/res/values/resources.xml b/native-route/android/app/src/main/res/values/resources.xml deleted file mode 100644 index 0beb99e..0000000 --- a/native-route/android/app/src/main/res/values/resources.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - @drawable/ic_notification - @drawable/ic_launcher - diff --git a/native-route/android/app/src/main/res/values/strings.xml b/native-route/android/app/src/main/res/values/strings.xml deleted file mode 100644 index 4b05c6b..0000000 --- a/native-route/android/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - RSSuper - RSSuper Notifications - RSSuper notification notifications - Critical - Critical RSSuper notifications - Low Priority - Low priority RSSuper notifications - Open RSSuper - Mark as read - diff --git a/native-route/android/app/src/main/res/values/styles.xml b/native-route/android/app/src/main/res/values/styles.xml deleted file mode 100644 index f06e6a2..0000000 --- a/native-route/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/native-route/android/app/src/test/java/com/rssuper/NotificationServiceTests.kt b/native-route/android/app/src/test/java/com/rssuper/NotificationServiceTests.kt deleted file mode 100644 index 4acbba7..0000000 --- a/native-route/android/app/src/test/java/com/rssuper/NotificationServiceTests.kt +++ /dev/null @@ -1,287 +0,0 @@ -package com.rssuper - -import android.content.Context -import android.content.Intent -import androidx.test.core.app.ApplicationTestCase -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.* - -/** - * NotificationServiceTests - Unit tests for NotificationService - */ -class NotificationServiceTests : ApplicationTestCase() { - - private lateinit var context: Context - private lateinit var notificationService: NotificationService - - override val packageName: String get() = "com.rssuper" - - @Before - fun setUp() { - context = getTargetContext() - notificationService = NotificationService() - notificationService.initialize(context) - } - - @Test - fun testNotificationService_initialization() { - assertNotNull("NotificationService should be initialized", notificationService) - assertNotNull("Context should be set", notificationService.getContext()) - } - - @Test - fun testNotificationService_getInstance() { - val instance = notificationService.getInstance() - assertNotNull("Instance should not be null", instance) - assertEquals("Instance should be the same object", notificationService, instance) - } - - @Test - fun testNotificationService_getNotificationId() { - assertEquals("Notification ID should be 1001", 1001, notificationService.getNotificationId()) - } - - @Test - fun testNotificationService_getService() { - val service = notificationService.getService() - assertNotNull("Service should not be null", service) - } - - @Test - fun testNotificationUrgency_values() { - assertEquals("CRITICAL should be 0", 0, NotificationUrgency.CRITICAL.ordinal) - assertEquals("LOW should be 1", 1, NotificationUrgency.LOW.ordinal) - assertEquals("NORMAL should be 2", 2, NotificationUrgency.NORMAL.ordinal) - } - - @Test - fun testNotificationUrgency_critical() { - assertEquals("Critical urgency should be CRITICAL", NotificationUrgency.CRITICAL, NotificationUrgency.CRITICAL) - } - - @Test - fun testNotificationUrgency_low() { - assertEquals("Low urgency should be LOW", NotificationUrgency.LOW, NotificationUrgency.LOW) - } - - @Test - fun testNotificationUrgency_normal() { - assertEquals("Normal urgency should be NORMAL", NotificationUrgency.NORMAL, NotificationUrgency.NORMAL) - } - - @Test - fun testNotificationService_showCriticalNotification() { - // Test that showCriticalNotification calls showNotification with CRITICAL urgency - // Note: This is a basic test - actual notification display would require Android environment - val service = NotificationService() - service.initialize(context) - - // Verify the method exists and can be called - assertDoesNotThrow { - service.showCriticalNotification("Test Title", "Test Text", 0) - } - } - - @Test - fun testNotificationService_showLowNotification() { - val service = NotificationService() - service.initialize(context) - - assertDoesNotThrow { - service.showLowNotification("Test Title", "Test Text", 0) - } - } - - @Test - fun testNotificationService_showNormalNotification() { - val service = NotificationService() - service.initialize(context) - - assertDoesNotThrow { - service.showNormalNotification("Test Title", "Test Text", 0) - } - } -} - -/** - * NotificationManagerTests - Unit tests for NotificationManager - */ -class NotificationManagerTests : ApplicationTestCase() { - - private lateinit var context: Context - private lateinit var notificationManager: NotificationManager - - override val packageName: String get() = "com.rssuper" - - @Before - fun setUp() { - context = getTargetContext() - notificationManager = NotificationManager(context) - } - - @Test - fun testNotificationManager_initialization() { - assertNotNull("NotificationManager should be initialized", notificationManager) - assertNotNull("Context should be set", notificationManager.getContext()) - } - - @Test - fun testNotificationManager_getPreferences_defaultValues() { - val prefs = notificationManager.getPreferences() - - assertTrue("newArticles should default to true", prefs.newArticles) - assertTrue("episodeReleases should default to true", prefs.episodeReleases) - assertTrue("customAlerts should default to true", prefs.customAlerts) - assertTrue("badgeCount should default to true", prefs.badgeCount) - assertTrue("sound should default to true", prefs.sound) - assertTrue("vibration should default to true", prefs.vibration) - } - - @Test - fun testNotificationManager_setPreferences() { - val preferences = NotificationPreferences( - newArticles = false, - episodeReleases = false, - customAlerts = false, - badgeCount = false, - sound = false, - vibration = false - ) - - assertDoesNotThrow { - notificationManager.setPreferences(preferences) - } - - val loadedPrefs = notificationManager.getPreferences() - assertEquals("newArticles should match", preferences.newArticles, loadedPrefs.newArticles) - assertEquals("episodeReleases should match", preferences.episodeReleases, loadedPrefs.episodeReleases) - assertEquals("customAlerts should match", preferences.customAlerts, loadedPrefs.customAlerts) - assertEquals("badgeCount should match", preferences.badgeCount, loadedPrefs.badgeCount) - assertEquals("sound should match", preferences.sound, loadedPrefs.sound) - assertEquals("vibration should match", preferences.vibration, loadedPrefs.vibration) - } - - @Test - fun testNotificationManager_getNotificationService() { - val service = notificationManager.getNotificationService() - assertNotNull("NotificationService should not be null", service) - } - - @Test - fun testNotificationManager_getNotificationManager() { - val mgr = notificationManager.getNotificationManager() - assertNotNull("NotificationManager should not be null", mgr) - } - - @Test - fun testNotificationManager_getAppIntent() { - val intent = notificationManager.getAppIntent() - assertNotNull("Intent should not be null", intent) - } - - @Test - fun testNotificationManager_getPrefsName() { - assertEquals("Prefs name should be notification_prefs", "notification_prefs", notificationManager.getPrefsName()) - } -} - -/** - * NotificationPreferencesTests - Unit tests for NotificationPreferences data class - */ -class NotificationPreferencesTests : ApplicationTestCase() { - - private lateinit var context: Context - - override val packageName: String get() = "com.rssuper" - - @Before - fun setUp() { - context = getTargetContext() - } - - @Test - fun testNotificationPreferences_defaultValues() { - val prefs = NotificationPreferences() - - assertTrue("newArticles should default to true", prefs.newArticles) - assertTrue("episodeReleases should default to true", prefs.episodeReleases) - assertTrue("customAlerts should default to true", prefs.customAlerts) - assertTrue("badgeCount should default to true", prefs.badgeCount) - assertTrue("sound should default to true", prefs.sound) - assertTrue("vibration should default to true", prefs.vibration) - } - - @Test - fun testNotificationPreferences_customValues() { - val prefs = NotificationPreferences( - newArticles = false, - episodeReleases = false, - customAlerts = false, - badgeCount = false, - sound = false, - vibration = false - ) - - assertFalse("newArticles should be false", prefs.newArticles) - assertFalse("episodeReleases should be false", prefs.episodeReleases) - assertFalse("customAlerts should be false", prefs.customAlerts) - assertFalse("badgeCount should be false", prefs.badgeCount) - assertFalse("sound should be false", prefs.sound) - assertFalse("vibration should be false", prefs.vibration) - } - - @Test - fun testNotificationPreferences_partialValues() { - val prefs = NotificationPreferences(newArticles = false, sound = false) - - assertFalse("newArticles should be false", prefs.newArticles) - assertTrue("episodeReleases should default to true", prefs.episodeReleases) - assertTrue("customAlerts should default to true", prefs.customAlerts) - assertTrue("badgeCount should default to true", prefs.badgeCount) - assertFalse("sound should be false", prefs.sound) - assertTrue("vibration should default to true", prefs.vibration) - } - - @Test - fun testNotificationPreferences_equality() { - val prefs1 = NotificationPreferences( - newArticles = true, - episodeReleases = false, - customAlerts = true, - badgeCount = false, - sound = true, - vibration = false - ) - - val prefs2 = NotificationPreferences( - newArticles = true, - episodeReleases = false, - customAlerts = true, - badgeCount = false, - sound = true, - vibration = false - ) - - assertEquals("Preferences with same values should be equal", prefs1, prefs2) - } - - @Test - fun testNotificationPreferences_hashCode() { - val prefs1 = NotificationPreferences() - val prefs2 = NotificationPreferences() - - assertEquals("Equal objects should have equal hash codes", prefs1.hashCode(), prefs2.hashCode()) - } - - @Test - fun testNotificationPreferences_copy() { - val prefs1 = NotificationPreferences(newArticles = false) - val prefs2 = prefs1.copy(newArticles = true) - - assertFalse("prefs1 newArticles should be false", prefs1.newArticles) - assertTrue("prefs2 newArticles should be true", prefs2.newArticles) - assertEquals("prefs2 should have same other values", prefs1.episodeReleases, prefs2.episodeReleases) - } -} diff --git a/native-route/android/app/src/test/java/com/rssuper/SyncWorkerTests.kt b/native-route/android/app/src/test/java/com/rssuper/SyncWorkerTests.kt deleted file mode 100644 index 12c9055..0000000 --- a/native-route/android/app/src/test/java/com/rssuper/SyncWorkerTests.kt +++ /dev/null @@ -1,168 +0,0 @@ -package com.rssuper - -import android.content.Context -import androidx.test.core.app.ApplicationTestCase -import androidx.work_testing.FakeWorkManagerConfiguration -import androidx.work_testing.TestDriver -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import java.util.concurrent.TimeUnit - -/** - * SyncWorkerTests - Unit tests for SyncWorker - */ -class SyncWorkerTests : ApplicationTestCase() { - - private lateinit var context: Context - private lateinit var syncScheduler: SyncScheduler - - override val packageName: String get() = "com.rssuper" - - @Before - fun setUp() { - context = getTargetContext() - syncScheduler = SyncScheduler(context) - - // Clear any existing sync state - syncScheduler.resetSyncSchedule() - } - - @Test - fun testSyncScheduler_initialState() { - // Test initial state - assertNull("Last sync date should be null initially", syncScheduler.lastSyncDate) - assertEquals( - "Default sync interval should be 6 hours", - SyncConfiguration.DEFAULT_SYNC_INTERVAL_HOURS, - syncScheduler.preferredSyncIntervalHours - ) - assertTrue("Sync should be due initially", syncScheduler.isSyncDue) - } - - @Test - fun testSyncScheduler_updateSyncInterval_withFewFeeds() { - // Test with few feeds (high frequency) - syncScheduler.updateSyncInterval(5, UserActivityLevel.HIGH) - - assertTrue( - "Sync interval should be reduced for few feeds with high activity", - syncScheduler.preferredSyncIntervalHours <= 2 - ) - } - - @Test - fun testSyncScheduler_updateSyncInterval_withManyFeeds() { - // Test with many feeds (lower frequency) - syncScheduler.updateSyncInterval(500, UserActivityLevel.LOW) - - assertTrue( - "Sync interval should be increased for many feeds with low activity", - syncScheduler.preferredSyncIntervalHours >= 24 - ) - } - - @Test - fun testSyncScheduler_updateSyncInterval_clampsToMax() { - // Test that interval is clamped to maximum - syncScheduler.updateSyncInterval(1000, UserActivityLevel.LOW) - - assertTrue( - "Sync interval should not exceed maximum", - syncScheduler.preferredSyncIntervalHours <= SyncConfiguration.MAXIMUM_SYNC_INTERVAL_HOURS - ) - } - - @Test - fun testSyncScheduler_isSyncDue_afterUpdate() { - // Simulate a sync by setting last sync date - syncScheduler.getSharedPreferences( - SyncConfiguration.PREFS_NAME, - Context.MODE_PRIVATE - ).edit() - .putLong(SyncConfiguration.PREF_LAST_SYNC_DATE, System.currentTimeMillis()) - .apply() - - assertFalse("Sync should not be due immediately after sync", syncScheduler.isSyncDue) - } - - @Test - fun testSyncScheduler_resetSyncSchedule() { - // Set some state - syncScheduler.preferredSyncIntervalHours = 12 - syncScheduler.getSharedPreferences( - SyncConfiguration.PREFS_NAME, - Context.MODE_PRIVATE - ).edit() - .putLong(SyncConfiguration.PREF_LAST_SYNC_DATE, System.currentTimeMillis()) - .apply() - - // Reset - syncScheduler.resetSyncSchedule() - - // Verify reset - assertNull("Last sync date should be null after reset", syncScheduler.lastSyncDate) - assertEquals( - "Sync interval should be reset to default", - SyncConfiguration.DEFAULT_SYNC_INTERVAL_HOURS, - syncScheduler.preferredSyncIntervalHours - ) - } - - @Test - fun testUserActivityLevel_calculation_highActivity() { - val activityLevel = UserActivityLevel.calculate(dailyOpenCount = 10, lastOpenedAgoSeconds = 60) - assertEquals("Should be HIGH activity", UserActivityLevel.HIGH, activityLevel) - } - - @Test - fun testUserActivityLevel_calculation_mediumActivity() { - val activityLevel = UserActivityLevel.calculate(dailyOpenCount = 3, lastOpenedAgoSeconds = 3600) - assertEquals("Should be MEDIUM activity", UserActivityLevel.MEDIUM, activityLevel) - } - - @Test - fun testUserActivityLevel_calculation_lowActivity() { - val activityLevel = UserActivityLevel.calculate(dailyOpenCount = 0, lastOpenedAgoSeconds = 86400 * 7) - assertEquals("Should be LOW activity", UserActivityLevel.LOW, activityLevel) - } - - @Test - fun testSyncConfiguration_createPeriodicWorkRequest() { - val workRequest = SyncConfiguration.createPeriodicWorkRequest(context) - - assertNotNull("Work request should not be null", workRequest) - assertEquals( - "Interval should be default (6 hours)", - SyncConfiguration.DEFAULT_SYNC_INTERVAL_HOURS, - workRequest.intervalDuration, - TimeUnit.HOURS - ) - } - - @Test - fun testSyncConfiguration_createPeriodicWorkRequest_customInterval() { - val workRequest = SyncConfiguration.createPeriodicWorkRequest(context, 12) - - assertEquals( - "Interval should be custom (12 hours)", - 12L, - workRequest.intervalDuration, - TimeUnit.HOURS - ) - } - - @Test - fun testSyncConfiguration_constraints() { - val defaultConstraints = SyncConfiguration.getDefaultConstraints() - val strictConstraints = SyncConfiguration.getStrictConstraints() - - // Default constraints should require network but not charging - assertTrue("Default constraints should require network", defaultConstraints.requiredNetworkType != androidx.work.NetworkType.NOT_REQUIRED) - assertFalse("Default constraints should not require charging", defaultConstraints.requiresCharging) - - // Strict constraints should require Wi-Fi and charging - assertEquals("Strict constraints should require Wi-Fi", androidx.work.NetworkType.UNMETERED, strictConstraints.requiredNetworkType) - assertTrue("Strict constraints should require charging", strictConstraints.requiresCharging) - } -} diff --git a/native-route/ios/RSSuper/AppDelegate.swift b/native-route/ios/RSSuper/AppDelegate.swift deleted file mode 100644 index fbd92c6..0000000 --- a/native-route/ios/RSSuper/AppDelegate.swift +++ /dev/null @@ -1,124 +0,0 @@ -import UIKit -import UserNotifications - -@main -class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { - - var notificationManager: NotificationManager? - var notificationPreferencesStore: NotificationPreferencesStore? - var settingsStore: SettingsStore? - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Initialize settings store - settingsStore = SettingsStore.shared - - // 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.default.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) { - 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") -} - diff --git a/native-route/ios/RSSuper/BackgroundSyncService.swift b/native-route/ios/RSSuper/BackgroundSyncService.swift deleted file mode 100644 index b2f4bfb..0000000 --- a/native-route/ios/RSSuper/BackgroundSyncService.swift +++ /dev/null @@ -1,234 +0,0 @@ -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 - } -} diff --git a/native-route/ios/RSSuper/CoreData/CoreDataModel.ent b/native-route/ios/RSSuper/CoreData/CoreDataModel.ent deleted file mode 100644 index 89eff3b..0000000 --- a/native-route/ios/RSSuper/CoreData/CoreDataModel.ent +++ /dev/null @@ -1,201 +0,0 @@ - - - FeedItem - - - id - NSUUID - true - - - subscriptionId - NSString - true - - - title - NSString - true - true - true - - - link - NSString - false - true - true - - - description - NSString - false - true - true - - - content - NSString - false - true - true - - - author - NSString - false - true - true - - - published - NSString - false - - - updated - NSString - false - - - categories - NSString - false - - - enclosureUrl - NSString - false - - - enclosureType - NSString - false - - - enclosureLength - NSNumber - false - - - guid - NSString - false - - - isRead - NSNumber - true - - - isStarred - NSNumber - true - - - - - subscription - FeedItem - FeedSubscription - false - true - - - - - - FeedSubscription - - - id - NSUUID - true - - - url - NSString - true - - - title - NSString - true - - - enabled - NSNumber - true - - - lastFetchedAt - NSNumber - false - - - nextFetchAt - NSNumber - false - - - error - NSString - false - - - - - feedItems - FeedSubscription - FeedItem - true - true - - - - - - SearchHistoryEntry - - - id - NSNumber - true - - - query - NSString - true - - - filtersJson - NSString - false - - - sortOption - NSString - true - - - page - NSNumber - true - - - pageSize - NSNumber - true - - - resultCount - NSNumber - true - - - createdAt - NSDate - true - - - diff --git a/native-route/ios/RSSuper/Info.plist b/native-route/ios/RSSuper/Info.plist deleted file mode 100644 index 602cff5..0000000 --- a/native-route/ios/RSSuper/Info.plist +++ /dev/null @@ -1,57 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - AppGroupID - group.com.rssuper.shared - NSLocationWhenInUseUsageDescription - We need your location to provide nearby feed updates. - NSUserNotificationsUsageDescription - We need permission to send you RSSuper notifications for new articles and feed updates. - UIBackgroundModes - - fetch - remote-notification - - UILaunchScreen - - UIColorName - primary - UIImageName - logo - - UIRequiresFullScreen - - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/native-route/ios/RSSuper/Models/SearchFilters.swift b/native-route/ios/RSSuper/Models/SearchFilters.swift deleted file mode 100644 index 318f6c4..0000000 --- a/native-route/ios/RSSuper/Models/SearchFilters.swift +++ /dev/null @@ -1,98 +0,0 @@ -/* - * SearchFilters.swift - * - * Search filter model for iOS search service. - */ - -import Foundation - -/// Search filter configuration -class SearchFilters: Codable { - /// Date range filter - let dateFrom: Date? - - /// Date range filter - let dateTo: Date? - - /// Feed ID filter - let feedIds: [String]? - - /// Author filter - let author: String? - - /// Category filter - let category: String? - - /// Enclosure type filter - let enclosureType: String? - - /// Enclosure length filter - let enclosureLength: Double? - - /// Is read filter - let isRead: Bool? - - /// Is starred filter - let isStarred: Bool? - - /// Initialize search filters - init( - dateFrom: Date? = nil, - dateTo: Date? = nil, - feedIds: [String]? = nil, - author: String? = nil, - category: String? = nil, - enclosureType: String? = nil, - enclosureLength: Double? = nil, - isRead: Bool? = nil, - isStarred: Bool? = nil - ) { - self.dateFrom = dateFrom - self.dateTo = dateTo - self.feedIds = feedIds - self.author = author - self.category = category - self.enclosureType = enclosureType - self.enclosureLength = enclosureLength - self.isRead = isRead - self.isStarred = isStarred - } - - /// Initialize with values - init( - dateFrom: Date?, - dateTo: Date?, - feedIds: [String]?, - author: String?, - category: String?, - enclosureType: String?, - enclosureLength: Double?, - isRead: Bool?, - isStarred: Bool? - ) { - self.dateFrom = dateFrom - self.dateTo = dateTo - self.feedIds = feedIds - self.author = author - self.category = category - self.enclosureType = enclosureType - self.enclosureLength = enclosureLength - self.isRead = isRead - self.isStarred = isStarred - } -} - -/// Search filter to string converter -extension SearchFilters { - func filtersToJSON() -> String { - try? JSONEncoder().encode(self).data(using: .utf8)?.description ?? "" - } - - init?(json: String) { - guard let data = json.data(using: .utf8), - let decoded = try? JSONDecoder().decode(SearchFilters.self, from: data) else { - return nil - } - self = decoded - } -} diff --git a/native-route/ios/RSSuper/Models/SearchQuery.swift b/native-route/ios/RSSuper/Models/SearchQuery.swift deleted file mode 100644 index 78f4003..0000000 --- a/native-route/ios/RSSuper/Models/SearchQuery.swift +++ /dev/null @@ -1,212 +0,0 @@ -/* - * SearchQuery.swift - * - * Search query model for iOS search service. - */ - -import Foundation - -/// Search query parameters -class SearchQuery: Codable { - /// The search query string - let query: String - - /// Current page number (0-indexed) - let page: Int - - /// Items per page - let pageSize: Int - - /// Optional filters - let filters: [SearchFilter]? - - /// Sort option - let sortOrder: SearchSortOption - - /// Timestamp when query was made - let createdAt: Date - - /// Human-readable description - var description: String { - guard !query.isEmpty else { return "Search" } - return query - } - - /// JSON representation - var jsonRepresentation: String { - try? JSONEncoder().encode(self).data(using: .utf8)?.description ?? "" - } - - /// Initialize a search query - init( - query: String, - page: Int = 0, - pageSize: Int = 50, - filters: [SearchFilter]? = nil, - sortOrder: SearchSortOption = .relevance - ) { - self.query = query - self.page = page - self.pageSize = pageSize - self.filters = filters - self.sortOrder = sortOrder - self.createdAt = Date() - } - - /// Initialize with values - init( - query: String, - page: Int, - pageSize: Int, - filters: [SearchFilter]?, - sortOrder: SearchSortOption - ) { - self.query = query - self.page = page - self.pageSize = pageSize - self.filters = filters - self.sortOrder = sortOrder - self.createdAt = Date() - } -} - -/// Search filter options -enum SearchFilter: String, Codable, CaseIterable { - case dateRange - case feedID - case author - case category - case enclosureType - case enclosureLength - case isRead - case isStarred - case publishedDateRange - case title -} - -/// Search sort options -enum SearchSortOption: String, Codable, CaseIterable { - case relevance - case publishedDate - case updatedDate - case title - case feedTitle - case author -} - -/// Search sort option converter -extension SearchSortOption { - static func sortOptionToKey(_ option: SearchSortOption) -> String { - switch option { - case .relevance: return "relevance" - case .publishedDate: return "publishedDate" - case .updatedDate: return "updatedDate" - case .title: return "title" - case .feedTitle: return "feedTitle" - case .author: return "author" - } - } - - static func sortOptionFromKey(_ key: String) -> SearchSortOption { - switch key { - case "relevance": return .relevance - case "publishedDate": return .publishedDate - case "updatedDate": return .updatedDate - case "title": return .title - case "feedTitle": return .feedTitle - case "author": return .author - default: return .relevance - } - } -} - -/// Search filter configuration -class SearchFilters: Codable { - /// Date range filter - let dateFrom: Date? - - /// Date range filter - let dateTo: Date? - - /// Feed ID filter - let feedIds: [String]? - - /// Author filter - let author: String? - - /// Category filter - let category: String? - - /// Enclosure type filter - let enclosureType: String? - - /// Enclosure length filter - let enclosureLength: Double? - - /// Is read filter - let isRead: Bool? - - /// Is starred filter - let isStarred: Bool? - - /// Initialize search filters - init( - dateFrom: Date? = nil, - dateTo: Date? = nil, - feedIds: [String]? = nil, - author: String? = nil, - category: String? = nil, - enclosureType: String? = nil, - enclosureLength: Double? = nil, - isRead: Bool? = nil, - isStarred: Bool? = nil - ) { - self.dateFrom = dateFrom - self.dateTo = dateTo - self.feedIds = feedIds - self.author = author - self.category = category - self.enclosureType = enclosureType - self.enclosureLength = enclosureLength - self.isRead = isRead - self.isStarred = isStarred - } - - /// Initialize with values - init( - dateFrom: Date?, - dateTo: Date?, - feedIds: [String]?, - author: String?, - category: String?, - enclosureType: String?, - enclosureLength: Double?, - isRead: Bool?, - isStarred: Bool? - ) { - self.dateFrom = dateFrom - self.dateTo = dateTo - self.feedIds = feedIds - self.author = author - self.category = category - self.enclosureType = enclosureType - self.enclosureLength = enclosureLength - self.isRead = isRead - self.isStarred = isStarred - } -} - -/// Search filter to string converter -extension SearchFilters { - func filtersToJSON() -> String { - try? JSONEncoder().encode(self).data(using: .utf8)?.description ?? "" - } - - init?(json: String) { - guard let data = json.data(using: .utf8), - let decoded = try? JSONDecoder().decode(SearchFilters.self, from: data) else { - return nil - } - self = decoded - } -} diff --git a/native-route/ios/RSSuper/Models/SearchResult.swift b/native-route/ios/RSSuper/Models/SearchResult.swift deleted file mode 100644 index d2d20f1..0000000 --- a/native-route/ios/RSSuper/Models/SearchResult.swift +++ /dev/null @@ -1,331 +0,0 @@ -/* - * SearchResult.swift - * - * Search result model for iOS search service. - */ - -import Foundation - -/// Search result type -enum SearchResultType: String, Codable, CaseIterable { - case article - case feed - case notification - case bookmark -} - -/// Search result highlight configuration -struct SearchResultHighlight: Codable { - /// The original text - let original: String - - /// Highlighted text - let highlighted: String - - /// Indices of highlighted ranges - let ranges: [(start: Int, end: Int)] - - /// Matched terms - let matchedTerms: [String] - - private enum CodingKeys: String, CodingKey { - case original, highlighted, ranges, matchedTerms - } -} - -/// Search result item -class SearchResult: Codable, Equatable { - /// Unique identifier - var id: String? - - /// Type of search result - var type: SearchResultType - - /// Main title - var title: String? - - /// Description - var description: String? - - /// Full content - var content: String? - - /// Link URL - var link: String? - - /// Feed title (for feed results) - var feedTitle: String? - - /// Published date - var published: String? - - /// Updated date - var updated: String? - - /// Author - var author: String? - - /// Categories - var categories: [String]? - - /// Enclosure URL - var enclosureUrl: String? - - /// Enclosure type - var enclosureType: String? - - /// Enclosure length - var enclosureLength: Double? - - /// Search relevance score (0.0 to 1.0) - var score: Double = 0.0 - - /// Highlighted text - var highlightedText: String? { - guard let content = content else { return nil } - return highlightText(content, query: nil) // Highlight all text - } - - /// Initialize with values - init( - id: String?, - type: SearchResultType, - title: String?, - description: String?, - content: String?, - link: String?, - feedTitle: String?, - published: String?, - updated: String? = nil, - author: String? = nil, - categories: [String]? = nil, - enclosureUrl: String? = nil, - enclosureType: String? = nil, - enclosureLength: Double? = nil, - score: Double = 0.0, - highlightedText: String? = nil - ) { - self.id = id - self.type = type - self.title = title - self.description = description - self.content = content - self.link = link - self.feedTitle = feedTitle - self.published = published - self.updated = updated - self.author = author - self.categories = categories - self.enclosureUrl = enclosureUrl - self.enclosureType = enclosureType - self.enclosureLength = enclosureLength - self.score = score - self.highlightedText = highlightedText - } - - /// Initialize with values (without highlightedText) - init( - id: String?, - type: SearchResultType, - title: String?, - description: String?, - content: String?, - link: String?, - feedTitle: String?, - published: String?, - updated: String? = nil, - author: String? = nil, - categories: [String]? = nil, - enclosureUrl: String? = nil, - enclosureType: String? = nil, - enclosureLength: Double? = nil, - score: Double = 0.0 - ) { - self.id = id - self.type = type - self.title = title - self.description = description - self.content = content - self.link = link - self.feedTitle = feedTitle - self.published = published - self.updated = updated - self.author = author - self.categories = categories - self.enclosureUrl = enclosureUrl - self.enclosureType = enclosureType - self.enclosureLength = enclosureLength - self.score = score - } - - /// Initialize with values (for Core Data) - init( - id: String?, - type: SearchResultType, - title: String?, - description: String?, - content: String?, - link: String?, - feedTitle: String?, - published: String?, - updated: String? = nil, - author: String? = nil, - categories: [String]? = nil, - enclosureUrl: String? = nil, - enclosureType: String? = nil, - enclosureLength: Double? = nil, - score: Double = 0.0 - ) { - self.id = id - self.type = type - self.title = title - self.description = description - self.content = content - self.link = link - self.feedTitle = feedTitle - self.published = published - self.updated = updated - self.author = author - self.categories = categories - self.enclosureUrl = enclosureUrl - self.enclosureType = enclosureType - self.enclosureLength = enclosureLength - self.score = score - } - - /// Initialize with values (for GRDB) - init( - id: String?, - type: SearchResultType, - title: String?, - description: String?, - content: String?, - link: String?, - feedTitle: String?, - published: String?, - updated: String? = nil, - author: String? = nil, - categories: [String]? = nil, - enclosureUrl: String? = nil, - enclosureType: String? = nil, - enclosureLength: Double? = nil, - score: Double = 0.0 - ) { - self.id = id - self.type = type - self.title = title - self.description = description - self.content = content - self.link = link - self.feedTitle = feedTitle - self.published = published - self.updated = updated - self.author = author - self.categories = categories - self.enclosureUrl = enclosureUrl - self.enclosureType = enclosureType - self.enclosureLength = enclosureLength - self.score = score - } - - /// Highlight text with query - func highlightText(_ text: String, query: String?) -> String? { - var highlighted = text - - if let query = query, !query.isEmpty { - let queryWords = query.components(separatedBy: .whitespaces) - - for word in queryWords { - let word = word.trimmingCharacters(in: .whitespaces) - guard !word.isEmpty else { continue } - - let lowerWord = word.lowercased() - let regex = try? NSRegularExpression(pattern: String(regexEscape(word)), options: [.caseInsensitive]) - - if let regex = regex { - let ranges = regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) - - for match in ranges { - if let range = Range(match.range, in: text) { - // Replace with HTML span - highlighted = highlightText(replacing: text[range], with: "\u{00AB}\(text[range])\u{00BB}") ?? highlighted - } - } - } - } - } - - return highlighted - } - - /// Highlight text with ranges - func highlightText(text: String, ranges: [(start: Int, end: Int)]) -> String? { - var result = text - - // Sort ranges by start position (descending) to process from end - let sortedRanges = ranges.sorted { $0.start > $1.start } - - for range in sortedRanges { - if let range = Range(range, in: text) { - result = result.replacingCharacters(in: range, with: "\u{00AB}\(text[range])\u{00BB}") ?? result - } - } - - return result - } - - /// Initialize with values (simple version) - init( - id: String?, - type: SearchResultType, - title: String?, - description: String?, - content: String?, - link: String?, - feedTitle: String?, - published: String?, - updated: String? = nil, - author: String? = nil, - categories: [String]? = nil, - enclosureUrl: String? = nil, - enclosureType: String? = nil, - enclosureLength: Double? = nil, - score: Double = 0.0, - published: Date? = nil - ) { - self.id = id - self.type = type - self.title = title - self.description = description - self.content = content - self.link = link - self.feedTitle = feedTitle - self.published = published.map { $0.iso8601 } - self.updated = updated.map { $0.iso8601 } - self.author = author - self.categories = categories - self.enclosureUrl = enclosureUrl - self.enclosureType = enclosureType - self.enclosureLength = enclosureLength - self.score = score - } -} - -/// Equality check -func == (lhs: SearchResult, rhs: SearchResult) -> Bool { - lhs.id == rhs.id && - lhs.type == rhs.type && - lhs.title == rhs.title && - lhs.description == rhs.description && - lhs.content == rhs.content && - lhs.link == rhs.link && - lhs.feedTitle == rhs.feedTitle && - lhs.published == rhs.published && - lhs.updated == rhs.updated && - lhs.author == rhs.author && - lhs.categories == rhs.categories && - lhs.enclosureUrl == rhs.enclosureUrl && - lhs.enclosureType == rhs.enclosureType && - lhs.enclosureLength == rhs.enclosureLength && - lhs.score == rhs.score -} diff --git a/native-route/ios/RSSuper/RefreshFeedsAppIntent.swift b/native-route/ios/RSSuper/RefreshFeedsAppIntent.swift deleted file mode 100644 index 5ac85ed..0000000 --- a/native-route/ios/RSSuper/RefreshFeedsAppIntent.swift +++ /dev/null @@ -1,109 +0,0 @@ -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)" - } - } -} diff --git a/native-route/ios/RSSuper/Services/CoreDataDatabase.swift b/native-route/ios/RSSuper/Services/CoreDataDatabase.swift deleted file mode 100644 index bfa7a4c..0000000 --- a/native-route/ios/RSSuper/Services/CoreDataDatabase.swift +++ /dev/null @@ -1,572 +0,0 @@ -/* - * CoreDataDatabase.swift - * - * Core Data database wrapper with FTS support. - */ - -import Foundation -import CoreData - -/// Core Data stack -class CoreDataStack: NSObject { - static let shared = CoreDataStack() - - private let persistentContainer: NSPersistentContainer - - private init() { - persistentContainer = NSPersistentContainer(name: "RSSuper") - persistentContainer.loadPersistentStores { - ($0, _ ) in - return NSPersistentStoreFault() - } - } - - var managedObjectContext: NSManagedObjectContext { - return persistentContainer.viewContext - } - - func saveContext() async throws { - try await managedObjectContext.save() - } - - func performTask(_ task: @escaping (NSManagedObjectContext) async throws -> Void) async throws { - try await task(managedObjectContext) - try await saveContext() - } -} - -/// CoreDataDatabase - Core Data wrapper with FTS support -class CoreDataDatabase: NSObject { - private let stack: CoreDataStack - - /// Create a new core data database - init() { - self.stack = CoreDataStack.shared - super.init() - } - - /// Perform a task on the context - func performTask(_ task: @escaping (NSManagedObjectContext) async throws -> Void) async throws { - try await task(stack.managedObjectContext) - try await stack.saveContext() - } -} - -/// CoreDataFeedItemStore - Feed item store with FTS -class CoreDataFeedItemStore: NSObject { - private let db: CoreDataDatabase - - init(db: CoreDataDatabase) { - self.db = db - super.init() - } - - /// Insert a feed item - func insertFeedItem(_ item: FeedItem) async throws { - try await db.performTask { context in - let managedObject = FeedItem(context: context) - managedObject.id = item.id - managedObject.subscriptionId = item.subscriptionId - managedObject.title = item.title - managedObject.link = item.link - managedObject.description = item.description - managedObject.content = item.content - managedObject.author = item.author - managedObject.published = item.published.map { $0.iso8601 } - managedObject.updated = item.updated.map { $0.iso8601 } - managedObject.categories = item.categories?.joined(separator: ",") - managedObject.enclosureUrl = item.enclosureUrl - managedObject.enclosureType = item.enclosureType - managedObject.enclosureLength = item.enclosureLength - managedObject.guid = item.guid - managedObject.isRead = item.isRead - managedObject.isStarred = item.isStarred - - // Update FTS index - try await updateFTS(context: context, feedItemId: item.id, title: item.title, link: item.link, description: item.description, content: item.content) - } - } - - /// Insert multiple feed items - func insertFeedItems(_ items: [FeedItem]) async throws { - try await db.performTask { context in - for item in items { - let managedObject = FeedItem(context: context) - managedObject.id = item.id - managedObject.subscriptionId = item.subscriptionId - managedObject.title = item.title - managedObject.link = item.link - managedObject.description = item.description - managedObject.content = item.content - managedObject.author = item.author - managedObject.published = item.published.map { $0.iso8601 } - managedObject.updated = item.updated.map { $0.iso8601 } - managedObject.categories = item.categories?.joined(separator: ",") - managedObject.enclosureUrl = item.enclosureUrl - managedObject.enclosureType = item.enclosureType - managedObject.enclosureLength = item.enclosureLength - managedObject.guid = item.guid - managedObject.isRead = item.isRead - managedObject.isStarred = item.isStarred - - // Update FTS index - try await updateFTS(context: context, feedItemId: item.id, title: item.title, link: item.link, description: item.description, content: item.content) - } - } - } - - /// Get feed items by subscription ID - func getFeedItems(_ subscriptionId: String?) async throws -> [FeedItem] { - let results: [FeedItem] = try await db.performTask { context in - var items: [FeedItem] = [] - - let predicate = NSPredicate(format: "subscriptionId == %@", subscriptionId ?? "") - let fetchRequest = NSFetchRequest(entityName: "FeedItem") - fetchRequest.predicate = predicate - fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)] - fetchRequest.limit = 1000 - - do { - let managedObjects = try context.fetch(fetchRequest) - for managedObject in managedObjects { - items.append(managedObjectToItem(managedObject)) - } - } catch { - print("Failed to fetch feed items: \(error.localizedDescription)") - } - - return items - } - - return results - } - - /// Get feed item by ID - func getFeedItemById(_ id: String) async throws -> FeedItem? { - let result: FeedItem? = try await db.performTask { context in - let fetchRequest = NSFetchRequest(entityName: "FeedItem") - fetchRequest.predicate = NSPredicate(format: "id == %@", id) - - do { - let managedObjects = try context.fetch(fetchRequest) - return managedObjects.first.map { managedObjectToItem($0) } - } catch { - print("Failed to fetch feed item: \(error.localizedDescription)") - return nil - } - } - - return result - } - - /// Delete feed item by ID - func deleteFeedItem(_ id: String) async throws { - try await db.performTask { context in - let fetchRequest = NSFetchRequest(entityName: "FeedItem") - fetchRequest.predicate = NSPredicate(format: "id == %@", id) - - do { - let managedObjects = try context.fetch(fetchRequest) - for managedObject in managedObjects { - context.delete(managedObject) - } - } catch { - print("Failed to delete feed item: \(error.localizedDescription)") - } - } - } - - /// Delete feed items by subscription ID - func deleteFeedItems(_ subscriptionId: String) async throws { - try await db.performTask { context in - let fetchRequest = NSFetchRequest(entityName: "FeedItem") - fetchRequest.predicate = NSPredicate(format: "subscriptionId == %@", subscriptionId) - - do { - let managedObjects = try context.fetch(fetchRequest) - for managedObject in managedObjects { - context.delete(managedObject) - } - } catch { - print("Failed to delete feed items: \(error.localizedDescription)") - } - } - } - - /// Clean up old feed items - func cleanupOldItems(keepCount: Int = 100) async throws { - try await db.performTask { context in - let fetchRequest = NSFetchRequest(entityName: "FeedItem") - fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)] - fetchRequest.limit = keepCount - - do { - let managedObjects = try context.fetch(fetchRequest) - for managedObject in managedObjects { - context.delete(managedObject) - } - } catch { - print("Failed to cleanup old feed items: \(error.localizedDescription)") - } - } - } - - /// Update FTS index for a feed item - private func updateFTS(context: NSManagedObjectContext, feedItemId: String, title: String?, link: String?, description: String?, content: String?) async throws { - try await db.performTask { context in - let feedItem = FeedItem(context: context) - - // Update text attributes for FTS - feedItem.title = title - feedItem.link = link - feedItem.description = description - feedItem.content = content - - // Trigger FTS update - do { - try context.performSyncBlock() - } catch { - print("FTS update failed: \(error.localizedDescription)") - } - } - } -} - -/// CoreDataSearchHistoryStore - Search history store -class CoreDataSearchHistoryStore: NSObject { - private let db: CoreDataDatabase - - init(db: CoreDataDatabase) { - self.db = db - super.init() - } - - /// Record a search query - func recordSearchHistory(query: SearchQuery, resultCount: Int) async throws -> Int { - try await db.performTask { context in - let historyEntry = SearchHistoryEntry(context: context) - historyEntry.query = query - historyEntry.resultCount = resultCount - historyEntry.createdAt = Date() - - // Save and trigger FTS update - try context.save() - try context.performSyncBlock() - - return resultCount - } - } - - /// Get search history - func getSearchHistory(limit: Int = 50) async throws -> [SearchQuery] { - let results: [SearchQuery] = try await db.performTask { context in - var queries: [SearchQuery] = [] - - let fetchRequest = NSFetchRequest(entityName: "SearchHistoryEntry") - fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)] - fetchRequest.limit = UInt32(limit) - - do { - let managedObjects = try context.fetch(fetchRequest) - for managedObject in managedObjects { - queries.append(managedObjectToQuery(managedObject)) - } - } catch { - print("Failed to fetch search history: \(error.localizedDescription)") - } - - return queries - } - - return results - } - - /// Get recent searches (last 24 hours) - func getRecentSearches(limit: Int = 20) async throws -> [SearchQuery] { - let results: [SearchQuery] = try await db.performTask { context in - var queries: [SearchQuery] = [] - - let now = Date() - let yesterday = Calendar.current.startOfDay(in: now) - let threshold = yesterday.timeIntervalSince1970 - - let fetchRequest = NSFetchRequest(entityName: "SearchHistoryEntry") - fetchRequest.predicate = NSPredicate(format: "createdAt >= %f", threshold) - fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)] - fetchRequest.limit = UInt32(limit) - - do { - let managedObjects = try context.fetch(fetchRequest) - for managedObject in managedObjects { - queries.append(managedObjectToQuery(managedObject)) - } - } catch { - print("Failed to fetch recent searches: \(error.localizedDescription)") - } - - return queries - } - - return results - } - - /// Delete a search history entry by ID - func deleteSearchHistoryEntry(id: Int) async throws { - try await db.performTask { context in - let fetchRequest = NSFetchRequest(entityName: "SearchHistoryEntry") - fetchRequest.predicate = NSPredicate(format: "id == %d", id) - - do { - let managedObjects = try context.fetch(fetchRequest) - for managedObject in managedObjects { - context.delete(managedObject) - } - } catch { - print("Failed to delete search history entry: \(error.localizedDescription)") - } - } - } - - /// Clear all search history - func clearSearchHistory() async throws { - try await db.performTask { context in - let fetchRequest = NSFetchRequest(entityName: "SearchHistoryEntry") - - do { - let managedObjects = try context.fetch(fetchRequest) - for managedObject in managedObjects { - context.delete(managedObject) - } - } catch { - print("Failed to clear search history: \(error.localizedDescription)") - } - } - } - - /// Clean up old search history entries - func cleanupOldSearchHistory(limit: Int = 100) async throws { - try await db.performTask { context in - let fetchRequest = NSFetchRequest(entityName: "SearchHistoryEntry") - fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)] - fetchRequest.limit = UInt32(limit) - - do { - let managedObjects = try context.fetch(fetchRequest) - for managedObject in managedObjects { - context.delete(managedObject) - } - } catch { - print("Failed to cleanup old search history: \(error.localizedDescription)") - } - } - } -} - -/// CoreDataFullTextSearch - FTS5 search implementation -class CoreDataFullTextSearch: NSObject { - private let db: CoreDataDatabase - - init(db: CoreDataDatabase) { - self.db = db - super.init() - } - - /// Search using FTS5 - func search(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] { - let fullTextSearch = CoreDataFullTextSearch(db: db) - - // Perform FTS search - var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit) - - // Rank results by relevance - results = try fullTextSearch.rankResults(query: query, results: results) - - return results - } - - /// Search using FTS5 with custom limit - func searchFTS(query: String, filters: SearchFilters? = nil, limit: Int) async throws -> [SearchResult] { - let fullTextSearch = CoreDataFullTextSearch(db: db) - - // Perform FTS search - var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit) - - // Rank results by relevance - results = try fullTextSearch.rankResults(query: query, results: results) - - return results - } - - /// Search with fuzzy matching - func searchFuzzy(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] { - let fullTextSearch = CoreDataFullTextSearch(db: db) - - // For FTS5, we can use the boolean mode with fuzzy operators - // FTS5 supports prefix matching and phrase queries - - // Convert query to FTS5 boolean format - let ftsQuery = fullTextSearch.buildFTSQuery(query) - - // Perform FTS search - var results = try await fullTextSearch.searchFTS(query: ftsQuery, filters: filters, limit: limit) - - // Rank results by relevance - results = try fullTextSearch.rankResults(query: query, results: results) - - return results - } - - /// Search with highlighting - func searchWithHighlight(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] { - let fullTextSearch = CoreDataFullTextSearch(db: db) - - // Perform FTS search - var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit) - - // Rank results by relevance - results = try fullTextSearch.rankResults(query: query, results: results) - - // Apply highlighting - results.forEach { result in - result.highlightedText = fullTextSearch.highlightText(result.content ?? "", query: query) - } - - return results - } - - /// Build FTS5 query from user input - /// Supports fuzzy matching with prefix operators - func buildFTSQuery(_ query: String) -> String { - var sb = StringBuilder() - let words = query.components(separatedBy: .whitespaces) - - for (index, word) in words.enumerated() { - let word = word.trimmingCharacters(in: .whitespaces) - if word.isEmpty { continue } - - if index > 0 { sb.append(" AND ") } - - // Use * for prefix matching in FTS5 - sb.append("\"") - sb.append(word) - sb.append("*") - sb.append("\"") - } - - return sb.str - } - - /// Highlight text with query - func highlightText(_ text: String, query: String) -> String? { - var highlighted = text - - if !query.isEmpty { - let queryWords = query.components(separatedBy: .whitespaces) - - for word in queryWords { - let word = word.trimmingCharacters(in: .whitespaces) - guard !word.isEmpty else { continue } - - let lowerWord = word.lowercased() - let regex = try? NSRegularExpression(pattern: String(regexEscape(word)), options: [.caseInsensitive]) - - if let regex = regex { - let ranges = regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) - - for match in ranges { - if let range = Range(match.range, in: text) { - highlighted = highlightText(replacing: text[range], with: "\u{00AB}\(text[range])\u{00BB}") ?? highlighted - } - } - } - } - } - - return highlighted - } - - /// Highlight text with ranges - func highlightText(text: String, ranges: [(start: Int, end: Int)]) -> String? { - var result = text - - // Sort ranges by start position (descending) to process from end - let sortedRanges = ranges.sorted { $0.start > $1.start } - - for range in sortedRanges { - if let range = Range(range, in: text) { - result = result.replacingCharacters(in: range, with: "\u{00AB}\(text[range])\u{00BB}") ?? result - } - } - - return result - } - - /// Rank search results by relevance - func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] { - let queryWords = query.components(separatedBy: .whitespaces) - var ranked: [SearchResult?] = results.map { $0 } - - for result in ranked { - guard let result = result else { continue } - var score = result.score - - // Boost score for exact title matches - if let title = result.title { - for word in queryWords { - let word = word.trimmingCharacters(in: .whitespaces) - if !word.isEmpty && title.lowercased().contains(word.lowercased()) { - score += 0.5 - } - } - } - - // Boost score for feed title matches - if let feedTitle = result.feedTitle { - for word in queryWords { - let word = word.trimmingCharacters(in: .whitespaces) - if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) { - score += 0.3 - } - } - } - - result.score = score - ranked.append(result) - } - - // Sort by score (descending) - ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 } - - return ranked.compactMap { $0 } - } -} - -/// CoreDataFeedItemStore extension for FTS search -extend(CoreDataFeedItemStore) { - /// Search using FTS5 - func searchFTS(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] { - let fullTextSearch = CoreDataFullTextSearch(db: db) - - // Perform FTS search - var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit) - - // Rank results by relevance - results = try fullTextSearch.rankResults(query: query, results: results) - - return results - } -} - -/// CoreDataSearchHistoryStore extension -extend(CoreDataSearchHistoryStore) { - /// Record a search query - func recordSearch(_ query: SearchQuery, resultCount: Int = 0) async throws -> Int { - try await recordSearchHistory(query: query, resultCount: resultCount) - searchRecorded?(query, resultCount) - - // Clean up old entries if needed - try await cleanupOldEntries(limit: maxEntries) - - return resultCount - } -} diff --git a/native-route/ios/RSSuper/Services/FeedItemStore.swift b/native-route/ios/RSSuper/Services/FeedItemStore.swift deleted file mode 100644 index f764177..0000000 --- a/native-route/ios/RSSuper/Services/FeedItemStore.swift +++ /dev/null @@ -1,190 +0,0 @@ -/* - * FeedItemStore.swift - * - * CRUD operations for feed items with FTS search support. - */ - -import Foundation -import CoreData - -/// FeedItemStore - Manages feed item persistence with FTS search -class FeedItemStore: NSObject { - private let db: CoreDataDatabase - - /// Signal emitted when an item is added - var itemAdded: ((FeedItem) -> Void)? - - /// Signal emitted when an item is updated - var itemUpdated: ((FeedItem) -> Void)? - - /// Signal emitted when an item is deleted - var itemDeleted: ((String) -> Void)? - - /// Create a new feed item store - init(db: CoreDataDatabase) { - self.db = db - super.init() - } - - /// Add a new feed item - func add(_ item: FeedItem) async throws -> FeedItem { - try await db.insertFeedItem(item) - itemAdded?(item) - return item - } - - /// Add multiple items in a batch - func addBatch(_ items: [FeedItem]) async throws { - try await db.insertFeedItems(items) - } - - /// Get an item by ID - func get_BY_ID(_ id: String) async throws -> FeedItem? { - return try await db.getFeedItemById(id) - } - - /// Get items by subscription ID - func get_BY_SUBSCRIPTION(_ subscriptionId: String) async throws -> [FeedItem] { - return try await db.getFeedItems(subscriptionId) - } - - /// Get all items - func get_ALL() async throws -> [FeedItem] { - return try await db.getFeedItems(nil) - } - - /// Search items using FTS - func search(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] { - return try await searchFTS(query: query, filters: filters, limit: limit) - } - - /// Search items using FTS with custom limit - func searchFTS(query: String, filters: SearchFilters? = nil, limit: Int) async throws -> [SearchResult] { - let fullTextSearch = FullTextSearch(db: db) - - // Perform FTS search - var results = try await fullTextSearch.search(query: query, filters: filters, limit: limit) - - // Rank results by relevance - results = try rankResults(query: query, results: results) - - return results - } - - /// Apply search filters to a search result - func applyFilters(_ result: SearchResult, filters: SearchFilters) -> Bool { - // Date filters - if let dateFrom = filters.dateFrom, result.published != nil { - let published = result.published.map { Date(string: $0) } ?? Date.distantPast - if published < dateFrom { - return false - } - } - - if let dateTo = filters.dateTo, result.published != nil { - let published = result.published.map { Date(string: $0) } ?? Date.distantFuture - if published > dateTo { - return false - } - } - - // Feed ID filters - if let feedIds = filters.feedIds, !feedIds.isEmpty { - // For now, we can't filter by feedId without additional lookup - // This would require joining with feed_subscriptions - } - - // Author filters - not directly supported in current schema - // Would require adding author to FTS index - - // Content type filters - not directly supported - // Would require adding enclosure_type to FTS index - - return true - } - - /// Rank search results by relevance - func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] { - let queryWords = query.components(separatedBy: .whitespaces) - var ranked: [SearchResult?] = results.map { $0 } - - for result in ranked { - guard let result = result else { continue } - var score = result.score - - // Boost score for exact title matches - if let title = result.title { - for word in queryWords { - let word = word.trimmingCharacters(in: .whitespaces) - if !word.isEmpty && title.lowercased().contains(word.lowercased()) { - score += 0.5 - } - } - } - - // Boost score for feed title matches - if let feedTitle = result.feedTitle { - for word in queryWords { - let word = word.trimmingCharacters(in: .whitespaces) - if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) { - score += 0.3 - } - } - } - - result.score = score - ranked.append(result) - } - - // Sort by score (descending) - ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 } - - return ranked.compactMap { $0 } - } - - /// Mark an item as read - func markAsRead(_ id: String) async throws { - try await db.markFeedItemAsRead(id) - } - - /// Mark an item as unread - func markAsUnread(_ id: String) async throws { - try await db.markFeedItemAsUnread(id) - } - - /// Mark an item as starred - func markAsStarred(_ id: String) async throws { - try await db.markFeedItemAsStarred(id) - } - - /// Unmark an item from starred - func unmarkStarred(_ id: String) async throws { - try await db.unmarkFeedItemAsStarred(id) - } - - /// Get unread items - func get_UNREAD() async throws -> [FeedItem] { - return try await db.getFeedItems(nil).filter { $0.isRead == false } - } - - /// Get starred items - func get_STARRED() async throws -> [FeedItem] { - return try await db.getFeedItems(nil).filter { $0.isStarred == true } - } - - /// Delete an item by ID - func delete(_ id: String) async throws { - try await db.deleteFeedItem(id) - itemDeleted?(id) - } - - /// Delete items by subscription ID - func deleteBySubscription(_ subscriptionId: String) async throws { - try await db.deleteFeedItems(subscriptionId) - } - - /// Delete old items (keep last N items per subscription) - func cleanupOldItems(keepCount: Int = 100) async throws { - try await db.cleanupOldItems(keepCount: keepCount) - } -} diff --git a/native-route/ios/RSSuper/Services/FullTextSearch.swift b/native-route/ios/RSSuper/Services/FullTextSearch.swift deleted file mode 100644 index 0a174d1..0000000 --- a/native-route/ios/RSSuper/Services/FullTextSearch.swift +++ /dev/null @@ -1,221 +0,0 @@ -/* - * FullTextSearch.swift - * - * Full-Text Search implementation using Core Data FTS5. - */ - -import Foundation -import CoreData - -/// FullTextSearch - FTS5 search implementation for Core Data -class FullTextSearch: NSObject { - private let db: CoreDataDatabase - - /// Create a new full text search - init(db: CoreDataDatabase) { - self.db = db - super.init() - } - - /// Search using FTS5 - func search(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] { - let fullTextSearch = FullTextSearch(db: db) - - // Perform FTS search - var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit) - - // Rank results by relevance - results = try fullTextSearch.rankResults(query: query, results: results) - - return results - } - - /// Search using FTS5 with custom limit - func searchFTS(query: String, filters: SearchFilters? = nil, limit: Int) async throws -> [SearchResult] { - let fullTextSearch = FullTextSearch(db: db) - - // Perform FTS search - var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit) - - // Rank results by relevance - results = try fullTextSearch.rankResults(query: query, results: results) - - return results - } - - /// Search with fuzzy matching - func searchFuzzy(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] { - let fullTextSearch = FullTextSearch(db: db) - - // For FTS5, we can use the boolean mode with fuzzy operators - // FTS5 supports prefix matching and phrase queries - - // Convert query to FTS5 boolean format - let ftsQuery = fullTextSearch.buildFTSQuery(query) - - // Perform FTS search - var results = try await fullTextSearch.searchFTS(query: ftsQuery, filters: filters, limit: limit) - - // Rank results by relevance - results = try fullTextSearch.rankResults(query: query, results: results) - - return results - } - - /// Build FTS5 query from user input - /// Supports fuzzy matching with prefix operators - func buildFTSQuery(_ query: String) -> String { - var sb = StringBuilder() - let words = query.components(separatedBy: .whitespaces) - - for (index, word) in words.enumerated() { - let word = word.trimmingCharacters(in: .whitespaces) - if word.isEmpty { continue } - - if index > 0 { sb.append(" AND ") } - - // Use * for prefix matching in FTS5 - // This allows matching partial words - sb.append("\"") - sb.append(word) - sb.append("*") - sb.append("\"") - } - - return sb.str - } - - /// Search with highlighting - func searchWithHighlight(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] { - let fullTextSearch = FullTextSearch(db: db) - - // Perform FTS search - var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit) - - // Rank results by relevance - results = try fullTextSearch.rankResults(query: query, results: results) - - // Apply highlighting - results.forEach { result in - result.highlightedText = fullTextSearch.highlightText(result.content ?? "", query: query) - } - - return results - } - - /// Highlight text with query - func highlightText(_ text: String, query: String) -> String? { - var highlighted = text - - if !query.isEmpty { - let queryWords = query.components(separatedBy: .whitespaces) - - for word in queryWords { - let word = word.trimmingCharacters(in: .whitespaces) - guard !word.isEmpty else { continue } - - let lowerWord = word.lowercased() - let regex = try? NSRegularExpression(pattern: String(regexEscape(word)), options: [.caseInsensitive]) - - if let regex = regex { - let ranges = regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) - - for match in ranges { - if let range = Range(match.range, in: text) { - // Replace with HTML span - highlighted = highlightText(replacing: text[range], with: "\u{00AB}\(text[range])\u{00BB}") ?? highlighted - } - } - } - } - } - - return highlighted - } - - /// Highlight text with ranges - func highlightText(text: String, ranges: [(start: Int, end: Int)]) -> String? { - var result = text - - // Sort ranges by start position (descending) to process from end - let sortedRanges = ranges.sorted { $0.start > $1.start } - - for range in sortedRanges { - if let range = Range(range, in: text) { - result = result.replacingCharacters(in: range, with: "\u{00AB}\(text[range])\u{00BB}") ?? result - } - } - - return result - } - - /// Rank search results by relevance - func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] { - let queryWords = query.components(separatedBy: .whitespaces) - var ranked: [SearchResult?] = results.map { $0 } - - for result in ranked { - guard let result = result else { continue } - var score = result.score - - // Boost score for exact title matches - if let title = result.title { - for word in queryWords { - let word = word.trimmingCharacters(in: .whitespaces) - if !word.isEmpty && title.lowercased().contains(word.lowercased()) { - score += 0.5 - } - } - } - - // Boost score for feed title matches - if let feedTitle = result.feedTitle { - for word in queryWords { - let word = word.trimmingCharacters(in: .whitespaces) - if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) { - score += 0.3 - } - } - } - - result.score = score - ranked.append(result) - } - - // Sort by score (descending) - ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 } - - return ranked.compactMap { $0 } - } -} - -/// StringBuilder helper -class StringBuilder { - var str: String = "" - - mutating func append(_ value: String) { - str.append(value) - } - - mutating func append(_ value: Int) { - str.append(String(value)) - } -} - -/// Regex escape helper -func regexEscape(_ string: String) -> String { - return string.replacingOccurrences(of: ".", with: ".") - .replacingOccurrences(of: "+", with: "+") - .replacingOccurrences(of: "?", with: "?") - .replacingOccurrences(of: "*", with: "*") - .replacingOccurrences(of: "^", with: "^") - .replacingOccurrences(of: "$", with: "$") - .replacingOccurrences(of: "(", with: "(") - .replacingOccurrences(of: ")", with: ")") - .replacingOccurrences(of: "[", with: "[") - .replacingOccurrences(of: "]", with: "]") - .replacingOccurrences(of: "{", with: "{") - .replacingOccurrences(of: "}", with: "}") - .replacingOccurrences(of: "|", with: "|") - .replacingOccurrences(of: "\\", with: "\\\\") -} diff --git a/native-route/ios/RSSuper/Services/NotificationManager.swift b/native-route/ios/RSSuper/Services/NotificationManager.swift deleted file mode 100644 index 496dfe3..0000000 --- a/native-route/ios/RSSuper/Services/NotificationManager.swift +++ /dev/null @@ -1,186 +0,0 @@ -import UserNotifications -import Foundation -import Combine -import UIKit - -/// 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() - - 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) { - if let count = Int(label) { - UIApplication.shared.applicationIconBadgeNumber = count - print("Badge updated to \(count)") - } - } - - /// 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 - /// - Parameter completionHandler: Callback with availability status - func isAvailable(_ completionHandler: @escaping (Bool) -> Void) { - notificationService.isAvailable(completionHandler) - } -} - -// MARK: - Notification Center Extensions - -extension Notification.Name { - static let badgeUpdate = Notification.Name("badgeUpdate") -} - diff --git a/native-route/ios/RSSuper/Services/NotificationPreferencesStore.swift b/native-route/ios/RSSuper/Services/NotificationPreferencesStore.swift deleted file mode 100644 index ae35cbe..0000000 --- a/native-route/ios/RSSuper/Services/NotificationPreferencesStore.swift +++ /dev/null @@ -1,185 +0,0 @@ -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() - - private init() { - loadPreferences() - } - - /// Load saved preferences - private func loadPreferences() { - guard let json = defaults.string(forKey: prefsKey) else { - // Set default preferences - preferences = NotificationPreferences() - if let data = try? JSONEncoder().encode(preferences!) { - defaults.set(data, forKey: prefsKey) - } - return - } - - do { - preferences = try JSONDecoder().decode(NotificationPreferences.self, from: json.data(using: .utf8)!) - } 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 -} - diff --git a/native-route/ios/RSSuper/Services/NotificationService.swift b/native-route/ios/RSSuper/Services/NotificationService.swift deleted file mode 100644 index e5637fa..0000000 --- a/native-route/ios/RSSuper/Services/NotificationService.swift +++ /dev/null @@ -1,209 +0,0 @@ -import UserNotifications -import Foundation -import UIKit - -/// 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 } - - unuserNotifications.delegate = self - - requestAuthorization(context: context) - - setDefaultNotificationSettings() - - setNotificationCategories() - - isInitialized = true - print("NotificationService initialized") - } - - /// Request notification authorization - /// - Parameter context: Application context - private func requestAuthorization(context: Any) { - let options: UNAuthorizationOptions = [.alert, .sound, .badge] - - unuserNotifications.requestAuthorization(options: options) { authorized, error in - if let error = error { - print("Notification authorization error: \(error)") - } else if authorized { - print("Notification authorization authorized") - } else { - print("Notification authorization denied") - } - } - } - - /// Set default notification settings - private func setDefaultNotificationSettings() { - unuserNotifications.delegate = self - print("Default notification settings configured") - } - - /// Set notification categories - private func setNotificationCategories() { - print("Notification categories configured via UNNotificationCategory") - } - - /// Show a local notification - /// - Parameters: - /// - title: Notification title - /// - body: Notification body - /// - icon: Icon name (unused on iOS) - /// - 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 notificationContent = UNMutableNotificationContent() - - notificationContent.title = title - notificationContent.body = body - notificationContent.sound = UNNotificationSound.default - notificationContent.categoryIdentifier = urgency.rawValue - - if let contentDate = contentDate { - notificationContent.deliveryDate = contentDate - } - - if let userInfo = userInfo { - notificationContent.userInfo = userInfo - } - - let trigger = contentDate.map { UNCalendarNotificationTrigger(dateMatching: Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: $0), repeats: false) } - - let request = UNNotificationRequest( - identifier: UUID().uuidString, - content: notificationContent, - trigger: trigger - ) - - unuserNotifications.add(request) { error in - if let error = error { - print("Failed to show notification: \(error)") - } else { - print("Notification shown: \(title)") - } - } - } - - /// 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 - /// - Parameter completionHandler: Callback with authorization status - func isAvailable(_ completionHandler: @escaping (Bool) -> Void) { - unuserNotifications.getNotificationSettings { settings in - completionHandler(settings.authorizationStatus == .authorized) - } - } - - /// Get current authorization status - func authorizationStatus() -> UNAuthorizationStatus { - var status: UNAuthorizationStatus = .denied - unuserNotifications.getNotificationSettings { settings in - status = settings.authorizationStatus - } - return status - } - - /// Get the notification center - func notificationCenter() -> UNUserNotificationCenter { - return unuserNotifications - } -} - -extension NotificationService: UNUserNotificationCenterDelegate { - - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - completionHandler([.banner, .sound]) - } - - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - completionHandler() - } -} - -/// 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 - } - } -} - diff --git a/native-route/ios/RSSuper/Services/SearchHistoryStore.swift b/native-route/ios/RSSuper/Services/SearchHistoryStore.swift deleted file mode 100644 index b93df65..0000000 --- a/native-route/ios/RSSuper/Services/SearchHistoryStore.swift +++ /dev/null @@ -1,65 +0,0 @@ -/* - * SearchHistoryStore.swift - * - * CRUD operations for search history. - */ - -import Foundation -import CoreData - -/// SearchHistoryStore - Manages search history persistence -class SearchHistoryStore: NSObject { - private let db: CoreDataDatabase - - /// Maximum number of history entries to keep - var maxEntries: Int = 100 - - /// Signal emitted when a search is recorded - var searchRecorded: ((SearchQuery, Int) -> Void)? - - /// Signal emitted when history is cleared - var historyCleared: (() -> Void)? - - /// Create a new search history store - init(db: CoreDataDatabase) { - self.db = db - super.init() - } - - /// Record a search query - func recordSearch(_ query: SearchQuery, resultCount: Int = 0) async throws -> Int { - try await db.recordSearchHistory(query: query, resultCount: resultCount) - searchRecorded?(query, resultCount) - - // Clean up old entries if needed - try await cleanupOldEntries() - - return resultCount - } - - /// Get search history - func getHistory(limit: Int = 50) async throws -> [SearchQuery] { - return try await db.getSearchHistory(limit: limit) - } - - /// Get recent searches (last 24 hours) - func getRecent(limit: Int = 20) async throws -> [SearchQuery] { - return try await db.getRecentSearches(limit: limit) - } - - /// Delete a search history entry by ID - func deleteHistoryEntry(id: Int) async throws { - try await db.deleteSearchHistoryEntry(id: id) - } - - /// Clear all search history - func clearHistory() async throws { - try await db.clearSearchHistory() - historyCleared?() - } - - /// Clear old search history entries - private func cleanupOldEntries() async throws { - try await db.cleanupOldSearchHistory(limit: maxEntries) - } -} diff --git a/native-route/ios/RSSuper/Services/SearchService.swift b/native-route/ios/RSSuper/Services/SearchService.swift deleted file mode 100644 index a0902ef..0000000 --- a/native-route/ios/RSSuper/Services/SearchService.swift +++ /dev/null @@ -1,252 +0,0 @@ -/* - * SearchService.swift - * - * Full-text search service with history tracking and fuzzy matching. - */ - -import Foundation -import Combine -import CoreData - -/// SearchService - Manages search operations with history tracking -class SearchService: NSObject { - private let db: CoreDataDatabase - private let historyStore: SearchHistoryStore - - /// Maximum number of results to return - var maxResults: Int = 50 - - /// Maximum number of history entries to keep - var maxHistory: Int = 100 - - /// Search results publisher - private let resultsPublisher = CurrentValueSubject(nil) - - /// Search history publisher - private let historyPublisher = CurrentValueSubject(nil) - - /// Signals - var searchPerformed: ((SearchQuery, SearchResult) -> Void)? - var searchRecorded: ((SearchQuery, Int) -> Void)? - var historyCleared: (() -> Void)? - - /// Create a new search service - init(db: CoreDataDatabase) { - self.db = db - self.historyStore = SearchHistoryStore(db: db) - self.historyStore.maxEntries = maxHistory - - // Connect to history store signals - historyStore.searchRecorded { query, count in - self.searchRecorded?(query, count) - self.historyPublisher.send(query) - } - - historyStore.historyCleared { [weak self] in - self?.historyCleared?() - } - } - - /// Perform a search - func search(_ query: String, filters: SearchFilters? = nil) async throws -> [SearchResult] { - let itemStore = FeedItemStore(db: db) - - // Perform FTS search - var results = try await itemStore.searchFTS(query: query, filters: filters, limit: maxResults) - - // Rank results by relevance - results = try rankResults(query: query, results: results) - - // Record in history - let searchQuery = SearchQuery( - query: query, - page: 0, - pageSize: maxResults, - filters: filters, - sortOrder: .relevance - ) - try await historyStore.recordSearch(searchQuery, resultCount: results.count) - - searchPerformed?(searchQuery, results.first!) - resultsPublisher.send(results.first) - - return results - } - - /// Perform a search with custom page size - func searchWithPage(_ query: String, page: Int, pageSize: Int, filters: SearchFilters? = nil) async throws -> [SearchResult] { - let itemStore = FeedItemStore(db: db) - - var results = try await itemStore.searchFTS(query: query, filters: filters, limit: pageSize) - - // Rank results by relevance - results = try rankResults(query: query, results: results) - - // Record in history - let searchQuery = SearchQuery( - query: query, - page: page, - pageSize: pageSize, - filters: filters, - sortOrder: .relevance - ) - try await historyStore.recordSearch(searchQuery, resultCount: results.count) - - searchPerformed?(searchQuery, results.first!) - resultsPublisher.send(results.first) - - return results - } - - /// Get search history - func getHistory(limit: Int = 50) async throws -> [SearchQuery] { - return try await historyStore.getHistory(limit: limit) - } - - /// Get recent searches (last 24 hours) - func getRecent() async throws -> [SearchQuery] { - return try await historyStore.getRecent(limit: 20) - } - - /// Delete a search history entry by ID - func deleteHistoryEntry(id: Int) async throws { - try await historyStore.deleteHistoryEntry(id: id) - } - - /// Clear all search history - func clearHistory() async throws { - try await historyStore.clearHistory() - historyCleared?() - } - - /// Get search suggestions based on recent queries - func getSuggestions(_ prefix: String, limit: Int = 10) async throws -> [String] { - let history = try await historyStore.getHistory(limit: limit * 2) - var suggestions: Set = [] - - for entry in history { - if entry.query.hasPrefix(prefix) && entry.query != prefix { - suggestions.insert(entry.query) - if suggestions.count >= limit { - break - } - } - } - - return Array(suggestions) - } - - /// Get search suggestions from current results - func getResultSuggestions(_ results: [SearchResult], field: String) -> [String] { - var suggestions: Set = [] - var resultList: [String] = [] - - for result in results { - switch field { - case "title": - if let title = result.title, !title.isEmpty { - suggestions.insert(title) - } - case "feed": - if let feedTitle = result.feedTitle, !feedTitle.isEmpty { - suggestions.insert(feedTitle) - } - default: - break - } - } - - var iter = suggestions.iterator() - var key: String? - while (key = iter.nextValue()) { - resultList.append(key!) - } - - return resultList - } - - /// Rank search results by relevance - func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] { - let queryWords = query.components(separatedBy: .whitespaces) - var ranked: [SearchResult?] = results.map { $0 } - - for result in ranked { - guard let result = result else { continue } - var score = result.score - - // Boost score for exact title matches - if let title = result.title { - for word in queryWords { - let word = word.trimmingCharacters(in: .whitespaces) - if !word.isEmpty && title.lowercased().contains(word.lowercased()) { - score += 0.5 - } - } - } - - // Boost score for feed title matches - if let feedTitle = result.feedTitle { - for word in queryWords { - let word = word.trimmingCharacters(in: .whitespaces) - if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) { - score += 0.3 - } - } - } - - result.score = score - ranked.append(result) - } - - // Sort by score (descending) - ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 } - - return ranked.compactMap { $0 } - } - - /// Search suggestions from recent queries - var suggestionsSubject: Published<[String]> { - return Published( - publisher: Publishers.CombineLatest( - Publishers.Everything($0.suggestionsSubject), - Publishers.Everything($0.historyPublisher) - ) { suggestions, history in - var result: [String] = suggestions - for query in history { - result += query.query.components(separatedBy: "\n") - } - return result.sorted() - } - ) - } -} - -/// Search history entry -class SearchHistoryEntry: Codable, Equatable { - let query: SearchQuery - let resultCount: Int - let createdAt: Date - - var description: String { - guard !query.query.isEmpty else { return "Search" } - return query.query - } - - init(query: SearchQuery, resultCount: Int = 0, createdAt: Date = Date()) { - self.query = query - self.resultCount = resultCount - self.createdAt = createdAt - } - - init(query: SearchQuery, resultCount: Int) { - self.query = query - self.resultCount = resultCount - self.createdAt = Date() - } -} - -extension SearchHistoryEntry: Equatable { - static func == (lhs: SearchHistoryEntry, rhs: SearchHistoryEntry) -> Bool { - lhs.query == rhs.query && lhs.resultCount == rhs.resultCount - } -} diff --git a/native-route/ios/RSSuper/Settings/AppSettings.swift b/native-route/ios/RSSuper/Settings/AppSettings.swift deleted file mode 100644 index d6d5dad..0000000 --- a/native-route/ios/RSSuper/Settings/AppSettings.swift +++ /dev/null @@ -1,284 +0,0 @@ -import Foundation - -/// App settings store for iOS RSSuper -/// Provides persistent storage for user settings using UserDefaults -class AppSettings { - - static let shared = AppSettings() - - private let defaults: UserDefaults - private let appGroupDefaults: UserDefaults? - - private let readingPrefix = "reading_" - private let notificationPrefix = "notification_" - - // Reading Preferences keys - private let fontSizeKey = "fontSize" - private let lineHeightKey = "lineHeight" - private let showTableOfContentsKey = "showTableOfContents" - private let showReadingTimeKey = "showReadingTime" - private let showAuthorKey = "showAuthor" - private let showDateKey = "showDate" - - // Notification Preferences keys - private let newArticlesKey = "newArticles" - private let episodeReleasesKey = "episodeReleases" - private let customAlertsKey = "customAlerts" - private let badgeCountKey = "badgeCount" - private let soundKey = "sound" - private let vibrationKey = "vibration" - - private var preferences: AppPreferences? - - private init() { - defaults = UserDefaults.standard - appGroupDefaults = UserDefaults(suiteName: "group.com.rssuper.app") - loadPreferences() - } - - /// Load saved preferences - private func loadPreferences() { - let readingPrefs = ReadingPreferences( - fontSize: getFontSize(), - lineHeight: getLineHeight(), - showTableOfContents: defaults.bool(forKey: readingPrefix + showTableOfContentsKey), - showReadingTime: defaults.bool(forKey: readingPrefix + showReadingTimeKey), - showAuthor: defaults.bool(forKey: readingPrefix + showAuthorKey), - showDate: defaults.bool(forKey: readingPrefix + showDateKey) - ) - - let notificationPrefs = NotificationPreferences( - newArticles: defaults.bool(forKey: notificationPrefix + newArticlesKey), - episodeReleases: defaults.bool(forKey: notificationPrefix + episodeReleasesKey), - customAlerts: defaults.bool(forKey: notificationPrefix + customAlertsKey), - badgeCount: defaults.bool(forKey: notificationPrefix + badgeCountKey), - sound: defaults.bool(forKey: notificationPrefix + soundKey), - vibration: defaults.bool(forKey: notificationPrefix + vibrationKey) - ) - - preferences = AppPreferences(reading: readingPrefs, notification: notificationPrefs) - } - - /// Save preferences - private func savePreferences() { - // Save to UserDefaults - saveReadingPreferences() - saveNotificationPreferences() - - // Sync to App Group if available - syncToAppGroup() - } - - /// Save reading preferences - private func saveReadingPreferences() { - guard let prefs = preferences?.reading else { return } - - defaults.set(prefs.fontSize.rawValue, forKey: readingPrefix + fontSizeKey) - defaults.set(prefs.lineHeight.rawValue, forKey: readingPrefix + lineHeightKey) - defaults.set(prefs.showTableOfContents, forKey: readingPrefix + showTableOfContentsKey) - defaults.set(prefs.showReadingTime, forKey: readingPrefix + showReadingTimeKey) - defaults.set(prefs.showAuthor, forKey: readingPrefix + showAuthorKey) - defaults.set(prefs.showDate, forKey: readingPrefix + showDateKey) - } - - /// Save notification preferences - private func saveNotificationPreferences() { - guard let prefs = preferences?.notification else { return } - - defaults.set(prefs.newArticles, forKey: notificationPrefix + newArticlesKey) - defaults.set(prefs.episodeReleases, forKey: notificationPrefix + episodeReleasesKey) - defaults.set(prefs.customAlerts, forKey: notificationPrefix + customAlertsKey) - defaults.set(prefs.badgeCount, forKey: notificationPrefix + badgeCountKey) - defaults.set(prefs.sound, forKey: notificationPrefix + soundKey) - defaults.set(prefs.vibration, forKey: notificationPrefix + vibrationKey) - } - - /// Sync to App Group - private func syncToAppGroup() { - guard let groupDefaults = appGroupDefaults else { return } - - groupDefaults.set(defaults.string(forKey: readingPrefix + fontSizeKey), forKey: "fontSize") - groupDefaults.set(defaults.string(forKey: readingPrefix + lineHeightKey), forKey: "lineHeight") - groupDefaults.set(defaults.bool(forKey: readingPrefix + showTableOfContentsKey), forKey: "showTableOfContents") - groupDefaults.set(defaults.bool(forKey: readingPrefix + showReadingTimeKey), forKey: "showReadingTime") - groupDefaults.set(defaults.bool(forKey: readingPrefix + showAuthorKey), forKey: "showAuthor") - groupDefaults.set(defaults.bool(forKey: readingPrefix + showDateKey), forKey: "showDate") - - groupDefaults.set(defaults.bool(forKey: notificationPrefix + newArticlesKey), forKey: "newArticles") - groupDefaults.set(defaults.bool(forKey: notificationPrefix + episodeReleasesKey), forKey: "episodeReleases") - groupDefaults.set(defaults.bool(forKey: notificationPrefix + customAlertsKey), forKey: "customAlerts") - groupDefaults.set(defaults.bool(forKey: notificationPrefix + badgeCountKey), forKey: "badgeCount") - groupDefaults.set(defaults.bool(forKey: notificationPrefix + soundKey), forKey: "sound") - groupDefaults.set(defaults.bool(forKey: notificationPrefix + vibrationKey), forKey: "vibration") - } - - // MARK: - Reading Preferences - - func getFontSize() -> ReadingPreferences.FontSize { - let value = defaults.string(forKey: readingPrefix + fontSizeKey) ?? "medium" - return ReadingPreferences.FontSize(rawValue: value) ?? .medium - } - - func setFontSize(_ fontSize: ReadingPreferences.FontSize) { - preferences?.reading.fontSize = fontSize - savePreferences() - } - - func getLineHeight() -> ReadingPreferences.LineHeight { - let value = defaults.string(forKey: readingPrefix + lineHeightKey) ?? "normal" - return ReadingPreferences.LineHeight(rawValue: value) ?? .normal - } - - func setLineHeight(_ lineHeight: ReadingPreferences.LineHeight) { - preferences?.reading.lineHeight = lineHeight - savePreferences() - } - - func isShowTableOfContents() -> Bool { - return defaults.bool(forKey: readingPrefix + showTableOfContentsKey) - } - - func setShowTableOfContents(_ show: Bool) { - preferences?.reading.showTableOfContents = show - savePreferences() - } - - func isShowReadingTime() -> Bool { - return defaults.bool(forKey: readingPrefix + showReadingTimeKey) - } - - func setShowReadingTime(_ show: Bool) { - preferences?.reading.showReadingTime = show - savePreferences() - } - - func isShowAuthor() -> Bool { - return defaults.bool(forKey: readingPrefix + showAuthorKey) - } - - func setShowAuthor(_ show: Bool) { - preferences?.reading.showAuthor = show - savePreferences() - } - - func isShowDate() -> Bool { - return defaults.bool(forKey: readingPrefix + showDateKey) - } - - func setShowDate(_ show: Bool) { - preferences?.reading.showDate = show - savePreferences() - } - - // MARK: - Notification Preferences - - func isNewArticlesEnabled() -> Bool { - return defaults.bool(forKey: notificationPrefix + newArticlesKey) - } - - func setNewArticles(_ enabled: Bool) { - preferences?.notification.newArticles = enabled - savePreferences() - } - - func isEpisodeReleasesEnabled() -> Bool { - return defaults.bool(forKey: notificationPrefix + episodeReleasesKey) - } - - func setEpisodeReleases(_ enabled: Bool) { - preferences?.notification.episodeReleases = enabled - savePreferences() - } - - func isCustomAlertsEnabled() -> Bool { - return defaults.bool(forKey: notificationPrefix + customAlertsKey) - } - - func setCustomAlerts(_ enabled: Bool) { - preferences?.notification.customAlerts = enabled - savePreferences() - } - - func isBadgeCountEnabled() -> Bool { - return defaults.bool(forKey: notificationPrefix + badgeCountKey) - } - - func setBadgeCount(_ enabled: Bool) { - preferences?.notification.badgeCount = enabled - savePreferences() - } - - func isSoundEnabled() -> Bool { - return defaults.bool(forKey: notificationPrefix + soundKey) - } - - func setSound(_ enabled: Bool) { - preferences?.notification.sound = enabled - savePreferences() - } - - func isVibrationEnabled() -> Bool { - return defaults.bool(forKey: notificationPrefix + vibrationKey) - } - - func setVibration(_ enabled: Bool) { - preferences?.notification.vibration = enabled - savePreferences() - } - - // MARK: - App Group - - func isAppGroupAvailable() -> Bool { - return appGroupDefaults != nil - } - - func syncFromAppGroup() { - guard let groupDefaults = appGroupDefaults else { return } - - if let fontSize = groupDefaults.string(forKey: "fontSize") { - defaults.set(fontSize, forKey: readingPrefix + fontSizeKey) - } - if let lineHeight = groupDefaults.string(forKey: "lineHeight") { - defaults.set(lineHeight, forKey: readingPrefix + lineHeightKey) - } - defaults.set(groupDefaults.bool(forKey: "showTableOfContents"), forKey: readingPrefix + showTableOfContentsKey) - defaults.set(groupDefaults.bool(forKey: "showReadingTime"), forKey: readingPrefix + showReadingTimeKey) - defaults.set(groupDefaults.bool(forKey: "showAuthor"), forKey: readingPrefix + showAuthorKey) - defaults.set(groupDefaults.bool(forKey: "showDate"), forKey: readingPrefix + showDateKey) - - defaults.set(groupDefaults.bool(forKey: "newArticles"), forKey: notificationPrefix + newArticlesKey) - defaults.set(groupDefaults.bool(forKey: "episodeReleases"), forKey: notificationPrefix + episodeReleasesKey) - defaults.set(groupDefaults.bool(forKey: "customAlerts"), forKey: notificationPrefix + customAlertsKey) - defaults.set(groupDefaults.bool(forKey: "badgeCount"), forKey: notificationPrefix + badgeCountKey) - defaults.set(groupDefaults.bool(forKey: "sound"), forKey: notificationPrefix + soundKey) - defaults.set(groupDefaults.bool(forKey: "vibration"), forKey: notificationPrefix + vibrationKey) - - loadPreferences() - } - - // MARK: - Getters - - func getReadingPreferences() -> ReadingPreferences { - return preferences?.reading ?? ReadingPreferences() - } - - func getNotificationPreferences() -> NotificationPreferences { - return preferences?.notification ?? NotificationPreferences() - } - - func getAllPreferences() -> AppPreferences { - return preferences ?? AppPreferences() - } -} - -/// App preferences container -@objcMembers -class AppPreferences: NSObject, Codable { - var reading: ReadingPreferences - var notification: NotificationPreferences - - init(reading: ReadingPreferences = ReadingPreferences(), notification: NotificationPreferences = NotificationPreferences()) { - self.reading = reading - self.notification = notification - } -} diff --git a/native-route/ios/RSSuper/Settings/NotificationPreferences.swift b/native-route/ios/RSSuper/Settings/NotificationPreferences.swift deleted file mode 100644 index 1e7d231..0000000 --- a/native-route/ios/RSSuper/Settings/NotificationPreferences.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -/// Notification preferences data structure -@objcMembers -class NotificationPreferences: NSObject, Codable { - var newArticles: Bool - var episodeReleases: Bool - var customAlerts: Bool - var badgeCount: Bool - var sound: Bool - var vibration: Bool - - init( - newArticles: Bool = true, - episodeReleases: Bool = true, - customAlerts: Bool = true, - badgeCount: Bool = true, - sound: Bool = true, - vibration: Bool = true - ) { - self.newArticles = newArticles - self.episodeReleases = episodeReleases - self.customAlerts = customAlerts - self.badgeCount = badgeCount - self.sound = sound - self.vibration = vibration - } -} diff --git a/native-route/ios/RSSuper/Settings/ReadingPreferences.swift b/native-route/ios/RSSuper/Settings/ReadingPreferences.swift deleted file mode 100644 index bd3ac5e..0000000 --- a/native-route/ios/RSSuper/Settings/ReadingPreferences.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -/// Reading preferences data structure -@objcMembers -class ReadingPreferences: NSObject, Codable { - var fontSize: FontSize - var lineHeight: LineHeight - var showTableOfContents: Bool - var showReadingTime: Bool - var showAuthor: Bool - var showDate: Bool - - init( - fontSize: FontSize = .medium, - lineHeight: LineHeight = .normal, - showTableOfContents: Bool = false, - showReadingTime: Bool = true, - showAuthor: Bool = true, - showDate: Bool = true - ) { - self.fontSize = fontSize - self.lineHeight = lineHeight - self.showTableOfContents = showTableOfContents - self.showReadingTime = showReadingTime - self.showAuthor = showAuthor - self.showDate = showDate - } - - enum FontSize: String, Codable { - case small = "small" - case medium = "medium" - case large = "large" - case xlarge = "xlarge" - } - - enum LineHeight: String, Codable { - case normal = "normal" - case relaxed = "relaxed" - case loose = "loose" - } -} diff --git a/native-route/ios/RSSuper/Settings/SettingsMigration.swift b/native-route/ios/RSSuper/Settings/SettingsMigration.swift deleted file mode 100644 index 1ee9962..0000000 --- a/native-route/ios/RSSuper/Settings/SettingsMigration.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Foundation - -/// Settings migration manager -/// Handles migration of settings between different app versions -class SettingsMigration { - - static let shared = SettingsMigration() - - private let defaults = UserDefaults.standard - private let versionKey = "settings_version" - - private init() {} - - /// Check if migration is needed - func needsMigration() -> Bool { - let currentVersion = 1 - let storedVersion = defaults.integer(forKey: versionKey) - return storedVersion < currentVersion - } - - /// Run migration if needed - func runMigration() { - guard needsMigration() else { return } - - let storedVersion = defaults.integer(forKey: versionKey) - - // Migration 0 -> 1: Convert from old format to new format - if storedVersion == 0 { - migrateFromV0ToV1() - } - - // Update version - defaults.set(1, forKey: versionKey) - } - - /// Migrate from version 0 to version 1 - private func migrateFromV0ToV1() { - // Check for old notification preferences format - if defaults.object(forKey: "notification_prefs") != nil { - // Migrate notification preferences - if let oldPrefs = defaults.string(forKey: "notification_prefs") { - // Parse old format and convert to new format - // This is a placeholder - implement actual migration logic - migrateNotificationPrefs(oldPrefs) - } - } - - // Check for old reading preferences format - if defaults.object(forKey: "reading_prefs") != nil { - // Migrate reading preferences - if let oldPrefs = defaults.string(forKey: "reading_prefs") { - migrateReadingPrefs(oldPrefs) - } - } - } - - /// Migrate notification preferences from old format - private func migrateNotificationPrefs(_ oldPrefs: String) { - // Parse old JSON format - // Convert to new format - // Set new keys - defaults.removeObject(forKey: "notification_prefs") - } - - /// Migrate reading preferences from old format - private func migrateReadingPrefs(_ oldPrefs: String) { - // Parse old JSON format - // Convert to new format - // Set new keys - defaults.removeObject(forKey: "reading_prefs") - } - - /// Get current settings version - func getCurrentVersion() -> Int { - return defaults.integer(forKey: versionKey) - } -} diff --git a/native-route/ios/RSSuper/Settings/SettingsStore.swift b/native-route/ios/RSSuper/Settings/SettingsStore.swift deleted file mode 100644 index 00149c3..0000000 --- a/native-route/ios/RSSuper/Settings/SettingsStore.swift +++ /dev/null @@ -1,141 +0,0 @@ -import Foundation - -/// Settings store for iOS RSSuper -/// Provides a unified interface for accessing and modifying all app settings -class SettingsStore { - - static let shared = SettingsStore() - - private let appSettings: AppSettings - private let migration: SettingsMigration - - private init() { - appSettings = AppSettings.shared - migration = SettingsMigration.shared - migration.runMigration() - } - - // MARK: - Reading Preferences - - func getFontSize() -> ReadingPreferences.FontSize { - return appSettings.getFontSize() - } - - func setFontSize(_ fontSize: ReadingPreferences.FontSize) { - appSettings.setFontSize(fontSize) - } - - func getLineHeight() -> ReadingPreferences.LineHeight { - return appSettings.getLineHeight() - } - - func setLineHeight(_ lineHeight: ReadingPreferences.LineHeight) { - appSettings.setLineHeight(lineHeight) - } - - func isShowTableOfContents() -> Bool { - return appSettings.isShowTableOfContents() - } - - func setShowTableOfContents(_ show: Bool) { - appSettings.setShowTableOfContents(show) - } - - func isShowReadingTime() -> Bool { - return appSettings.isShowReadingTime() - } - - func setShowReadingTime(_ show: Bool) { - appSettings.setShowReadingTime(show) - } - - func isShowAuthor() -> Bool { - return appSettings.isShowAuthor() - } - - func setShowAuthor(_ show: Bool) { - appSettings.setShowAuthor(show) - } - - func isShowDate() -> Bool { - return appSettings.isShowDate() - } - - func setShowDate(_ show: Bool) { - appSettings.setShowDate(show) - } - - // MARK: - Notification Preferences - - func isNewArticlesEnabled() -> Bool { - return appSettings.isNewArticlesEnabled() - } - - func setNewArticles(_ enabled: Bool) { - appSettings.setNewArticles(enabled) - } - - func isEpisodeReleasesEnabled() -> Bool { - return appSettings.isEpisodeReleasesEnabled() - } - - func setEpisodeReleases(_ enabled: Bool) { - appSettings.setEpisodeReleases(enabled) - } - - func isCustomAlertsEnabled() -> Bool { - return appSettings.isCustomAlertsEnabled() - } - - func setCustomAlerts(_ enabled: Bool) { - appSettings.setCustomAlerts(enabled) - } - - func isBadgeCountEnabled() -> Bool { - return appSettings.isBadgeCountEnabled() - } - - func setBadgeCount(_ enabled: Bool) { - appSettings.setBadgeCount(enabled) - } - - func isSoundEnabled() -> Bool { - return appSettings.isSoundEnabled() - } - - func setSound(_ enabled: Bool) { - appSettings.setSound(enabled) - } - - func isVibrationEnabled() -> Bool { - return appSettings.isVibrationEnabled() - } - - func setVibration(_ enabled: Bool) { - appSettings.setVibration(enabled) - } - - // MARK: - App Group - - func isAppGroupAvailable() -> Bool { - return appSettings.isAppGroupAvailable() - } - - func syncFromAppGroup() { - appSettings.syncFromAppGroup() - } - - // MARK: - Getters - - func getReadingPreferences() -> ReadingPreferences { - return appSettings.getReadingPreferences() - } - - func getNotificationPreferences() -> NotificationPreferences { - return appSettings.getNotificationPreferences() - } - - func getAllPreferences() -> AppPreferences { - return appSettings.getAllPreferences() - } -} diff --git a/native-route/ios/RSSuper/SyncScheduler.swift b/native-route/ios/RSSuper/SyncScheduler.swift deleted file mode 100644 index 5b7818b..0000000 --- a/native-route/ios/RSSuper/SyncScheduler.swift +++ /dev/null @@ -1,193 +0,0 @@ -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) - } - } -} diff --git a/native-route/ios/RSSuper/SyncWorker.swift b/native-route/ios/RSSuper/SyncWorker.swift deleted file mode 100644 index 070b27d..0000000 --- a/native-route/ios/RSSuper/SyncWorker.swift +++ /dev/null @@ -1,227 +0,0 @@ -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).self - ) { group in - var results: [(Subscription, Result)] = [] - - 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 { - // 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.. - - rssuper - - - - - - - - - - - - - - - - - - - - - - - - New Article Notifications - true - Enable notifications for new articles - - - - Episode Release Notifications - true - Enable notifications for episode releases - - - - Custom Alert Notifications - true - Enable notifications for custom alerts - - - - Badge Count - true - Show badge count in app header - - - - Sound - true - Play sound on notification - - - - Vibration - true - Vibrate device on notification - - - - All Preferences - { - "newArticles": true, - "episodeReleases": true, - "customAlerts": true, - "badgeCount": true, - "sound": true, - "vibration": true - } - All notification preferences as JSON - - \ No newline at end of file diff --git a/native-route/linux/gsettings/org.rssuper.sync.gschema.xml b/native-route/linux/gsettings/org.rssuper.sync.gschema.xml deleted file mode 100644 index 26635a7..0000000 --- a/native-route/linux/gsettings/org.rssuper.sync.gschema.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - 0 - Last sync timestamp - The Unix timestamp of the last successful sync - - - 21600 - Preferred sync interval in seconds - The preferred interval between sync operations (default: 6 hours) - - - true - Auto-sync enabled - Whether automatic background sync is enabled - - - 0 - Sync on Wi-Fi only - 0=always, 1=Wi-Fi only, 2=never - - - diff --git a/native-route/linux/org.rssuper.sync.desktop b/native-route/linux/org.rssuper.sync.desktop deleted file mode 100644 index 7518446..0000000 --- a/native-route/linux/org.rssuper.sync.desktop +++ /dev/null @@ -1,10 +0,0 @@ -[Desktop Entry] -Name=RSSuper Background Sync -Comment=Background feed synchronization for RSSuper -Exec=/opt/rssuper/bin/rssuper-sync-daemon -Terminal=false -Type=Application -Categories=Utility;Network; -StartupNotify=false -Hidden=false -X-GNOME-Autostart-enabled=true diff --git a/native-route/linux/rssuper-sync.service b/native-route/linux/rssuper-sync.service deleted file mode 100644 index e7f9957..0000000 --- a/native-route/linux/rssuper-sync.service +++ /dev/null @@ -1,23 +0,0 @@ -[Unit] -Description=RSSuper Background Sync Service -Documentation=man:rssuper(1) -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -ExecStart=/opt/rssuper/bin/rssuper-sync -StandardOutput=journal -StandardError=journal - -# Security hardening -NoNewPrivileges=yes -ProtectSystem=strict -ProtectHome=read-only -PrivateTmp=yes - -# Timeout (5 minutes) -TimeoutStartSec=300 - -[Install] -WantedBy=multi-user.target diff --git a/native-route/linux/rssuper-sync.timer b/native-route/linux/rssuper-sync.timer deleted file mode 100644 index 278e68f..0000000 --- a/native-route/linux/rssuper-sync.timer +++ /dev/null @@ -1,23 +0,0 @@ -[Unit] -Description=RSSuper Background Sync Timer -Documentation=man:rssuper(1) - -[Timer] -# On-boot delay (randomized between 1-5 minutes) -OnBootSec=1min -RandomizedDelaySec=4min - -# On-unit-active delay (6 hours after service starts) -OnUnitActiveSec=6h - -# Accuracy (allow ±15 minutes) -AccuracySec=15min - -# Persist timer across reboots -Persistent=true - -# Wake system if sleeping to run timer -WakeSystem=true - -[Install] -WantedBy=timers.target diff --git a/native-route/linux/src/background-sync.vala b/native-route/linux/src/background-sync.vala deleted file mode 100644 index dbd4d1c..0000000 --- a/native-route/linux/src/background-sync.vala +++ /dev/null @@ -1,514 +0,0 @@ -/* - * background-sync.vala - * - * Main background sync service for RSSuper on Linux. - * Orchestrates background feed synchronization using GTimeout and systemd timer. - */ - -using Gio; -using GLib; - -namespace RSSuper { - -/** - * BackgroundSyncService - Main background sync service coordinator - * - * Orchestrates background feed synchronization using: - * - GTimeout for in-app scheduling - * - systemd timer for system-level scheduling - */ -public class BackgroundSyncService : Object { - - // Singleton instance - private static BackgroundSyncService? _instance; - - // Sync scheduler - private SyncScheduler? _sync_scheduler; - - // Sync worker - private SyncWorker? _sync_worker; - - // Current sync state - private bool _is_syncing = false; - - // Sync configuration - public const string BACKGROUND_REFRESH_IDENTIFIER = "org.rssuper.background-refresh"; - public const string PERIODIC_SYNC_IDENTIFIER = "org.rssuper.periodic-sync"; - - // Settings - private Settings? _settings; - - /** - * Get singleton instance - */ - public static BackgroundSyncService? get_instance() { - if (_instance == null) { - _instance = new BackgroundSyncService(); - } - return _instance; - } - - /** - * Get the instance (for singleton pattern) - */ - private BackgroundSyncService() { - _sync_scheduler = SyncScheduler.get_instance(); - _sync_worker = new SyncWorker(); - - try { - _settings = new Settings("org.rssuper.sync"); - } catch (Error e) { - warning("Failed to create settings: %s", e.message); - } - - // Connect to sync due signal - if (_sync_scheduler != null) { - _sync_scheduler.sync_due.connect(on_sync_due); - } - - info("BackgroundSyncService initialized"); - } - - /** - * Initialize the sync service - */ - public void initialize() { - info("Initializing background sync service"); - - // Schedule initial sync - schedule_next_sync(); - - info("Background sync service initialized"); - } - - /** - * Schedule the next sync - */ - public bool schedule_next_sync() { - if (_is_syncing) { - warning("Sync already in progress"); - return false; - } - - if (_sync_scheduler != null) { - return _sync_scheduler.schedule_next_sync(); - } - - return false; - } - - /** - * Cancel all pending sync operations - */ - public void cancel_all_pending() { - if (_sync_scheduler != null) { - _sync_scheduler.cancel_sync_timeout(); - } - info("All pending sync operations cancelled"); - } - - /** - * Force immediate sync (for testing or user-initiated) - */ - public async void force_sync() { - if (_is_syncing) { - warning("Sync already in progress"); - return; - } - - _is_syncing = true; - - try { - var result = yield _sync_worker.perform_sync(); - - // Update last sync timestamp - if (_sync_scheduler != null) { - _sync_scheduler.set_last_sync_timestamp(); - } - - info("Force sync completed: %d feeds, %d articles", - result.feeds_synced, result.articles_fetched); - - // Schedule next sync - schedule_next_sync(); - - } catch (Error e) { - warning("Force sync failed: %s", e.message); - } - - _is_syncing = false; - } - - /** - * Check if background sync is enabled - */ - public bool are_background_tasks_enabled() { - // Check if systemd timer is active - try { - var stdout, stderr; - var exit_status = Process.spawn_command_line_sync( - "systemctl is-enabled rssuper-sync.timer", - out stdout, out stderr - ); - - if (exit_status != 0) { - // Timer might not be installed - return true; - } - - var result = stdout.to_string(); - return result.strip() == "enabled"; - } catch (Error e) { - // Timer might not be installed - return true; - } - } - - /** - * Get last sync date - */ - public DateTime? get_last_sync_date() { - return _sync_scheduler != null ? _sync_scheduler.get_last_sync_date() : null; - } - - /** - * Get pending feeds count - */ - public int get_pending_feeds_count() { - // TODO: Implement - return 0; - } - - /** - * Check if currently syncing - */ - public bool is_syncing() { - return _is_syncing; - } - - /** - * Sync due callback - */ - private void on_sync_due() { - if (_is_syncing) { - warning("Sync already in progress"); - return; - } - - info("Sync due, starting background sync"); - - _is_syncing = true; - - // Run sync in background - GLib.Thread.new(null, () => { - try { - var result = _sync_worker.perform_sync(); - - // Update last sync timestamp - if (_sync_scheduler != null) { - _sync_scheduler.set_last_sync_timestamp(); - } - - info("Background sync completed: %d feeds, %d articles", - result.feeds_synced, result.articles_fetched); - - // Schedule next sync - schedule_next_sync(); - - } catch (Error e) { - warning("Background sync failed: %s", e.message); - } - - _is_syncing = false; - - return null; - }); - } - - /** - * Shutdown the sync service - */ - public void shutdown() { - cancel_all_pending(); - info("Background sync service shut down"); - } -} - -/** - * SyncWorker - Performs the actual sync work - */ -public class SyncWorker : Object { - - // Maximum number of feeds to sync per batch - public const int MAX_FEEDS_PER_BATCH = 20; - - // Timeout for individual feed fetch (in seconds) - public const int FEED_FETCH_TIMEOUT = 30; - - // Maximum concurrent feed fetches - public const int MAX_CONCURRENT_FETCHES = 3; - - /** - * Perform a full sync operation - */ - public SyncResult perform_sync() { - int feeds_synced = 0; - int articles_fetched = 0; - var errors = new List(); - - info("Starting sync"); - - // Get all subscriptions that need syncing - var subscriptions = fetch_subscriptions_needing_sync(); - - info("Syncing %d subscriptions", subscriptions.length()); - - if (subscriptions.length() == 0) { - info("No subscriptions to sync"); - return new SyncResult(feeds_synced, articles_fetched, errors); - } - - // Process subscriptions in batches - var batches = chunk_list(subscriptions, MAX_FEEDS_PER_BATCH); - - foreach (var batch in batches) { - var batch_result = sync_batch(batch); - feeds_synced += batch_result.feeds_synced; - articles_fetched += batch_result.articles_fetched; - errors.append_list(batch_result.errors); - - // Small delay between batches to be battery-friendly - try { - Thread.sleep(500); // 500ms - } catch (Error e) { - warning("Failed to sleep: %s", e.message); - } - } - - info("Sync completed: %d feeds, %d articles, %d errors", - feeds_synced, articles_fetched, errors.length()); - - return new SyncResult(feeds_synced, articles_fetched, errors); - } - - /** - * Perform a partial sync for specific subscriptions - */ - public SyncResult perform_partial_sync(List subscription_ids) { - // TODO: Implement partial sync - return new SyncResult(0, 0, new List()); - } - - /** - * Cancel ongoing sync operations - */ - public void cancel_sync() { - info("Sync cancelled"); - // TODO: Cancel ongoing network requests - } - - /** - * Fetch subscriptions that need syncing - */ - private List fetch_subscriptions_needing_sync() { - // TODO: Replace with actual database query - // This is a placeholder that returns an empty list until the database layer is implemented - // Once database is available, query subscriptions where last_sync_at + interval < now - return new List(); - } - - /** - * Sync a batch of subscriptions - */ - private SyncResult sync_batch(List subscriptions) { - var feeds_synced = 0; - var articles_fetched = 0; - var errors = new List(); - - foreach (var subscription in subscriptions) { - try { - var feed_data = fetch_feed_data(subscription); - - if (feed_data != null) { - process_feed_data(feed_data, subscription.id); - feeds_synced++; - articles_fetched += feed_data.articles.length(); - - info("Synced %s: %d articles", subscription.title, - feed_data.articles.length()); - } - - } catch (Error e) { - errors.append(e); - warning("Error syncing %s: %s", subscription.title, e.message); - } - } - - return new SyncResult(feeds_synced, articles_fetched, errors); - } - - /** - * Fetch feed data for a subscription - */ - private FeedData? fetch_feed_data(Subscription subscription) { - // TODO: Implement actual feed fetching - // This is a placeholder implementation - - // Example implementation: - // var uri = new Uri(subscription.url); - // var client = new HttpClient(); - // var data = client.get(uri); - // var feed_data = rss_parser.parse(data); - // return feed_data; - - return null; - } - - /** - * Process fetched feed data - */ - private void process_feed_data(FeedData feed_data, string subscription_id) { - // TODO: Implement actual feed data processing - // - Store new articles - // - Update feed metadata - // - Handle duplicates - - info("Processing %d articles for %s", feed_data.articles.length(), - feed_data.title); - } - - /** - * Chunk a list into batches - */ - private List> chunk_list(List list, int size) { - var batches = new List>(); - var current_batch = new List(); - - foreach (var item in list) { - current_batch.append(item); - if (current_batch.length() >= size) { - batches.append(current_batch); - current_batch = new List(); - } - } - - if (current_batch.length() > 0) { - batches.append(current_batch); - } - - return batches; - } -} - -/** - * SyncResult - Result of a sync operation - */ -public class SyncResult : Object { - public int feeds_synced { - get { return _feeds_synced; } - } - public int articles_fetched { - get { return _articles_fetched; } - } - public List errors { - get { return _errors; } - } - - private int _feeds_synced; - private int _articles_fetched; - private List _errors; - - public SyncResult(int feeds_synced, int articles_fetched, List errors) { - _feeds_synced = feeds_synced; - _articles_fetched = articles_fetched; - _errors = errors; - } -} - -/** - * Subscription - Model for a feed subscription - */ -public class Subscription : Object { - public string id { - get { return _id; } - } - public string title { - get { return _title; } - } - public string url { - get { return _url; } - } - public uint64 last_sync_date { - get { return _last_sync_date; } - } - - private string _id; - private string _title; - private string _url; - private uint64 _last_sync_date; - - public Subscription(string id, string title, string url, uint64 last_sync_date = 0) { - _id = id; - _title = title; - _url = url; - _last_sync_date = last_sync_date; - } -} - -/** - * FeedData - Parsed feed data - */ -public class FeedData : Object { - public string title { - get { return _title; } - } - public List
articles { - get { return _articles; } - } - - private string _title; - private List
_articles; - - public FeedData(string title, List
articles) { - _title = title; - _articles = articles; - } -} - -/** - * Article - Model for a feed article - */ -public class Article : Object { - public string id { - get { return _id; } - } - public string title { - get { return _title; } - } - public string? link { - get { return _link; } - } - public uint64 published { - get { return _published; } - } - public string? content { - get { return _content; } - } - - private string _id; - private string _title; - private string? _link; - private uint64 _published; - private string? _content; - - public Article(string id, string title, string? link = null, - uint64 published = 0, string? content = null) { - _id = id; - _title = title; - _link = link; - _published = published; - _content = content; - } -} - -} diff --git a/native-route/linux/src/notification-manager.vala b/native-route/linux/src/notification-manager.vala deleted file mode 100644 index 4714cc2..0000000 --- a/native-route/linux/src/notification-manager.vala +++ /dev/null @@ -1,280 +0,0 @@ -/* - * notification-manager.vala - * - * Notification manager for RSSuper on Linux. - * Coordinates notifications, badge management, and tray integration. - */ - -using Gio; -using GLib; -using Gtk; - -namespace RSSuper { - -/** - * NotificationManager - Manager for coordinating notifications - */ -public class NotificationManager : Object { - - // Singleton instance - private static NotificationManager? _instance; - - // Notification service - private NotificationService? _notification_service; - - // Badge reference - private Gtk.Badge? _badge; - - // Tray icon reference - private Gtk.TrayIcon? _tray_icon; - - // App reference - private Gtk.App? _app; - - // Current unread count - private int _unread_count = 0; - - // Badge visibility - private bool _badge_visible = true; - - /** - * Get singleton instance - */ - public static NotificationManager? get_instance() { - if (_instance == null) { - _instance = new NotificationManager(); - } - return _instance; - } - - /** - * Get the instance - */ - private NotificationManager() { - _notification_service = NotificationService.get_instance(); - _app = Gtk.App.get_active(); - } - - /** - * Initialize the notification manager - */ - public void initialize() { - // Set up badge - _badge = Gtk.Badge.new(); - _badge.set_visible(_badge_visible); - _badge.set_halign(Gtk.Align.START); - - // Set up tray icon - _tray_icon = Gtk.TrayIcon.new(); - _tray_icon.set_icon_name("rssuper"); - _tray_icon.set_tooltip_text("RSSuper - Press for notifications"); - - // Connect tray icon clicked signal - _tray_icon.clicked.connect(_on_tray_clicked); - - // Set up tray icon popup handler - _tray_icon.set_popup_handler(_on_tray_popup); - } - - /** - * Set up the badge in the app header - */ - public void set_up_badge() { - _badge.set_visible(_badge_visible); - _badge.set_halign(Gtk.Align.START); - } - - /** - * Set up the tray icon popup menu - */ - public void set_up_tray_icon() { - _tray_icon.set_icon_name("rssuper"); - _tray_icon.set_tooltip_text("RSSuper - Press for notifications"); - - // Connect tray icon clicked signal - _tray_icon.clicked.connect(_on_tray_clicked); - - _tray_icon.set_popup_handler(_on_tray_popup); - - _tray_icon.set_tooltip_text("RSSuper - Press for notifications"); - } - - /** - * Show badge - */ - public void show_badge() { - _badge.set_visible(true); - } - - /** - * Hide badge - */ - public void hide_badge() { - _badge.set_visible(false); - } - - /** - * Show badge with count - */ - public void show_badge_with_count(int count) { - _badge.set_visible(true); - _badge.set_label(count.toString()); - } - - /** - * Set unread count - */ - public void set_unread_count(int count) { - _unread_count = count; - - // Update badge - if (_badge != null) { - _badge.set_label(count.toString()); - } - - // Show badge if count > 0 - if (count > 0) { - show_badge(); - } - } - - /** - * Clear unread count - */ - public void clear_unread_count() { - _unread_count = 0; - hide_badge(); - } - - /** - * Get unread count - */ - public int get_unread_count() { - return _unread_count; - } - - /** - * Get badge reference - */ - public Gtk.Badge? get_badge() { - return _badge; - } - - /** - * Get tray icon reference - */ - public Gtk.TrayIcon? get_tray_icon() { - return _tray_icon; - } - - /** - * Get app reference - */ - public Gtk.App? get_app() { - return _app; - } - - /** - * Check if badge should be visible - */ - public bool should_show_badge() { - return _unread_count > 0 && _badge_visible; - } - - /** - * Set badge visibility - */ - public void set_badge_visibility(bool visible) { - _badge_visible = visible; - - if (_badge != null) { - _badge.set_visible(visible); - } - } - - /** - * Show notification with badge - */ - public void show_with_badge(string title, string body, - string icon = null, - Urgency urgency = Urgency.NORMAL) { - - var notification = _notification_service.create(title, body, icon, urgency); - notification.show_with_timeout(5000); - - // Show badge - if (_unread_count == 0) { - show_badge_with_count(1); - } - } - - /** - * Show notification without badge - */ - public void show_without_badge(string title, string body, - string icon = null, - Urgency urgency = Urgency.NORMAL) { - - var notification = _notification_service.create(title, body, icon, urgency); - notification.show_with_timeout(5000); - } - - /** - * Show critical notification - */ - public void show_critical(string title, string body, - string icon = null) { - show_with_badge(title, body, icon, Urgency.CRITICAL); - } - - /** - * Show low priority notification - */ - public void show_low(string title, string body, - string icon = null) { - show_with_badge(title, body, icon, Urgency.LOW); - } - - /** - * Show normal notification - */ - public void show_normal(string title, string body, - string icon = null) { - show_with_badge(title, body, icon, Urgency.NORMAL); - } - - - - /** - * Handle tray icon clicked signal - */ - private void _on_tray_clicked(Gtk.TrayIcon tray) { - show_notifications_panel(); - } - - - - /** - * Show notifications panel - */ - private void show_notifications_panel() { - // TODO: Show notifications panel - print("Notifications panel requested"); - } - - /** - * Get notification service - */ - public NotificationService? get_notification_service() { - return _notification_service; - } - - /** - * Check if notification manager is available - */ - public bool is_available() { - return _notification_service != null && _notification_service.is_available(); - } -} - -} \ No newline at end of file diff --git a/native-route/linux/src/notification-preferences-store.vala b/native-route/linux/src/notification-preferences-store.vala deleted file mode 100644 index ffecbde..0000000 --- a/native-route/linux/src/notification-preferences-store.vala +++ /dev/null @@ -1,258 +0,0 @@ -/* - * notification-preferences-store.vala - * - * Store for notification preferences. - * Provides persistent storage for user notification settings. - */ - -using GLib; - -namespace RSSuper { - -/** - * NotificationPreferencesStore - Persistent storage for notification preferences - * - * Uses GSettings for persistent storage following freedesktop.org conventions. - */ -public class NotificationPreferencesStore : Object { - - // Singleton instance - private static NotificationPreferencesStore? _instance; - - // GSettings schema key - private const string SCHEMA_KEY = "org.rssuper.notification.preferences"; - - // Preferences schema - private GSettings? _settings; - - // Preferences object - private NotificationPreferences? _preferences; - - /** - * Get singleton instance - */ - public static NotificationPreferencesStore? get_instance() { - if (_instance == null) { - _instance = new NotificationPreferencesStore(); - } - return _instance; - } - - /** - * Get the instance - */ - private NotificationPreferencesStore() { - _settings = GSettings.new(SCHEMA_KEY); - - // Load initial preferences - _preferences = NotificationPreferences.from_json_string(_settings.get_string("preferences")); - - if (_preferences == null) { - // Set default preferences if none exist - _preferences = new NotificationPreferences(); - _settings.set_string("preferences", _preferences.to_json_string()); - } - - // Listen for settings changes - _settings.changed.connect(_on_settings_changed); - } - - /** - * Get notification preferences - */ - public NotificationPreferences? get_preferences() { - return _preferences; - } - - /** - * Set notification preferences - */ - public void set_preferences(NotificationPreferences prefs) { - _preferences = prefs; - - // Save to GSettings - _settings.set_string("preferences", prefs.to_json_string()); - } - - /** - * Get new articles preference - */ - public bool get_new_articles() { - return _preferences != null ? _preferences.new_articles : true; - } - - /** - * Set new articles preference - */ - public void set_new_articles(bool enabled) { - _preferences = _preferences ?? new NotificationPreferences(); - _preferences.new_articles = enabled; - _settings.set_boolean("newArticles", enabled); - } - - /** - * Get episode releases preference - */ - public bool get_episode_releases() { - return _preferences != null ? _preferences.episode_releases : true; - } - - /** - * Set episode releases preference - */ - public void set_episode_releases(bool enabled) { - _preferences = _preferences ?? new NotificationPreferences(); - _preferences.episode_releases = enabled; - _settings.set_boolean("episodeReleases", enabled); - } - - /** - * Get custom alerts preference - */ - public bool get_custom_alerts() { - return _preferences != null ? _preferences.custom_alerts : true; - } - - /** - * Set custom alerts preference - */ - public void set_custom_alerts(bool enabled) { - _preferences = _preferences ?? new NotificationPreferences(); - _preferences.custom_alerts = enabled; - _settings.set_boolean("customAlerts", enabled); - } - - /** - * Get badge count preference - */ - public bool get_badge_count() { - return _preferences != null ? _preferences.badge_count : true; - } - - /** - * Set badge count preference - */ - public void set_badge_count(bool enabled) { - _preferences = _preferences ?? new NotificationPreferences(); - _preferences.badge_count = enabled; - _settings.set_boolean("badgeCount", enabled); - } - - /** - * Get sound preference - */ - public bool get_sound() { - return _preferences != null ? _preferences.sound : true; - } - - /** - * Set sound preference - */ - public void set_sound(bool enabled) { - _preferences = _preferences ?? new NotificationPreferences(); - _preferences.sound = enabled; - _settings.set_boolean("sound", enabled); - } - - /** - * Get vibration preference - */ - public bool get_vibration() { - return _preferences != null ? _preferences.vibration : true; - } - - /** - * Set vibration preference - */ - public void set_vibration(bool enabled) { - _preferences = _preferences ?? new NotificationPreferences(); - _preferences.vibration = enabled; - _settings.set_boolean("vibration", enabled); - } - - /** - * Enable all notifications - */ - public void enable_all() { - _preferences = _preferences ?? new NotificationPreferences(); - _preferences.enable_all(); - - // Save to GSettings - _settings.set_string("preferences", _preferences.to_json_string()); - } - - /** - * Disable all notifications - */ - public void disable_all() { - _preferences = _preferences ?? new NotificationPreferences(); - _preferences.disable_all(); - - // Save to GSettings - _settings.set_string("preferences", _preferences.to_json_string()); - } - - /** - * Get all preferences as dictionary - */ - public Dictionary get_all_preferences() { - if (_preferences == null) { - return new Dictionary(); - } - - var prefs = new Dictionary(); - prefs["new_articles"] = _preferences.new_articles; - prefs["episode_releases"] = _preferences.episode_releases; - prefs["custom_alerts"] = _preferences.custom_alerts; - prefs["badge_count"] = _preferences.badge_count; - prefs["sound"] = _preferences.sound; - prefs["vibration"] = _preferences.vibration; - - return prefs; - } - - /** - * Set all preferences from dictionary - */ - public void set_all_preferences(Dictionary prefs) { - _preferences = new NotificationPreferences(); - - if (prefs.containsKey("new_articles")) { - _preferences.new_articles = prefs["new_articles"] as bool; - } - if (prefs.containsKey("episode_releases")) { - _preferences.episode_releases = prefs["episode_releases"] as bool; - } - if (prefs.containsKey("custom_alerts")) { - _preferences.custom_alerts = prefs["custom_alerts"] as bool; - } - if (prefs.containsKey("badge_count")) { - _preferences.badge_count = prefs["badge_count"] as bool; - } - if (prefs.containsKey("sound")) { - _preferences.sound = prefs["sound"] as bool; - } - if (prefs.containsKey("vibration")) { - _preferences.vibration = prefs["vibration"] as bool; - } - - // Save to GSettings - _settings.set_string("preferences", _preferences.to_json_string()); - } - - /** - * Handle settings changed signal - */ - private void _on_settings_changed(GSettings settings) { - // Settings changed, reload preferences - _preferences = NotificationPreferences.from_json_string(settings.get_string("preferences")); - - if (_preferences == null) { - // Set defaults on error - _preferences = new NotificationPreferences(); - settings.set_string("preferences", _preferences.to_json_string()); - } - } -} - -} \ No newline at end of file diff --git a/native-route/linux/src/notification-service.vala b/native-route/linux/src/notification-service.vala deleted file mode 100644 index 6a7ced8..0000000 --- a/native-route/linux/src/notification-service.vala +++ /dev/null @@ -1,199 +0,0 @@ -/* - * notification-service.vala - * - * Main notification service for RSSuper on Linux. - * Implements Gio.Notification API following freedesktop.org spec. - */ - -using Gio; -using GLib; - -namespace RSSuper { - -/** - * NotificationService - Main notification service for Linux - * - * Handles desktop notifications using Gio.Notification. - * Follows freedesktop.org notify-send specification. - */ -public class NotificationService : Object { - - // Singleton instance - private static NotificationService? _instance; - - // Gio.Notification instance - private Gio.Notification? _notification; - - // Tray icon reference - private Gtk.App? _app; - - // Default title - private string _default_title = "RSSuper"; - - // Default urgency - private Urgency _default_urgency = Urgency.NORMAL; - - /** - * Get singleton instance - */ - public static NotificationService? get_instance() { - if (_instance == null) { - _instance = new NotificationService(); - } - return _instance; - } - - /** - * Get the instance (for singleton pattern) - */ - private NotificationService() { - _app = Gtk.App.get_active(); - _default_title = _app != null ? _app.get_name() : "RSSuper"; - _default_urgency = Urgency.NORMAL; - } - - /** - * Check if notification service is available - */ - public bool is_available() { - return Gio.Notification.is_available(); - } - - /** - * Create a new notification - * - * @param title The notification title - * @param body The notification body - * @param icon Optional icon path - * @param urgency Urgency level (NORMAL, CRITICAL, LOW) - * @param timestamp Optional timestamp (defaults to now) - * @return Notification instance - */ - public Notification create(string title, string body, - string? icon = null, - Urgency urgency = Urgency.NORMAL, - DateTime? timestamp = null) { - - if (string.IsNullOrEmpty(title)) { - warning("Notification title cannot be empty"); - title = _default_title; - } - - if (string.IsNullOrEmpty(body)) { - warning("Notification body cannot be empty"); - body = ""; - } - - _notification = Gio.Notification.new(title); - _notification.set_body(body); - _notification.set_urgency(urgency); - - if (timestamp == null) { - _notification.set_time_now(); - } else { - _notification.set_time(timestamp); - } - - if (icon != null) { - try { - _notification.set_icon(icon); - } catch (Error e) { - warning("Failed to set icon: %s", e.message); - } - } - - return _notification; - } - - /** - * Show the notification - */ - public void show() { - if (_notification == null) { - warning("Cannot show null notification"); - return; - } - - try { - _notification.show(); - } catch (Error e) { - warning("Failed to show notification: %s", e.message); - } - } - - /** - * Show the notification with timeout - * - * @param timeout_seconds Timeout in seconds (default: 5) - */ - public void show_with_timeout(int timeout_seconds = 5) { - if (_notification == null) { - warning("Cannot show null notification"); - return; - } - - try { - _notification.show_with_timeout(timeout_seconds * 1000); - } catch (Error e) { - warning("Failed to show notification with timeout: %s", e.message); - } - } - - /** - * Get the notification instance - */ - public Gio.Notification? get_notification() { - return _notification; - } - - /** - * Set the default title - */ - public void set_default_title(string title) { - _default_title = title; - } - - /** - * Set the default urgency - */ - public void set_default_urgency(Urgency urgency) { - _default_urgency = urgency; - } - - /** - * Get the default title - */ - public string get_default_title() { - return _default_title; - } - - /** - * Get the default urgency - */ - public Urgency get_default_urgency() { - return _default_urgency; - } - - /** - * Get the app reference - */ - public Gtk.App? get_app() { - return _app; - } - - /** - * Check if the notification can be shown - */ - public bool can_show() { - return _notification != null && _notification.can_show(); - } - - /** - * Get available urgency levels - */ - public static List get_available_urgencies() { - return Urgency.get_available(); - } -} - -} \ No newline at end of file diff --git a/native-route/linux/src/sync-scheduler-tests.vala b/native-route/linux/src/sync-scheduler-tests.vala deleted file mode 100644 index a496b3f..0000000 --- a/native-route/linux/src/sync-scheduler-tests.vala +++ /dev/null @@ -1,218 +0,0 @@ -/* - * sync-scheduler-tests.vala - * - * Unit tests for SyncScheduler - */ - -using GLib; - -namespace RSSuper { - -public class SyncSchedulerTests : TestCase { - - private SyncScheduler? _scheduler; - - protected void setup() { - _scheduler = SyncScheduler.get_instance(); - _scheduler.reset_sync_schedule(); - } - - protected void teardown() { - _scheduler = null; - } - - public void test_initial_state() { - // Test initial state - assert(_scheduler.get_last_sync_date() == null, "Last sync date should be null initially"); - assert(_scheduler.get_preferred_sync_interval_hours() == 6, - "Default sync interval should be 6 hours"); - assert(_scheduler.is_sync_due(), "Sync should be due initially"); - } - - public void test_update_sync_interval_few_feeds() { - // Test with few feeds (high frequency) - _scheduler.update_sync_interval(5, UserActivityLevel.HIGH); - - assert(_scheduler.get_preferred_sync_interval_hours() <= 2, - "Sync interval should be reduced for few feeds with high activity"); - } - - public void test_update_sync_interval_many_feeds() { - // Test with many feeds (lower frequency) - _scheduler.update_sync_interval(500, UserActivityLevel.LOW); - - assert(_scheduler.get_preferred_sync_interval_hours() >= 24, - "Sync interval should be increased for many feeds with low activity"); - } - - public void test_update_sync_interval_clamps_to_max() { - // Test that interval is clamped to maximum - _scheduler.update_sync_interval(1000, UserActivityLevel.LOW); - - assert(_scheduler.get_preferred_sync_interval_hours() <= 24, - "Sync interval should not exceed maximum (24 hours)"); - } - - public void test_is_sync_due_after_update() { - // Simulate a sync by setting last sync timestamp - _scheduler.set_last_sync_timestamp(); - - assert(!_scheduler.is_sync_due(), "Sync should not be due immediately after sync"); - } - - public void test_reset_sync_schedule() { - // Set some state - _scheduler.set_preferred_sync_interval_hours(12); - _scheduler.set_last_sync_timestamp(); - - // Reset - _scheduler.reset_sync_schedule(); - - // Verify reset - assert(_scheduler.get_last_sync_date() == null, - "Last sync date should be null after reset"); - assert(_scheduler.get_preferred_sync_interval_hours() == 6, - "Sync interval should be reset to default (6 hours)"); - } - - public void test_user_activity_level_high() { - var activity_level = UserActivityLevel.calculate(10, 60); - assert(activity_level == UserActivityLevel.HIGH, - "Should be HIGH activity"); - } - - public void test_user_activity_level_medium() { - var activity_level = UserActivityLevel.calculate(3, 3600); - assert(activity_level == UserActivityLevel.MEDIUM, - "Should be MEDIUM activity"); - } - - public void test_user_activity_level_low() { - var activity_level = UserActivityLevel.calculate(0, 86400 * 7); - assert(activity_level == UserActivityLevel.LOW, - "Should be LOW activity"); - } - - public void test_schedule_next_sync() { - // Schedule should succeed - var result = _scheduler.schedule_next_sync(); - assert(result, "Schedule next sync should succeed"); - } - - public void test_cancel_sync_timeout() { - // Schedule then cancel - _scheduler.schedule_next_sync(); - _scheduler.cancel_sync_timeout(); - - // Should not throw - assert(true, "Cancel should not throw"); - } -} - -public class SyncWorkerTests : TestCase { - - private SyncWorker? _worker; - - protected void setup() { - _worker = new SyncWorker(); - } - - protected void teardown() { - _worker = null; - } - - public void test_perform_sync_empty() { - // Sync with no subscriptions should succeed - var result = _worker.perform_sync(); - - assert(result.feeds_synced == 0, "Should sync 0 feeds"); - assert(result.articles_fetched == 0, "Should fetch 0 articles"); - } - - public void test_sync_result() { - var errors = new List(); - var result = new SyncResult(5, 100, errors); - - assert(result.feeds_synced == 5, "Should have 5 feeds synced"); - assert(result.articles_fetched == 100, "Should have 100 articles fetched"); - } - - public void test_subscription() { - var sub = new Subscription("test-id", "Test Feed", "http://example.com/feed"); - - assert(sub.id == "test-id", "ID should match"); - assert(sub.title == "Test Feed", "Title should match"); - assert(sub.url == "http://example.com/feed", "URL should match"); - } - - public void test_feed_data() { - var articles = new List
(); - articles.append(new Article("art-1", "Article 1", "http://example.com/1")); - - var feed_data = new FeedData("Test Feed", articles); - - assert(feed_data.title == "Test Feed", "Title should match"); - assert(feed_data.articles.length() == 1, "Should have 1 article"); - } - - public void test_article() { - var article = new Article("art-1", "Article 1", "http://example.com/1", 1234567890); - - assert(article.id == "art-1", "ID should match"); - assert(article.title == "Article 1", "Title should match"); - assert(article.link == "http://example.com/1", "Link should match"); - assert(article.published == 1234567890, "Published timestamp should match"); - } -} - -public class BackgroundSyncServiceTests : TestCase { - - private BackgroundSyncService? _service; - - protected void setup() { - _service = BackgroundSyncService.get_instance(); - } - - protected void teardown() { - _service.shutdown(); - _service = null; - } - - public void test_singleton() { - var instance1 = BackgroundSyncService.get_instance(); - var instance2 = BackgroundSyncService.get_instance(); - - assert(instance1 == instance2, "Should return same instance"); - } - - public void test_is_syncing_initially_false() { - assert(!_service.is_syncing(), "Should not be syncing initially"); - } - - public void test_schedule_next_sync() { - var result = _service.schedule_next_sync(); - assert(result, "Schedule should succeed"); - } - - public void test_cancel_all_pending() { - _service.schedule_next_sync(); - _service.cancel_all_pending(); - - // Should not throw - assert(true, "Cancel should not throw"); - } -} - -} // namespace RSSuper - -// Main test runner -public static int main(string[] args) { - Test.init(ref args); - - // Add test suites - Test.add_suite("SyncScheduler", SyncSchedulerTests.new); - Test.add_suite("SyncWorker", SyncWorkerTests.new); - Test.add_suite("BackgroundSyncService", BackgroundSyncServiceTests.new); - - return Test.run(); -} diff --git a/native-route/linux/src/sync-scheduler.vala b/native-route/linux/src/sync-scheduler.vala deleted file mode 100644 index d91bed0..0000000 --- a/native-route/linux/src/sync-scheduler.vala +++ /dev/null @@ -1,325 +0,0 @@ -/* - * sync-scheduler.vala - * - * Manages background sync scheduling for RSSuper on Linux. - * Uses GTimeout for in-app scheduling and integrates with systemd timer. - */ - -using Gio; -using GLib; - -namespace RSSuper { - -/** - * SyncScheduler - Manages background sync scheduling - * - * Handles intelligent scheduling based on user behavior and system conditions. - * Uses GTimeout for in-app scheduling and can trigger systemd timer. - */ -public class SyncScheduler : Object { - - // Default sync interval (6 hours in seconds) - public const int DEFAULT_SYNC_INTERVAL = 6 * 3600; - - // Minimum sync interval (15 minutes in seconds) - public const int MINIMUM_SYNC_INTERVAL = 15 * 60; - - // Maximum sync interval (24 hours in seconds) - public const int MAXIMUM_SYNC_INTERVAL = 24 * 3600; - - // Singleton instance - private static SyncScheduler? _instance; - - // Settings for persisting sync state - private Settings? _settings; - - // GTimeout source for scheduling - private uint _timeout_source_id = 0; - - // Last sync timestamp - private uint64 _last_sync_timestamp = 0; - - // Preferred sync interval - private int _preferred_sync_interval = DEFAULT_SYNC_INTERVAL; - - // Sync callback - public signal void sync_due(); - - /** - * Get singleton instance - */ - public static SyncScheduler? get_instance() { - if (_instance == null) { - _instance = new SyncScheduler(); - } - return _instance; - } - - /** - * Get the instance (for singleton pattern) - */ - private SyncScheduler() { - // Initialize settings for persisting sync state - try { - _settings = new Settings("org.rssuper.sync"); - } catch (Error e) { - warning("Failed to create settings: %s", e.message); - } - - // Load last sync timestamp - if (_settings != null) { - _last_sync_timestamp = _settings.get_uint64("last-sync-timestamp"); - _preferred_sync_interval = _settings.get_int("preferred-sync-interval"); - } - - info("SyncScheduler initialized: last_sync=%lu, interval=%d", - _last_sync_timestamp, _preferred_sync_interval); - } - - /** - * Get last sync date as DateTime - */ - public DateTime? get_last_sync_date() { - if (_last_sync_timestamp == 0) { - return null; - } - return new DateTime.from_unix_local((int64)_last_sync_timestamp); - } - - /** - * Get preferred sync interval in hours - */ - public int get_preferred_sync_interval_hours() { - return _preferred_sync_interval / 3600; - } - - /** - * Set preferred sync interval in hours - */ - public void set_preferred_sync_interval_hours(int hours) { - int clamped = hours.clamp(MINIMUM_SYNC_INTERVAL / 3600, MAXIMUM_SYNC_INTERVAL / 3600); - _preferred_sync_interval = clamped * 3600; - - if (_settings != null) { - _settings.set_int("preferred-sync-interval", _preferred_sync_interval); - } - - info("Preferred sync interval updated to %d hours", clamped); - } - - /** - * Get time since last sync in seconds - */ - public uint64 get_time_since_last_sync() { - if (_last_sync_timestamp == 0) { - return uint64.MAX; - } - var now = get_monotonic_time() / 1000000; // Convert to seconds - return now - _last_sync_timestamp; - } - - /** - * Check if sync is due - */ - public bool is_sync_due() { - var time_since = get_time_since_last_sync(); - return time_since >= (uint64)_preferred_sync_interval; - } - - /** - * Schedule the next sync based on current conditions - */ - public bool schedule_next_sync() { - // Cancel any existing timeout - cancel_sync_timeout(); - - // Check if we should sync immediately - if (is_sync_due() && get_time_since_last_sync() >= (uint64)(_preferred_sync_interval * 2)) { - info("Sync is significantly overdue, scheduling immediate sync"); - schedule_immediate_sync(); - return true; - } - - // Calculate next sync time - var next_sync_in = calculate_next_sync_time(); - - info("Next sync scheduled in %d seconds (%.1f hours)", - next_sync_in, next_sync_in / 3600.0); - - // Schedule timeout - _timeout_source_id = Timeout.add_seconds(next_sync_in, on_sync_timeout); - return true; - } - - /** - * Update preferred sync interval based on user behavior - */ - public void update_sync_interval(int number_of_feeds, UserActivityLevel activity_level) { - int base_interval; - - // Adjust base interval based on number of feeds - if (number_of_feeds < 10) { - base_interval = 4 * 3600; // 4 hours for small feed lists - } else if (number_of_feeds < 50) { - base_interval = 6 * 3600; // 6 hours for medium feed lists - } else if (number_of_feeds < 200) { - base_interval = 12 * 3600; // 12 hours for large feed lists - } else { - base_interval = 24 * 3600; // 24 hours for very large feed lists - } - - // Adjust based on user activity - switch (activity_level) { - case UserActivityLevel.HIGH: - _preferred_sync_interval = base_interval / 2; // Sync more frequently - break; - case UserActivityLevel.MEDIUM: - _preferred_sync_interval = base_interval; - break; - case UserActivityLevel.LOW: - _preferred_sync_interval = base_interval * 2; // Sync less frequently - break; - } - - // Clamp to valid range - _preferred_sync_interval = _preferred_sync_interval.clamp( - MINIMUM_SYNC_INTERVAL, MAXIMUM_SYNC_INTERVAL); - - // Persist - if (_settings != null) { - _settings.set_int("preferred-sync-interval", _preferred_sync_interval); - } - - info("Sync interval updated to %d hours (feeds: %d, activity: %s)", - _preferred_sync_interval / 3600, number_of_feeds, - activity_level.to_string()); - - // Re-schedule - schedule_next_sync(); - } - - /** - * Get recommended sync interval based on current conditions - */ - public int recommended_sync_interval() { - return _preferred_sync_interval; - } - - /** - * Reset sync schedule - */ - public void reset_sync_schedule() { - cancel_sync_timeout(); - _last_sync_timestamp = 0; - _preferred_sync_interval = DEFAULT_SYNC_INTERVAL; - - if (_settings != null) { - _settings.set_uint64("last-sync-timestamp", 0); - _settings.set_int("preferred-sync-interval", DEFAULT_SYNC_INTERVAL); - } - - info("Sync schedule reset"); - } - - /** - * Cancel any pending sync timeout - */ - public void cancel_sync_timeout() { - if (_timeout_source_id > 0) { - Source.remove(_timeout_source_id); - _timeout_source_id = 0; - info("Sync timeout cancelled"); - } - } - - /** - * Set last sync timestamp (called after sync completes) - */ - public void set_last_sync_timestamp() { - _last_sync_timestamp = get_monotonic_time() / 1000000; - - if (_settings != null) { - _settings.set_uint64("last-sync-timestamp", _last_sync_timestamp); - } - - info("Last sync timestamp updated to %lu", _last_sync_timestamp); - } - - /** - * Trigger sync now (for testing or user-initiated) - */ - public void trigger_sync_now() { - info("Triggering sync now"); - sync_due(); - } - - /** - * Reload state from settings - */ - public void reload_state() { - if (_settings != null) { - _last_sync_timestamp = _settings.get_uint64("last-sync-timestamp"); - _preferred_sync_interval = _settings.get_int("preferred-sync-interval"); - } - info("State reloaded: last_sync=%lu, interval=%d", - _last_sync_timestamp, _preferred_sync_interval); - } - - /** - * Sync timeout callback - */ - private bool on_sync_timeout() { - info("Sync timeout triggered"); - sync_due(); - return false; // Don't repeat - } - - /** - * Schedule immediate sync - */ - private void schedule_immediate_sync() { - // Schedule for 1 minute from now - _timeout_source_id = Timeout.add_seconds(60, () => { - info("Immediate sync triggered"); - sync_due(); - return false; - }); - } - - /** - * Calculate next sync time in seconds - */ - private int calculate_next_sync_time() { - var time_since = get_time_since_last_sync(); - if (time_since >= (uint64)_preferred_sync_interval) { - return 60; // Sync soon - } - return _preferred_sync_interval - (int)time_since; - } -} - -/** - * UserActivityLevel - User activity level for adaptive sync scheduling - */ -public enum UserActivityLevel { - HIGH, // User actively reading, sync more frequently - MEDIUM, // Normal usage - LOW // Inactive user, sync less frequently - - public static UserActivityLevel calculate(int daily_open_count, uint64 last_opened_ago_seconds) { - // High activity: opened 5+ times today OR opened within last hour - if (daily_open_count >= 5 || last_opened_ago_seconds < 3600) { - return UserActivityLevel.HIGH; - } - - // Medium activity: opened 2+ times today OR opened within last day - if (daily_open_count >= 2 || last_opened_ago_seconds < 86400) { - return UserActivityLevel.MEDIUM; - } - - // Low activity: otherwise - return UserActivityLevel.LOW; - } -} - -}