From b79e6e7aa292629270a4bc2b20cdf90d5dfda2b8 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 31 Mar 2026 12:54:01 -0400 Subject: [PATCH] fix readme repo diagram, add agents.md --- AGENTS.md | 175 ++++++++++++++ README.md | 9 +- .../integration/FeedIntegrationTest.kt | 10 +- .../org.rssuper.app.settings.gschema.xml | 21 ++ linux/meson.build | 37 +++ linux/src/app-settings.vala | 162 +++++++++++++ linux/src/tests/settings-store-tests.vala | 224 ++++++++++++++++++ 7 files changed, 629 insertions(+), 9 deletions(-) create mode 100644 AGENTS.md create mode 100644 linux/gsettings/org.rssuper.app.settings.gschema.xml create mode 100644 linux/src/app-settings.vala create mode 100644 linux/src/tests/settings-store-tests.vala diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0884c10 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,175 @@ +# AGENTS.md - RSSuper Development Guide + +This file provides guidelines for AI agents working on the RSSuper codebase. + +## Project Overview + +RSSuper is a native multi-platform RSS reader: +- **iOS/macOS**: Swift + SwiftUI + Xcode +- **Android**: Kotlin + Jetpack Compose + Gradle +- **Linux**: Vala + GTK4 + Libadwaita + Meson +- **Windows**: Planned (C# + WinUI3) + +## Build Commands + +### Main Build Script (all platforms) +```bash +./scripts/build.sh # Build all platforms (debug) +./scripts/build.sh -p ios,android # Build specific platforms +./scripts/build.sh -t release # Release build +./scripts/build.sh --test # Build and test +./scripts/build.sh -a clean # Clean all +``` + +### iOS/macOS +```bash +./scripts/build-ios.sh # Debug build +./scripts/build-ios.sh Release iOS build # Release build +./scripts/build-ios.sh Debug iOS test # Run tests +./scripts/build-ios.sh Debug iOS clean # Clean + +# Single test file (in Xcode) +xcodebuild test -scheme RSSuper -destination 'platform=iOS Simulator,name=iPhone 15' \ + -only-testing:RSSuperTests/SearchQueryTests +``` + +### Android +```bash +cd android && ./gradlew assembleDebug # Debug APK +cd android && ./gradlew assembleRelease # Release APK +cd android && ./gradlew test # Run all unit tests +cd android && ./gradlew test --tests "com.rssuper.search.SearchQueryTest" # Single test class +cd android && ./gradlew clean # Clean + +# Single test via Gradle +cd android && ./gradlew test --tests "com.rssuper.search.SearchQueryTest.testParse*" +``` + +### Linux +```bash +./scripts/build-linux.sh debug build # Debug build +./scripts/build-linux.sh release test # Run tests +./scripts/build-linux.sh debug clean # Clean +``` + +## Code Style Guidelines + +### General Principles +- Write clean, readable code with minimal comments (only when logic is complex) +- Follow existing patterns in each platform's codebase +- Use dependency injection for testability +- Prefer explicit over implicit + +### Kotlin (Android) +- **Formatting**: 4 spaces indentation, 140 char line limit +- **Naming**: `camelCase` for functions/variables, `PascalCase` for classes +- **Imports**: Grouped: standard library → Android → third-party → project +- **Types**: Use Kotlin null safety, prefer `val` over `var` +- **Error Handling**: Use `Result` or sealed classes for errors (see `SearchQuery.kt`) +- **Coroutines**: Use `viewModelScope` in ViewModels, structured concurrency +- **Tests**: JUnit 4 with Mockito, Robolectric for Android-specific tests + +### Swift (iOS/macOS) +- **Formatting**: 4 spaces, 120 char line limit +- **Naming**: `camelCase`, `PascalCase` for types/protocols +- **Imports**: Foundation → SwiftUI/AppKit → third-party → project +- **Error Handling**: Use `Result` and `async/await` +- **Protocols**: Use protocols for dependency injection (see `FeedServiceProtocol`) +- **Tests**: XCTest, arrange/act/assert pattern + +### Vala (Linux) +- **Formatting**: 4 spaces indentation +- **Naming**: `snake_case` for methods/variables, `PascalCase` for classes +- **Memory**: Use GLib's reference counting (`ref`/`unref`) +- **Error Handling**: Use `GLib.Error` and `try/catch` + +### Android-specific +- **ViewModels**: Use `androidx.lifecycle.ViewModel` with `StateFlow` +- **Database**: Room with Kotlin coroutines +- **Background Work**: WorkManager for sync tasks + +### iOS-specific +- **State Management**: `@Observable` macro (iOS 17+) or `ObservableObject` +- **Persistence**: SQLite via SQLite.swift +- **Background**: BGTaskScheduler for background refresh + +## Architecture Patterns + +### Clean Architecture Layers +1. **UI Layer**: SwiftUI Views / Jetpack Compose +2. **ViewModel/ViewModel**: Business logic, state management +3. **Service Layer**: `FeedService`, `SearchService`, `BookmarkStore` +4. **Data Layer**: Database managers, network clients + +### Dependency Injection +- Android: Manual DI in constructors (no framework) +- iOS: Protocol-based DI with default implementations + +## Testing Guidelines + +### Unit Tests Location +- **Android**: `android/src/test/java/com/rssuper/` +- **iOS**: `iOS/RSSuperTests/` +- **Linux**: Test files alongside source in `linux/src/` + +### Test Naming Convention +- `MethodName_State_ExpectedResult` (e.g., `SearchQueryTest.parseEmptyQueryReturnsNull`) + +### Test Data +- Fixtures in `tests/fixtures/` (sample RSS/Atom feeds) + +## File Organization + +``` +RSSuper/ +├── android/ # Kotlin Android library +│ ├── src/main/java/com/rssuper/ +│ │ ├── model/ # Data models +│ │ ├── parsing/ # Feed parsers +│ │ ├── state/ # State management +│ │ ├── viewmodel/ # ViewModels +│ │ ├── search/ # Search service +│ │ └── database/ # Room database +│ └── src/test/ # Unit tests +├── iOS/RSSuper/ # Swift iOS app +│ ├── Models/ # Data models +│ ├── Parsing/ # Feed parsers +│ ├── Services/ # Business logic +│ ├── ViewModels/ # ViewModels +│ ├── Networking/ # HTTP client +│ ├── Settings/ # User preferences +│ └── Database/ # SQLite layer +├── iOS/RSSuperTests/ # Unit tests +├── linux/ # Vala GTK app +│ └── src/ # Source + tests +├── scripts/ # Build scripts +└── tests/fixtures/ # Test data +``` + +## Common Tasks + +### Running a Single Android Test +```bash +cd android +./gradlew test --tests "com.rssuper.search.SearchQueryTest" +``` + +### Running a Single iOS Test +```bash +./scripts/build-ios.sh Debug iOS test +# Or in Xcode: Product > Run Tests (select specific test) +``` + +### Adding a New Platform Module +1. Create directory under `native-route/` +2. Add build command to `./scripts/build.sh` +3. Add test configuration to CI workflow +4. Update this AGENTS.md + +## Important Notes + +- The Android project uses a library module structure (not application) +- iOS minimum deployment: iOS 16.0+ +- Android minimum SDK: 24 (Android 7.0) +- Linux requires GTK4, Libadwaita, SQLite3 development headers +- All platform modules share similar business logic patterns \ No newline at end of file diff --git a/README.md b/README.md index 939001f..86bf4f2 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,10 @@ RSSuper uses a native-first approach, building truly native applications for eac ``` RSSuper/ -├── native-route/ # Native platform projects -│ ├── ios/ # iOS/macOS Xcode project -│ ├── android/ # Android Gradle project -│ ├── linux/ # Linux Meson project -│ └── windows/ # Windows project (planned) +ios/ # iOS/macOS Xcode project +android/ # Android Gradle project +linux/ # Linux Meson project +windows/ # Windows project (planned) ├── scripts/ # Build scripts │ ├── build.sh # Main build orchestrator │ ├── build-ios.sh # iOS/macOS builder diff --git a/android/src/androidTest/java/com/rssuper/integration/FeedIntegrationTest.kt b/android/src/androidTest/java/com/rssuper/integration/FeedIntegrationTest.kt index 08bfc8d..004e76e 100644 --- a/android/src/androidTest/java/com/rssuper/integration/FeedIntegrationTest.kt +++ b/android/src/androidTest/java/com/rssuper/integration/FeedIntegrationTest.kt @@ -328,10 +328,12 @@ class FeedIntegrationTest { val fetchResult = feedFetcher.fetch(feedUrl) assertTrue("Fetch should succeed", fetchResult.isSuccess()) - - assertThrows { - feedParser.parse(fetchResult.getOrNull()!!.feedXml, feedUrl) - } + + try { + feedParser.parse(fetchResult.getOrNull()!!.feedXml, feedUrl) + fail("Parsing invalid XML should throw exception") + } catch (e: Exception) { + } } @Test diff --git a/linux/gsettings/org.rssuper.app.settings.gschema.xml b/linux/gsettings/org.rssuper.app.settings.gschema.xml new file mode 100644 index 0000000..dee6344 --- /dev/null +++ b/linux/gsettings/org.rssuper.app.settings.gschema.xml @@ -0,0 +1,21 @@ + + + + + 'reading_preferences.json' + Reading preferences file name + + + 'sync_preferences.json' + Sync preferences file name + + + false + Enable background sync + + + 15 + Sync interval in minutes + + + diff --git a/linux/meson.build b/linux/meson.build index 5a174b8..e846b85 100644 --- a/linux/meson.build +++ b/linux/meson.build @@ -20,6 +20,19 @@ xml_dep = dependency('libxml-2.0', version: '>= 2.0') soup_dep = dependency('libsoup-3.0', version: '>= 3.0') gtk_dep = dependency('gtk4', version: '>= 4.0') +# GSettings schemas +schema_files = [ + 'gsettings/org.rssuper.notification.preferences.gschema.xml', + 'gsettings/org.rssuper.app.settings.gschema.xml', +] + +compile_schemas = custom_target('Compile GSettings schemas', + output: 'schemas.glibdir', + command: glib_dep.get_variable(name: 'glib-mkenums') + ' --targetdir=build --schema-dir=gsettings schemas.glibdir', + dependencies: glib_dep, + install: false +) + # Source files models = files( 'src/models/feed-item.vala', @@ -32,6 +45,13 @@ models = files( 'src/models/bookmark.vala', ) +# Settings files +settings = files( + 'src/settings-store.vala', + 'src/app-settings.vala', + 'src/notification-preferences-store.vala', +) + # Database files database = files( 'src/database/db-error.vala', @@ -76,6 +96,13 @@ models_lib = library('rssuper-models', models, install: false ) +# Settings library +settings_lib = library('rssuper-settings', settings, + dependencies: [glib_dep, gio_dep, json_dep], + link_with: [models_lib], + install: false +) + # Database library database_lib = library('rssuper-database', database, dependencies: [glib_dep, gio_dep, json_dep, sqlite_dep, gobject_dep], @@ -153,9 +180,19 @@ notification_manager_test_exe = executable('notification-manager-tests', install: false ) +# Settings store test executable +settings_store_test_exe = executable('settings-store-tests', + 'src/tests/settings-store-tests.vala', + dependencies: [glib_dep, gio_dep, json_dep], + link_with: [models_lib, settings_lib], + vala_args: ['--vapidir', '.', '--pkg', 'gio-2.0'], + install: false +) + # Test definitions test('database tests', test_exe) test('parser tests', parser_test_exe) test('feed fetcher tests', fetcher_test_exe) test('notification service tests', notification_service_test_exe) test('notification manager tests', notification_manager_test_exe) +test('settings store tests', settings_store_test_exe) diff --git a/linux/src/app-settings.vala b/linux/src/app-settings.vala new file mode 100644 index 0000000..cab3c38 --- /dev/null +++ b/linux/src/app-settings.vala @@ -0,0 +1,162 @@ +/* + * app-settings.vala + * + * Application settings interface and utilities. + * Provides a unified interface for accessing application settings. + */ + +using GLib; + +namespace RSSuper { + +/** + * AppSettings - Application settings interface + * + * Provides access to all application-level settings including: + * - Reading preferences + * - Sync preferences + * - Theme settings + * - Language settings + */ +public class AppSettings : Object { + + // Singleton instance + private static AppSettings? _instance; + + // Settings store + private SettingsStore? _settings_store; + + // Theme + private Theme _theme; + + // Language + private string _language; + + // GSettings schema key + private const string SCHEMA_KEY = "org.rssuper.app.settings"; + + /** + * Get singleton instance + */ + public static AppSettings? get_instance() { + if (_instance == null) { + _instance = new AppSettings(); + } + return _instance; + } + + /** + * Constructor + */ + private AppSettings() { + _settings_store = SettingsStore.get_instance(); + + // Load theme + _theme = Theme.SYSTEM; + _language = "en"; + } + + /** + * Get reading preferences + */ + public ReadingPreferences? get_reading_preferences() { + return _settings_store.get_reading_preferences(); + } + + /** + * Set reading preferences + */ + public void set_reading_preferences(ReadingPreferences prefs) { + _settings_store.set_reading_preferences(prefs); + } + + /** + * Get background sync enabled + */ + public bool get_background_sync_enabled() { + return _settings_store.get_background_sync_enabled(); + } + + /** + * Set background sync enabled + */ + public void set_background_sync_enabled(bool enabled) { + _settings_store.set_background_sync_enabled(enabled); + } + + /** + * Get sync interval in minutes + */ + public int get_sync_interval_minutes() { + return _settings_store.get_sync_interval_minutes(); + } + + /** + * Set sync interval in minutes + */ + public void set_sync_interval_minutes(int minutes) { + _settings_store.set_sync_interval_minutes(minutes); + } + + /** + * Get theme + */ + public Theme get_theme() { + return _theme; + } + + /** + * Set theme + */ + public void set_theme(Theme theme) { + _theme = theme; + } + + /** + * Get language + */ + public string get_language() { + return _language; + } + + /** + * Set language + */ + public void set_language(string language) { + _language = language; + } + + /** + * Get all settings as dictionary + */ + public Dictionary get_all_settings() { + return _settings_store.get_all_settings(); + } + + /** + * Set all settings from dictionary + */ + public void set_all_settings(Dictionary settings) { + _settings_store.set_all_settings(settings); + } + + /** + * Reset all settings to defaults + */ + public void reset_to_defaults() { + _settings_store.reset_to_defaults(); + _theme = Theme.SYSTEM; + _language = "en"; + } +} + +/** + * Theme - Available theme options + */ +public enum RSSuper.Theme { + SYSTEM, + LIGHT, + DARK +} + +} diff --git a/linux/src/tests/settings-store-tests.vala b/linux/src/tests/settings-store-tests.vala new file mode 100644 index 0000000..9f42a48 --- /dev/null +++ b/linux/src/tests/settings-store-tests.vala @@ -0,0 +1,224 @@ +/* + * settings-store-tests.vala + * + * Unit tests for settings store. + */ + +using GLib; + +namespace RSSuper { + +public void main(string[] args) { + print("Running Settings Store Tests\n"); + print("============================\n\n"); + + // Test ReadingPreferences + test_reading_preferences(); + + // Test SettingsStore + test_settings_store(); + + // Test AppSettings + test_app_settings(); + + print("\nAll tests passed!\n"); +} + +/** + * Test ReadingPreferences + */ +private void test_reading_preferences() { + print("Testing ReadingPreferences...\n"); + + // Test default constructor + var prefs = new ReadingPreferences(); + assert(prefs.font_size == FontSize.MEDIUM); + assert(prefs.line_height == LineHeight.NORMAL); + assert(prefs.show_table_of_contents == true); + assert(prefs.show_reading_time == true); + assert(prefs.show_author == true); + assert(prefs.show_date == true); + print(" ✓ Default constructor sets correct defaults\n"); + + // Test with_values constructor + var prefs2 = new ReadingPreferences.with_values( + FontSize.LARGE, + LineHeight.RELAXED, + false, + false, + false, + false + ); + assert(prefs2.font_size == FontSize.LARGE); + assert(prefs2.line_height == LineHeight.RELAXED); + assert(prefs2.show_table_of_contents == false); + assert(prefs2.show_reading_time == false); + assert(prefs2.show_author == false); + assert(prefs2.show_date == false); + print(" ✓ with_values constructor sets correct values\n"); + + // Test to_json_string + var json_str = prefs.to_json_string(); + assert(json_str.contains("fontSize")); + assert(json_str.contains("lineHeight")); + assert(json_str.contains("showTableOfContents")); + print(" ✓ to_json_string produces valid JSON\n"); + + // Test from_json_string + var prefs3 = ReadingPreferences.from_json_string(json_str); + assert(prefs3 != null); + assert(prefs3.font_size == FontSize.MEDIUM); + print(" ✓ from_json_string parses JSON correctly\n"); + + // Test font_size_from_string + assert(ReadingPreferences.font_size_from_string("small") == FontSize.SMALL); + assert(ReadingPreferences.font_size_from_string("medium") == FontSize.MEDIUM); + assert(ReadingPreferences.font_size_from_string("large") == FontSize.LARGE); + assert(ReadingPreferences.font_size_from_string("xlarge") == FontSize.XLARGE); + assert(ReadingPreferences.font_size_from_string("invalid") == FontSize.MEDIUM); + print(" ✓ font_size_from_string parses correctly\n"); + + // Test line_height_from_string + assert(ReadingPreferences.line_height_from_string("normal") == LineHeight.NORMAL); + assert(ReadingPreferences.line_height_from_string("relaxed") == LineHeight.RELAXED); + assert(ReadingPreferences.line_height_from_string("loose") == LineHeight.LOOSE); + assert(ReadingPreferences.line_height_from_string("invalid") == LineHeight.NORMAL); + print(" ✓ line_height_from_string parses correctly\n"); + + // Test equals + assert(prefs.equals(prefs2) == false); + var prefs4 = new ReadingPreferences(); + assert(prefs.equals(prefs4) == true); + print(" ✓ equals works correctly\n"); + + // Test copy_from + var prefs5 = new ReadingPreferences(); + prefs5.copy_from(prefs2); + assert(prefs5.font_size == FontSize.LARGE); + assert(prefs5.line_height == LineHeight.RELAXED); + print(" ✓ copy_from copies values correctly\n"); + + // Test reset_to_defaults + prefs.font_size = FontSize.LARGE; + prefs.reset_to_defaults(); + assert(prefs.font_size == FontSize.MEDIUM); + print(" ✓ reset_to_defaults resets to defaults\n"); + + print("ReadingPreferences tests passed!\n\n"); +} + +/** + * Test SettingsStore + */ +private void test_settings_store() { + print("Testing SettingsStore...\n"); + + // Test singleton pattern + var store1 = SettingsStore.get_instance(); + var store2 = SettingsStore.get_instance(); + assert(store1 == store2); + print(" ✓ Singleton pattern works correctly\n"); + + // Test reading preferences + var prefs = new ReadingPreferences.with_values(FontSize.LARGE); + store1.set_reading_preferences(prefs); + var retrieved_prefs = store1.get_reading_preferences(); + assert(retrieved_prefs != null); + assert(retrieved_prefs.font_size == FontSize.LARGE); + print(" ✓ Reading preferences stored and retrieved correctly\n"); + + // Test background sync enabled + store1.set_background_sync_enabled(true); + assert(store1.get_background_sync_enabled() == true); + store1.set_background_sync_enabled(false); + assert(store1.get_background_sync_enabled() == false); + print(" ✓ Background sync enabled works correctly\n"); + + // Test sync interval + store1.set_sync_interval_minutes(30); + assert(store1.get_sync_interval_minutes() == 30); + store1.set_sync_interval_minutes(15); + assert(store1.get_sync_interval_minutes() == 15); + print(" ✓ Sync interval works correctly\n"); + + // Test get_all_settings + var all_settings = store1.get_all_settings(); + assert(all_settings.containsKey("fontSize")); + assert(all_settings.containsKey("backgroundSyncEnabled")); + assert(all_settings.containsKey("syncIntervalMinutes")); + print(" ✓ get_all_settings returns correct keys\n"); + + // Test set_all_settings + var new_settings = new Dictionary(); + new_settings["fontSize"] = "large"; + new_settings["backgroundSyncEnabled"] = true; + new_settings["syncIntervalMinutes"] = 60; + store1.set_all_settings(new_settings); + assert(store1.get_reading_preferences().font_size == FontSize.LARGE); + assert(store1.get_background_sync_enabled() == true); + assert(store1.get_sync_interval_minutes() == 60); + print(" ✓ set_all_settings sets all values correctly\n"); + + // Test reset_to_defaults + store1.reset_to_defaults(); + assert(store1.get_reading_preferences().font_size == FontSize.MEDIUM); + assert(store1.get_background_sync_enabled() == false); + assert(store1.get_sync_interval_minutes() == 15); + print(" ✓ reset_to_defaults resets all values\n"); + + print("SettingsStore tests passed!\n\n"); +} + +/** + * Test AppSettings + */ +private void test_app_settings() { + print("Testing AppSettings...\n"); + + // Test singleton pattern + var settings1 = AppSettings.get_instance(); + var settings2 = AppSettings.get_instance(); + assert(settings1 == settings2); + print(" ✓ Singleton pattern works correctly\n"); + + // Test theme + settings1.set_theme(Theme.DARK); + assert(settings1.get_theme() == Theme.DARK); + settings1.set_theme(Theme.LIGHT); + assert(settings1.get_theme() == Theme.LIGHT); + settings1.set_theme(Theme.SYSTEM); + assert(settings1.get_theme() == Theme.SYSTEM); + print(" ✓ Theme works correctly\n"); + + // Test language + settings1.set_language("en"); + assert(settings1.get_language() == "en"); + settings1.set_language("es"); + assert(settings1.get_language() == "es"); + print(" ✓ Language works correctly\n"); + + // Test reading preferences through AppSettings + var prefs = new ReadingPreferences.with_values(FontSize.XLARGE); + settings1.set_reading_preferences(prefs); + var retrieved_prefs = settings1.get_reading_preferences(); + assert(retrieved_prefs != null); + assert(retrieved_prefs.font_size == FontSize.XLARGE); + print(" ✓ Reading preferences through AppSettings works\n"); + + // Test sync settings through AppSettings + settings1.set_background_sync_enabled(true); + assert(settings1.get_background_sync_enabled() == true); + settings1.set_sync_interval_minutes(45); + assert(settings1.get_sync_interval_minutes() == 45); + print(" ✓ Sync settings through AppSettings works\n"); + + // Test reset_to_defaults + settings1.reset_to_defaults(); + assert(settings1.get_theme() == Theme.SYSTEM); + assert(settings1.get_language() == "en"); + print(" ✓ reset_to_defaults works correctly\n"); + + print("AppSettings tests passed!\n\n"); +} + +}