FeedFlow is a multi-platform RSS reader built with Kotlin Multiplatform, Compose Multiplatform and SwiftUI. It targets Android, iOS, macOS, Windows, and Linux. The app uses SwiftUI for iOS-specific UI components and Compose for all other platforms. All the business logic is shared via Kotlin Multiplatform.
- core/: Core domain models and utilities shared across all platforms
- shared/: Main business logic, repositories, view models, and data layer
- sharedUI/: Compose UI components shared across Android and Desktop
- database/: SQLDelight database implementation
- i18n/: Internationalization resources
- feedSync/: Feed synchronization modules (Dropbox, FreshRSS, iCloud, etc)
- androidApp/: Android-specific app implementation
- iosApp/: iOS app with SwiftUI
- desktopApp/: Desktop app for Windows, Linux, and macOS
- website/: Hugo-based website
All Gradle commands in this section should be run with --quiet --console=plain.
./gradlew --quiet --console=plain detekt allTests-> Run all checks including tests and linting for Shared code, Android and Desktop.scripts/ci.sh-> Run the local equivalent of.github/workflows/code-checks.yaml(translation refresh, SwiftLint, Gradle checks, Android/Desktop/iOS builds)./gradlew --quiet --console=plain detekt-> Run static analysis with Detekt for Shared code, Android and Desktop.scripts/ios-format.sh-> Format iOS code through swiftformat and swiftlint./gradlew --quiet --console=plain test-> Run all tests for Shared code, Android and Desktop.scripts/refresh-translations.sh-> Regenerate i18n translation code after adding new translations./gradlew --quiet --console=plain :androidApp:assembleGooglePlayDebug-> Build the Android debug APK (then deploy/run with theandroidCLI, see "Running & validating the Android app")./gradlew --quiet --console=plain :androidApp:compileGooglePlayDebugKotlin-> Quick compile check for Android (no APK assembly).scripts/run-android.sh-> Install and launch Android Google Play debug (wraps:androidApp:installGooglePlayDebug)./gradlew --quiet --console=plain desktopApp:run-> Run Desktop app./gradlew --quiet --console=plain :desktopApp:compileKotlinJvm-> Quick compile check for Desktop./gradlew --quiet --console=plain :desktopApp:jvmMainClasses-> Compile Desktop main classes (faster than full build).scripts/delete-desktop-debug-db.sh-> Delete local Desktop debug database and prefs./gradlew --quiet --console=plain :shared:compileKotlinJvm-> Quick compile check for shared module only (fastest iteration)./gradlew --quiet --console=plain :feedSync:feedbin:build-> Build a specific feedSync sub-module (pattern::feedSync:<module>:build)./gradlew --quiet --console=plain :desktopApp:packageDistributionForCurrentOS-> Package desktop app distribution for the current OS
The committed profiles in androidApp/src/main/generated/baselineProfiles/ (baseline-prof.txt, startup-prof.txt) are generated by the :benchmarks module and shared by both flavors (mergeIntoMain = true). Do not hand-edit them.
./gradlew --quiet --console=plain :androidApp:generateBaselineProfile-> Regenerate the profiles; needs a connected device or emulator on API 33+ (or rooted). Commit the updated files../gradlew --quiet --console=plain :benchmarks:connectedGooglePlayBenchmarkReleaseAndroidTest-> Run theStartupBenchmarkmacrobenchmark (compares startup with/without the profile; run on a physical device for meaningful numbers)- Regenerate before releases or when startup/feed-list code paths change significantly; a slightly stale profile is still effective.
- The
nonMinifiedRelease/benchmarkReleasebuild types created by the baseline profile plugin need agoogle-services.jsoninandroidApp/src/nonMinifiedRelease/andandroidApp/src/benchmarkRelease/(see Initial Setup). - The E2E seed deep links are debug-only, so the generator journey is cold start plus a guarded feed-list scroll on the release-like build.
Use the Android CLI (android, installed globally; SDK at ~/Library/Android/sdk) as the default tool to deploy, run, and validate Android.
Auto-update on session start. The first time android is invoked in a session, check its output for a notice like A new version of Android CLI is available (<version>). Please run 'android update' to install it. If you see it, run android update immediately, then tell the user which version was installed and continue with the original task. Don't ask for permission — this is a routine self-update.
Precondition — Android CLI must be installed. Before running any of the commands below, verify it is available with command -v android. If it is not installed, stop and ask the user to install it:
The
androidCLI is required to deploy and validate the app. Install it by following the official guide: https://developer.android.com/tools/agents/android-cli After installation, install the companion skill so agents (Claude Code, codex) know how to drive it:android initRestart your shell and confirm with
android --version, then re-run the task.
Precondition — a device/emulator must be running. Check with adb devices. Do not assume an emulator: use the first device returned by adb devices (real device or emulator — both are fine). If none is connected, default to the resizable emulator: android emulator start Resizable_Experimental (verify the exact AVD name with android emulator list if it fails).
Resizable emulator display mode. When testing with the resizable emulator and the task needs a specific form factor, set it before building/running:
adb emu resize-display 0 # phone
adb emu resize-display 1 # foldable
adb emu resize-display 2 # tabletValidate a UI change end to end:
- Build + deploy:
./gradlew --quiet --console=plain :androidApp:assembleGooglePlayDebug, thenandroid run --apks=androidApp/build/outputs/apk/googlePlay/debug/androidApp-googlePlay-debug.apk(--device=<serial>to pick a device;.scripts/run-android.shcovers the routine install+launch loop). - Inspect structure/text/state with
android layout --pretty. To confirm a specific change, capture the layout before and after the change, then useandroid layout --diffto see only what moved. - For purely visual changes the layout tree can't show (color, spacing, fonts), use
android screen capture --output=ui.png --annotate. Useandroid screen resolve --screenshot=ui.png --string="input tap #<n>"to turn a labeled element into tap coordinates when you need to drive the UI to the screen under test.
For Android API/library questions, android docs search '<query>' before falling back to web search.
For anything deeper — SDK package management (android sdk ...), device interaction, or journey/UI tests — consult the globally-installed android-cli skill instead of expanding this section.
When you need to run the Desktop app for testing, UI iteration, or user-visible validation, prefer the Compose Hot Reload MCP server configured in .mcp.json:
./gradlew --quiet --console=plain :desktopApp:hotMcpServerJvmUse the Gradle daemon for manual local runs so repeated desktop iterations stay fast. The checked-in .mcp.json may still pass --no-daemon because JetBrains' AI-agent example does that for MCP client-managed server processes.
Use the MCP tools when you need to interact with the running Desktop app: check status, reload code changes, list windows, capture screenshots, inspect the semantic tree, click/type/scroll, or resize windows. The MCP server is experimental in Compose Hot Reload; if it is unavailable or not connected, fall back to ./gradlew --quiet --console=plain desktopApp:run and note the limitation in handoff.
The desktop reader bundles readability-es5.js and turndown-es5.js in shared/src/jvmMain/resources/ are committed pre-built ES5 artifacts; do not hand-edit them (the generator tool has been removed from the repo). reader-content-parser.js is hand-written glue and can be edited normally.
When writing or running Maestro E2E tests, follow e2e/maestro/maestro-e2e-guide.md.
The full catalog of existing flows lives in e2e/maestro/maestro-e2e-tests.md; do not mark a test done until the required Maestro flow passes.
When changing a user-visible feature, add or update Maestro coverage if the behavior can be exercised with existing app UI, debug seed deep links, fixtures, or test tooling without adding production-only code paths. If Maestro coverage is not feasible, document the limitation in e2e/maestro/maestro-e2e-tests.md.
Quick local smoke checks:
e2e/scripts/run-android-smoke.she2e/scripts/run-ios-smoke.sh
Full automated E2E checks before release:
e2e/scripts/run-android.she2e/scripts/run-ios.sh
When the user asks for the full Maestro release gate or a failure summary, use the repo-local run-maestro-release-tests skill. Its runner builds/installs both platforms, runs smoke + regression flows, continues after failures, and writes report.html, report.md, and logs under .tmp/maestro-release-tests/<timestamp>/.
Use the debug seeding deep links documented in that guide. Do not depend on live feeds, OAuth, or previous app state in smoke or regression flows.
The iOS Xcode project (iosApp/FeedFlow.xcodeproj) is generated from iosApp/project.yml by XcodeGen and is NOT committed to git. Regenerate it with:
cd iosApp && ./.scripts/generate-project.shThis requires XcodeGen installed (brew install xcodegen, or mint bootstrap using iosApp/Mintfile which pins the version). Regenerate whenever project.yml, iOS source folder structure, xcconfigs, entitlements, or SPM dependencies change. The wrapper script also creates Assets/Config-Debug.xcconfig from the tracked template when it is missing, post-fixes lastKnownFileType for iOS 26 .icon bundles, and injects wasCreatedForAppExtension="YES" into the Widget/Share extension schemes (XcodeGen drops this when the scheme also launches the host app — yonaskolb/XcodeGen#1523). SPM pins are managed via exactVersion: entries in project.yml; no Package.resolved is tracked.
- If using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools.
- The xcodeproj must exist locally. Run
cd iosApp && ./.scripts/generate-project.shfirst if it's missing. - XcodeBuildMCP has repo-local defaults in
.xcodebuildmcp/config.yaml; prefer the current CLI shape:
xcodebuildmcp simulator buildTo build and launch in one step:
xcodebuildmcp simulator build-and-runIf the MCP server is registered in the current agent session, use the equivalent simulator/build or simulator/build-and-run tool; do not use the old build_sim_name_proj tool name.
Do not set derivedDataPath in .xcodebuildmcp/config.yaml by default. Leaving it unset lets XcodeBuildMCP use its external per-workspace DerivedData path, which avoids branch-local artifacts and avoids sharing mutable Xcode build products across concurrent worktrees. Set it only for a temporary cache investigation or an explicit reproducibility test.
./gradlew --quiet --console=plain :shared:allTests-> Run all shared module tests across supported targets./gradlew --quiet --console=plain :shared:jvmTest --tests "com.prof18.feedflow.shared.presentation.SomeTest"-> Run a specific test class on JVM./gradlew --quiet --console=plain :shared:iosSimulatorArm64Test-> Run shared tests on iOS simulator
IMPORTANT: When editing code, you MUST:
- Build the project after making changes
- Fix any compilation errors before proceeding Be sure to build ONLY for the platform you are working on to save time.
Before handing off you must:
- Run
.scripts/refresh-translations.shbefore the Gradle checks if you changed translation resources - Run
./gradlew --quiet --console=plain detekt allTeststo ensure Kotlin/shared/Android/Desktop checks pass - don't run it if you modified only swift files - Run
.scripts/ios-format.shto format iOS code - only run if you made changes on the iOS app - If you changed iOS code, run
xcodebuild -project iosApp/FeedFlow.xcodeproj -scheme FeedFlow -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build -quietbefore handoff; rerun without-quietonly if you need the full diagnostics - Fix any issues found during the above steps
# Android: Copy dummy google-services.json
cp config/dummy-google-services.json androidApp/src/debug/google-services.json
cp config/dummy-google-services.json androidApp/src/release/google-services.json
# Android: baseline profile build types also need it (profile generation/benchmarks)
mkdir -p androidApp/src/nonMinifiedRelease androidApp/src/benchmarkRelease
cp config/dummy-google-services.json androidApp/src/nonMinifiedRelease/google-services.json
cp config/dummy-google-services.json androidApp/src/benchmarkRelease/google-services.json
# iOS: Copy dummy GoogleService-Info.plist
cp config/dummy-google-service.plist iosApp/GoogleService-Info-dev.plist
cp config/dummy-google-service.plist iosApp/GoogleService-Info.plist
# iOS: Create Config.xcconfig
cp iosApp/Assets/Config.xcconfig.template iosApp/Assets/Config.xcconfigFor compile-only local/CI iOS builds without real sync credentials, you can use cp config/dummy-config.xcconfig iosApp/Assets/Config.xcconfig instead.
For Google Play listing/bootstrap checks, prefer FEEDFLOW_PLAY_CONFIG_JSON=/path/to/play_config.json ./gradlew --quiet --console=plain :androidApp:bootstrapGooglePlayReleaseListing. Do not assume an uncommitted repo-root play_config.json exists in automation worktrees.
When writing tests, follow the comprehensive testing guide at .ai/TESTING.md.
Key points:
- All tests extend
KoinTestBasefor dependency injection - Use Turbine for Flow testing
- Prefer fakes over mocking libraries
- Use data generators from
shared/src/commonTest/.../test/generators/ - For sync service tests, use the
feedSync/test-utilsmodule (provides mock HTTP engines and Koin modules for GReader/Feedbin) - Run specific test classes with
--tests "fully.qualified.ClassName"to iterate quickly
For Sentry crash notes, Sentry issue URLs, or event IDs, use the repo-local sentry-note-crash-fix skill. Confirm the live Sentry event when possible, identify the first relevant first-party frame, inspect dependency versions and affected platform code, then validate the narrow fix with the affected platform build before the full gate.
For Miniflux/GReader sync failures, compare FeedFlow's exact requests and headers against the upstream service behavior before changing protocol logic. Miniflux bad sync tokens are detected from the X-Reader-Google-Bad-Token header and mapped to FeedSyncError.GReaderBadToken (FS11); cover changes with feedSync/test-utils mocks.
- DO NOT write comments for every function or class. Only write comments when the code is not self-explanatory.
- If you touch or create any business logic, ensure it's thoroughly tested with unit tests.
- DO NOT excessively use try/catch blocks for every function. Use them only for the top caller or the bottom callers, depending on the cases.
- ALWAYS run gradle tasks with the following flag:
--quiet --console=plain - Prefer keeping data classes and other simple model types at the bottom of a file, or in a dedicated model file when they are shared by multiple classes.
- Android app modules use AGP 9's built-in Kotlin support; do not re-add
org.jetbrains.kotlin.android. Android and Desktop app version values come from thecom.feedflow.versioningconvention plugin, notversioning.gradle.kts. - Detekt is still on the
1.23.xline, so keepio.nlopez.compose.rules:detekton0.4.xunless the repo migrates to Detekt 2;0.5.0+is Detekt 2-only for this repo. - Detekt's
MagicNumberrule hasignoreNamedArgument: true. Do NOT extract a literal into a private constant when it is passed as a named argument (e.g.Modifier.widthIn(max = 180.dp),shadowElevation = 3.dp,.copy(alpha = 0.8f)) — inline it. Only extract a named constant when the literal is a positional argument that Detekt would flag (e.g..zIndex(0.5f),.height(28.dp)); values inignoreNumbers(-1, 0, 1, 2) never need one. - Every
DropdownMenu(Android, shared, and Desktop) must passshape = MaterialTheme.shapes.largeso menus stay coherent with the rounded toolbars. There is no shared wrapper — set it explicitly on each newDropdownMenu.
- For desktop settings/details pages opened from the main screen, prefer a dedicated
DialogWindowinstead of in-window navigation. - Reuse
desktopApp/src/jvmMain/kotlin/com/prof18/feedflow/desktop/ui/components/DesktopDialogWindow.ktfor new desktop windows instead of duplicating window setup. - Use
desktopApp/src/jvmMain/kotlin/com/prof18/feedflow/desktop/main/DesktopDialogWindowNavigator.ktto open/close windows fromMainWindow(add destinations to the enum and keep visibility state there). - Keep the screen body content as a composable content block and avoid duplicating content calls; apply platform conditionals only to wrapper chrome (for example, toolbar/title handling).
- On macOS, keep transparent title bar handling inside the reusable desktop window wrapper; on other desktop platforms avoid adding duplicate custom title UI.
- Use
openUriSafelyfor desktop link opening so unsupported or malformed URLs can fall back to copying instead of crashing the app.
When creating commits:
- Use simple, one-liner commit messages
- DO NOT include phase numbers (e.g., "Phase 1", "Phase 2")
- DO NOT add "Generated with Claude Code" attribution
- DO NOT add "Co-Authored-By: Claude" attribution
- Example:
git commit -m "Add foundation for unified article parsing system"
- Native libraries in
desktopApp/resources-sandbox/macos-arm64/must be signed with the Mac App Store certificate when it is renewed. - Sign with:
codesign --force --timestamp --options runtime --sign "3rd Party Mac Developer Application: Marco Gomiero (Q7CUB3RNAK)" <path> - Verify with:
codesign -dvvv <path>
- ALWAYS build with xcodebuild with -quiet flag when building for iOS. If the command returns errors you may run xcodebuild again without the -quiet flag.
- Direct xcodebuild alternative:
xcodebuild -project iosApp/FeedFlow.xcodeproj -scheme FeedFlow -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build -quiet - IMPORTANT: The project now supports iOS 26 SDK (June 2025) while maintaining iOS 18 as the minimum deployment target. Use #available checks when adopting iOS 26+ APIs.
- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file.
- Keep accessibility identifier enums in separate
*AccessibilityIdentifiers.swiftfiles, not appended to view files. - Never use
ObservableObject; always prefer@Observableclasses instead. - Never use
Task.sleep(nanoseconds:); always useTask.sleep(for:)instead. - Avoid
AnyViewunless it is absolutely required. - Avoid force unwraps and force
tryunless it is unrecoverable. - For SwiftUI screens that use
BrowserSelector, pass the same environment instance into nestedNavigationLinkdestinations and sheets so link-opening and reader-mode settings do not reset while navigating.
- Use
ReaderModeEligibility.canOpenReaderMode/FeedItemUrlInfo.canOpenReaderMode()as the shared gate before opening reader mode on Android, Desktop, and iOS. Ineligible links such as non-http(s), media/PDF/download URLs, YouTube, and Telegram should fall back to the configured browser orHtmlNotAvailablebehavior instead of attempting reader parsing.
- String resources are located in
i18n/src/commonMain/resources/locale/values-[language]/ - Run .scripts/refresh-translations.sh after adding a new translation, to re-generate the kotlin code
- Use the repo-local
translation-release-auditskill for weekly or release checks of app translations, store copy, generated Play listing files, live store metadata, and screenshot copy/assets. Do not upload store metadata/screenshots unless the user explicitly asks. - NEVER add hardcoded strings in the code. Always use the i18n resources.
- NEVER try to translate other languages by yourself. Add only the English strings. The translations will be handled by professionals later.
.scripts/flatpak-build-setup.sh-> Prepare the project for Flatpak builds (sets release props, disables JetBrains JDK vendor, disables toolchain auto-provisioning, comments out Android-only Gradle plugins).scripts/disable-android-for-flatpak.sh-> Comments out all Android-related Gradle config across allbuild.gradle.ktsand build-logic files; excludesandroidAppfromsettings.gradle.kts- Flatpak packaging files live in
desktopApp/packaging/flatpak/(manifest, launch script, desktop entry, AppStream metadata, icon) - The
flatpak=trueproperty indesktopApp/src/jvmMain/resources/props.propertiesis used to disable platform-specific features (e.g., Google Drive sync) in Flatpak builds - HiDPI scaling for the JVM uses
sun.java2d.uiScaleJVM argument. For local testing:./gradlew --quiet --console=plain desktopApp:run -PjvmArgs="-Dsun.java2d.uiScale=2.0"
- CI config:
.github/workflows/code-checks.yaml - Pipeline:
checks(macos-26, detekt + allTests + swiftlint) ->build-android-app+build-desktop-app+build-ios-app(in parallel) - iOS CI build forces arm64 simulator architecture:
xcodebuild -project iosApp/FeedFlow.xcodeproj -configuration Debug -scheme FeedFlow -sdk iphonesimulator -destination "generic/platform=iOS Simulator" ARCHS=arm64 ONLY_ACTIVE_ARCH=YES build | xcbeautify --renderer github-actions - CI runs
.scripts/refresh-translations.shbefore checks; do this locally before pushing if translations changed - Debugging CI failures:
gh run list --limit=10, thengh run view <run-id> --log - Red CI recovery loop:
gh run rerun <run-id>(orgh run rerun <run-id> --failed), then fix and push until green