feat: implement cross-platform features and UI integration

- iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker, BookmarkViewModel, FeedViewModel
- iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker services
- Linux: Add settings-store.vala, State.vala signals, view widgets (FeedList, FeedDetail, AddFeed, Search, Settings, Bookmark)
- Linux: Add bookmark-store.vala, bookmark vala model, search-service.vala
- Android: Add NotificationService, NotificationManager, NotificationPreferencesStore
- Android: Add BookmarkDao, BookmarkRepository, SettingsStore
- Add unit tests for iOS, Android, Linux
- Add integration tests
- Add performance benchmarks
- Update tasks and documentation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-03-30 23:06:12 -04:00
parent 6191458730
commit 14efe072fa
98 changed files with 11262 additions and 109 deletions

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist>
<schema id="org.rssuper.sync" path="/org/rssuper/sync/">
<key type="t" name="last-sync-timestamp">
<default>0</default>
<summary>Last sync timestamp</summary>
<description>The Unix timestamp of the last successful sync</description>
</key>
<key type="i" name="preferred-sync-interval">
<default>21600</default>
<summary>Preferred sync interval in seconds</summary>
<description>The preferred interval between sync operations (default: 6 hours)</description>
</key>
<key type="b" name="auto-sync-enabled">
<default>true</default>
<summary>Auto-sync enabled</summary>
<description>Whether automatic background sync is enabled</description>
</key>
<key type="i" name="sync-on-wifi-only">
<default>0</default>
<summary>Sync on Wi-Fi only</summary>
<description>0=always, 1=Wi-Fi only, 2=never</description>
</key>
</schema>
</schemalist>

View File

@@ -0,0 +1,10 @@
[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

View File

@@ -0,0 +1,23 @@
[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

View File

@@ -0,0 +1,23 @@
[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

View File

@@ -0,0 +1,503 @@
/*
* 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 result = subprocess_helper_command_str(
"systemctl", "is-enabled", "rssuper-sync.timer");
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<void?>(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<Error>();
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<string> subscription_ids) {
// TODO: Implement partial sync
return new SyncResult(0, 0, new List<Error>());
}
/**
* Cancel ongoing sync operations
*/
public void cancel_sync() {
info("Sync cancelled");
// TODO: Cancel ongoing network requests
}
/**
* Fetch subscriptions that need syncing
*/
private List<Subscription> fetch_subscriptions_needing_sync() {
// TODO: Replace with actual database query
// For now, return empty list as placeholder
return new List<Subscription>();
}
/**
* Sync a batch of subscriptions
*/
private SyncResult sync_batch(List<Subscription> subscriptions) {
var feeds_synced = 0;
var articles_fetched = 0;
var errors = new List<Error>();
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<List<Subscription>> chunk_list(List<Subscription> list, int size) {
var batches = new List<List<Subscription>>();
var current_batch = new List<Subscription>();
foreach (var item in list) {
current_batch.append(item);
if (current_batch.length() >= size) {
batches.append(current_batch);
current_batch = new List<Subscription>();
}
}
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<Error> errors {
get { return _errors; }
}
private int _feeds_synced;
private int _articles_fetched;
private List<Error> _errors;
public SyncResult(int feeds_synced, int articles_fetched, List<Error> 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<Article> articles {
get { return _articles; }
}
private string _title;
private List<Article> _articles;
public FeedData(string title, List<Article> 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;
}
}
}

View File

@@ -0,0 +1,325 @@
/*
* 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;
}
}
}