- Kotlin 99.1%
- Shell 0.9%
| app | ||
| gradle | ||
| .env | ||
| .gitignore | ||
| build.gradle.kts | ||
| crash_log.txt | ||
| follow-up-features.md | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| logcat.txt | ||
| project-prep.sh | ||
| README.md | ||
| settings.gradle.kts | ||
| VERSION | ||
FresshReader
Your feeds. Your rules.
A privacy-first Android RSS reader that pairs with your FreshRSS server and delivers a Google Discover–style browsing experience — entirely on-device. Your data never leaves your device.
Every article, feed, sync, and ranking decision in FresshReader is fully auditable. The source code is open, the algorithms are documented, and nothing happens in secret.
Table of Contents
- Features
- How It Works
- Communication & Security
- Third-Party Dependencies
- Installation
- Getting Started
- Feed Modes
- The Ranking Engine
- YouTube & Video Playback
- SponsorBlock Integration
- Card Layout
- Data & Privacy
- License
Features
- FreshRSS Greader API sync — authenticate, pull subscriptions and articles, push read/star state back
- Discover feed — algorithmic ranking with recency scoring, feed weighting, source cooldown, category balancing, and duplicate clustering for a varied, curated experience
- Five feed modes — Discover (Balanced / Priority), Latest (one per source), Starred, Catch Up (oldest first)
- On-device ranking — fully deterministic, no cloud round-trips, every placement has an explainable reason
- Offline support — articles are cached locally and fully readable without network
- Background sync — periodic sync every 30 minutes via Android WorkManager
- Pull-to-refresh — manual sync whenever you want new content
- Inline video player — YouTube videos detected in your feed are played inside the reader via Invidious (ad-free), with optional SponsorBlock auto-skipping of sponsored segments
- Explainability — every article card shows why it was placed there (recency, favorite source, category diversity, etc.)
- Star feed sources — mark entire feeds as "starred" and see their articles alongside your bookmarked articles in the Starred tab
- Feed management — set source weight (Favorite / Normal / Low / Muted), cooldown spacing, and preferred-source status from Settings
- Left side drawer — swipe from the left edge (or tap the menu icon) to open a slide-out drawer. The top section lists every category; the bottom section lists your subscriptions, grouped by category with per-category collapsible groups
- Category & subscription views — pick a category or a single subscription from the drawer to open a full-page, Discover-style browse of just those articles. Both support search within the selection, sorting (newest first / oldest first / random), and — for categories — a source filter (multi-select chips of the sources in that category)
- System theme support — light, dark, or follow-the-system
- Custom accent color — pick from a palette of colors to personalize the app
How It Works
FresshReader does not fetch, parse, or store RSS feeds on its own. It relies entirely on your FreshRSS server for feed retrieval and storage. The app is a smart browsing layer on top of FreshRSS.
Data Flow
FreshRSS Server
(fetches feeds, parses RSS/Atom)
│
▼ Greader API (HTTPS)
┌─────────────┐
│ FresshReader │
│ (Android app)│
│ │
│ Local Room DB│
│ (on-device │
│ cache) │
│ │
│ Ranking │
│ Engine │
│ (on-device) │
│ │
│ Compose UI │
└─────────────┘
-
Login — You provide your FreshRSS server URL, username, and password (or API password). The app authenticates via FreshRSS's Google Reader–style
ClientLoginendpoint and stores your credentials in Android's encrypted storage (EncryptedSharedPreferences). -
Initial sync — After authentication, the app pulls your full subscription list via
subscription/list. For each subscription, it fetches the latest 100 articles (with pagination) viastream/contents. All data is written to a local Room SQLite database. -
Incremental sync — Subsequent syncs (manual or background) use a timestamp-based approach. The app stores the last sync time and only pulls articles published after that point. FreshRSS handles deduplication.
-
Ranking — When you open a feed, the app queries the local database for candidate articles, then runs the ranking pipeline entirely on-device (the CPU — no network calls). The ranked list is displayed as cards.
-
State push-back — When you mark an article read or toggle its star, the app pushes that state back to FreshRSS via the
edit-tagendpoint, so changes stay in sync across all your RSS clients. -
Offline fallback — Articles already cached in Room are available for reading at any time, with or without network. Sync operations gracefully handle network errors and will retry on the next interval.
The Local Database (Room)
All synced data lives in a local SQLite database managed by Room (Android's official persistence library). Three tables are maintained:
| Table | What it stores | Why it exists |
|---|---|---|
feeds |
Subscription metadata: ID, name, URL, icon, category, weight, cooldown, preferred/hidden/starred flags | In-mirrors your FreshRSS subscriptions; the app extends this with local-only fields (weight, cooldown, etc.) that never sync back |
articles |
Full article data: title, summary, content, author, published date, URL, categories, enclosure links, images, read/star state, ranking score, display position, duplicate-group ID | Enables offline reading and powers the ranking engine without hitting the network |
sync_state |
Key-value store for sync metadata (last successful sync timestamp, auth token) | Tracks where you left off for incremental sync |
Feed Processing
When data arrives from FreshRSS:
- FreshRSS is the authority for what feeds exist and what articles belong to them
- FresshReader preserves all local-only fields (weight, cooldown, hidden/starred state, ranking score, display position) across syncs — they are never overwritten
- If an article already exists locally, the app smart-merges it (preserving read/star state, local score, and other app-specific data)
- Duplicate detection runs at ranking time, not sync time, so deduplication adapts as articles age
Communication & Security
| Concern | How it works |
|---|---|
| Protocol | FreshRSS Google Reader API (Greader) over HTTPS |
| Authentication | ClientLogin — username + password exchanged once for an Auth token; the token is sent as a bearer header on all subsequent requests |
| Credential storage | EncryptedSharedPreferences (AES-256-GCM backed by Android Keystore) — your password and auth token are encrypted at rest |
| Credential transmission | Password is sent once during login, over HTTPS, to your FreshRSS server only |
| In-memory auth | The auth token is held in memory for the app's lifetime; the raw password is loaded from encrypted storage only when a new token is needed |
| Network requests | All API calls go to your FreshRSS server — nothing is sent to any third party |
| Encryption used | AES-256-GCM (credential storage), TLS 1.3 (network transport via OkHttp) |
| FreshRSS endpoints used | accounts/ClientLogin, reader/api/0/subscription/list, reader/api/0/stream/contents, reader/api/0/edit-tag |
| Fever API fallback | If your FreshRSS version does not support Greader subscription/list, the app falls back to the Fever API to enumerate feeds and relies on Greader for article content |
What the app sends to your server
- Your username and password (once, at login)
- An
Authorization: GoogleLogin auth=...header on all requests - Read/star state changes as
edit-tagcalls when you interact with articles
What the app sends to third parties
- SponsorBlock API (
sponsor.ajay.app) — when you watch a YouTube video inside the app, a request is made to fetch skip segments (sponsored content timestamps). No identifying data is sent; only the YouTube video ID. You can read SponsorBlock's privacy policy at https://sponsor.ajay.app/privacy. - YouTube/Invidious — when watching a YouTube video, the WebView loads an embed page from a randomly chosen Invidious instance (see YouTube & Video Playback). Invidious serves no ads, runs no tracking scripts, and does not require JavaScript from Google's domains.
- Coil image loader — article images (thumbnails, feed icons) are fetched from the URLs provided by your RSS feeds. This typically means connecting to the original publisher's servers to download images. Coil does not send identifying headers beyond standard HTTP.
Third-Party Dependencies
Every external library in FresshReader is included for a specific purpose, and every one is open-source. Here is the complete list, what it does, and why it is here.
Build & Compilation
| Dependency | Version | What it does | Why it is included |
|---|---|---|---|
| Kotlin | 1.9.24 | The programming language | Primary development language for Android |
| Android Gradle Plugin | 8.5.2 | Build system plugin | Compiles the Android app, manages the SDK toolchain |
UI Framework (Jetpack Compose + Material 3)
| Dependency | Version | What it does | Why it is included |
|---|---|---|---|
| Compose BOM | 2024.06.00 | Bill of materials — pins all Compose libraries to compatible versions | Ensures all Jetpack Compose libraries work together |
| Compose UI | (via BOM) | Core UI toolkit — layout, drawing, input, accessibility | Declarative UI rendering engine |
| Compose Material 3 | (via BOM) | Material Design 3 components — cards, buttons, navigation bar, text fields, dialogs | Provides the app's look and feel (Material 3 design language) |
| Material Icons Extended | (via BOM) | 5,000+ Material Design icon resources | Powers all icons in the app (star, share, settings, play, etc.) |
| Compose UI Tooling | (via BOM) | Layout inspector and preview renderer | Used during development for debugging UI layouts |
| Activity Compose | 1.9.0 | Bridges Android Activity lifecycle with Compose | Required entry point for any Compose-based Android app |
| Navigation Compose | 2.7.7 | Declarative navigation — tab bar, screen routing, back stack | Manages navigation between Discover, Latest, Starred, Catch Up, Settings, and Reader screens |
| Hilt Navigation Compose | 1.2.0 | Dependency injection for Compose Navigation destinations | Injects ViewModels into each screen automatically |
Architecture & Dependency Injection
| Dependency | Version | What it does | Why it is included |
|---|---|---|---|
| Hilt (Dagger) | 2.51.1 | Compile-time dependency injection framework | Wires together the database, API client, sync engine, ranking engine, and ViewModels without manual boilerplate. Hilt is Google's recommended DI for Android. |
| androidx.lifecycle | 2.8.2 | Lifecycle-aware components — ViewModel, LiveData, LifecycleOwner | Manages UI state across configuration changes, survives rotation, handles coroutine scoping |
| androidx.core.ktx | 1.13.1 | Kotlin extensions for Android core APIs | Provides idiomatic Kotlin wrappers (e.g. Context.toast(), SharedPreferences.edit {}) |
Local Database
| Dependency | Version | What it does | Why it is included |
|---|---|---|---|
| Room | 2.6.1 | SQLite object-relational mapping (ORM) | Abstracts the local database with compile-time verified queries. Room is Google's recommended persistence library for Android. |
| Room KTX | 2.6.1 | Kotlin coroutine extensions for Room | Enables suspending DAO methods (suspend fun) for clean coroutine integration |
| Room Compiler | 2.6.1 | Annotation processor | Generates Room implementation code at compile time |
Network
| Dependency | Version | What it does | Why it is included |
|---|---|---|---|
| Retrofit | 2.11.0 | Type-safe HTTP client | Declares the FreshRSS Greader API as a Kotlin interface; Retrofit handles serialization, deserialization, and HTTP method routing. It is the most widely used HTTP client in Android. |
| Gson Converter | 2.11.0 | JSON serialization/deserialization for Retrofit | Converts FreshRSS JSON responses to Kotlin data classes (and vice versa for outgoing requests) |
| OkHttp | 4.12.0 | Low-level HTTP engine | Powers Retrofit's actual network I/O; handles connection pooling, timeouts, TLS, and HTTP/2. OkHttp is the foundational networking layer used by most Android apps. |
| OkHttp Logging | 4.12.0 | HTTP request/response logging interceptor | Used during debugging to inspect API calls; excluded from release builds via configuration |
Background Work
| Dependency | Version | What it does | Why it is included |
|---|---|---|---|
| WorkManager | 2.9.1 | Deferrable background task scheduler | Runs periodic sync every 30 minutes, surviving app restarts and device reboots. WorkManager is Google's recommended library for persistent background work in Android. |
Image Loading
| Dependency | Version | What it does | Why it is included |
|---|---|---|---|
| Coil | 2.6.0 | Image loader for Compose | Fetches, caches, and renders article images and feed icons. Coil is the most popular Compose-native image loader, built on OkHttp (which we already include). It handles disk/memory caching, downsampling, and placeholder rendering automatically. |
Encryption
| Dependency | Version | What it does | Why it is included |
|---|---|---|---|
| AndroidX Security Crypto | 1.0.0 | EncryptedSharedPreferences | Encrypts your server URL, username, password, and auth token at rest using AES-256-GCM backed by the Android Keystore hardware (where available) |
Coroutines
| Dependency | Version | What it does | Why it is included |
|---|---|---|---|
| Kotlinx Coroutines | 1.8.1 | Kotlin coroutines — structured concurrency | Powers all async operations: network calls, database queries, sync pipeline, ranking engine. Every suspending function in the app runs on a coroutine. |
| Kotlinx Coroutines Android | 1.8.1 | Android Dispatchers.Main integration |
Enables viewModelScope.launch(Dispatchers.IO) — the standard pattern for safe background work in ViewModels |
Integration with SponsorBlock
| Dependency | Version | What it does | Why it is included |
|---|---|---|---|
| SponsorBlock API | (external service) | Crowd-sourced database of sponsored segments in YouTube videos | Not a library. The app makes an HTTP GET to https://sponsor.ajay.app/api/skipSegments?videoID=... to fetch sponsored-segment timestamps. No identifying data is sent — only the YouTube video ID. Skip segments are optional and enabled per-video. |
Testing (development only, not in the APK)
| Dependency | Version | What it does |
|---|---|---|
| JUnit 4 | 4.13.2 | Unit testing framework |
| MockK | 1.13.11 | Kotlin mocking library for tests |
| AndroidX Test JUnit | 1.2.1 | Android instrumentation test helpers |
| Espresso | 3.6.1 | UI automation testing |
Installation
Via Obtainium (recommended)
FresshReader is distributed as a signed APK attached to each release. The recommended way to install and receive updates is Obtainium, an APK downloader and updater that supports Forgejo releases natively.
- Install Obtainium on your Android device
- Open Obtainium and tap Add App
- Configure the source:
- Source Type:
Forgejo - App URL:
https://forge.sabylasolutions.com/sabyla/fresshreader
- Source Type:
- Tap Add — Obtainium fetches the latest release info
- Verify the package ID is
com.fresshreader.appand tap Download/Install - Automatic updates will be delivered through Obtainium thereafter
Manual install
Download the app-release-v*.apk from the releases page and install it directly.
Getting Started
-
You need a FreshRSS instance. FresshReader connects to an existing FreshRSS server (self-hosted or shared). You need your server URL, username, and either your account password or a dedicated API password (recommended — set in FreshRSS under Profile → API password).
-
Launch the app. The first screen is the login form:
- Server URL — the full URL of your FreshRSS instance, e.g.
https://rss.example.com(no trailing path needed; the app auto-appends/api/greader.php/) - Username — your FreshRSS login
- Password — your account password or the dedicated API password
- Tap Connect to authenticate
- Server URL — the full URL of your FreshRSS instance, e.g.
-
First sync. After login, the app triggers an initial full sync: it pulls all your subscriptions and the latest articles. A loading indicator shows progress.
-
Ongoing sync. The app automatically syncs every 30 minutes in the background. You can also pull-to-refresh on any feed to trigger an immediate sync.
Feed Modes
FresshReader offers five distinct ways to browse your feeds, accessible from the bottom navigation bar:
| Mode | Tab | What it shows |
|---|---|---|
| Discover — Balanced | 1st tab | All non-muted articles, algorithmically ranked for variety. The default feed. Recency, source preference, category diversity, and source spacing are all factored in. |
| Discover — Priority | 1st tab (sub-tab) | Same pipeline as Balanced, but favorite sources are weighted more heavily. For when you want to prioritize your preferred feeds. |
| Latest | 2nd tab | One most-recent unread article per source, newest first. A simple chronological view. |
| Starred | 3rd tab | Articles you have bookmarked (starred), plus articles from feeds you have marked as starred. Useful as a "favorites" collection. |
| Catch Up | 4th tab | All unread articles, oldest first. Designed for working through your backlog. |
| Settings | 5th tab (cog icon) | Feed management (star, weight, cooldown, preferred source), theme (light/dark/system), accent color picker, account logout |
Within each mode, tapping an article card opens the full Reader Screen with:
- Formatted article content (HTML rendered via Android WebView)
- Article metadata (author, date, source, estimated reading time)
- YouTube / Invidious video player inline (with SponsorBlock support — see below)
- Share button to share the article URL with any Android app
- Swipe back or use the back arrow to return to the feed
The Left Drawer & Category / Subscription Views
Beyond the bottom navigation tabs, FresshReader has a left side drawer for drilling into your feeds by category and by individual source.
- Open it by swiping from the left screen edge, or by tapping the menu (hamburger) icon in any tab's top bar.
- Top section — Categories: every FreshRSS category (feeds with no category appear as "Uncategorized"). Tap one to open a full-page view of all articles in that category.
- Bottom section — Subscriptions: your feeds, grouped by category. Each category group is collapsible; tap a feed to open a full-page view of all articles from that single source.
Both full-page views behave like the bottom-nav tabs (they keep the bottom navigation bar and open articles in the Reader). Within them you can:
- Search — a search field filters articles by title/summary within the selected category or source.
- Sort — newest first, oldest first, or random. Random order is fixed the moment you pick it and stays stable across refreshes (pull-to-refresh keeps its standard behavior).
- Filter by source (categories only) — toggle chips to narrow the category view to one or more specific sources; no chips selected means "all sources in this category".
Unlike the main tabs (which show unread, deduped articles and exclude muted/hidden feeds), the category and subscription views show every article — read and unread, including muted and hidden feeds — so they act as a complete, unfiltered browser for that slice of your library.
The Ranking Engine
The Discover feeds use a multi-stage ranking pipeline that runs entirely on your device. Every placement is computed deterministically from the same data and is fully explainable.
Pipeline Stages
-
Recentness scoring — Each article is assigned a recency score based on its age. An article published 5 minutes ago scores near 100; one published 30 days ago scores near 1. The decay curve is logarithmic, so recent articles are strongly favored but old articles still have a chance to surface.
-
Duplicate clustering — The same news story published by multiple sources is clustered into a single group. The cluster algorithm uses three matching strategies in order:
- SimHash — A 64-bit locality-sensitive hash computed from title word trigrams; articles with the same SimHash are grouped
- URL match — If two articles share the same destination URL, they are grouped
- Jaccard similarity — If title word overlap >= 80%, they are grouped
Within each group, the best article surfaces (your preferred source wins; if none is marked preferred, the highest-scoring article wins). Articles from the same cluster that do not surface are excluded from the feed to prevent redundancy.
-
Feed weighting — Each source has a weight multiplier:
Weight Multiplier Effect Favorite ×1.5 Boosts score by 50% Normal ×1.0 Baseline Low ×0.6 Reduces score by 40% Muted ×0.0 Source excluded entirely -
Source cooldown — No single source is allowed to dominate. The algorithm enforces a minimum spacing (default: 4 other articles) between consecutive appearances of the same source. If violating this would leave an empty slot, the constraint relaxes to avoid gaps.
-
Category balancing — A sliding window (size 6) ensures categories are interleaved. If two articles from the same category would appear within the window, a lower-scoring article from a different category may swap in (as long as its score is within 15% of the displaced article). This prevents any one topic from overwhelming the feed.
-
Final ordering — Articles are sorted by their final score (recency × weight × any remaining penalty), then by publication date (newer first), then by database ID (for deterministic ordering of ties).
Priority Mode
Priority mode uses the same pipeline but squares the weight multiplier (favorite → 2.25×, normal → 1.0×, low → 0.36×). This makes favorite sources appear much more often while still respecting cooldown and category balancing.
Explainability
Every card in the Discover feed can show why it was placed where it was. The reasons are generated during ranking and stored alongside each article. Possible reasons include:
- Recent — This article is fresher than competing articles
- Favorite source — The source is marked as a favorite (×1.5 boost)
- Preferred source — This source won a duplicate tiebreaker
- Not a duplicate — A unique story, no duplicates existed
- Spaced — Cooldown spacing was satisfied
- Category diversity — Category was underrepresented in recent cards
YouTube & Video Playback
When an article in your feed contains a YouTube URL, FresshReader detects it and provides an inline video player experience.
Detection
The app scans article URLs for YouTube links matching any of these formats:
youtube.com/watch?v=VIDEO_IDyoutu.be/VIDEO_IDyoutube.com/embed/VIDEO_IDm.youtube.com/watch?v=VIDEO_ID
Thumbnail
Article thumbnails are automatically set to the YouTube video's maxresdefault.jpg image (fetched directly from img.youtube.com). A play button overlay is shown over the thumbnail.
Player
When you tap the play button, the app creates an inline WebView-based player. Instead of loading YouTube's official embed (which serves ads, tracks viewing, and requires Google JavaScript), the app uses Invidious — a privacy-respecting, ad-free front-end for YouTube.
How Invidious works:
- Invidious is an open-source proxy that fetches YouTube video streams and serves them without ads, tracking, or Google JavaScript
- The app maintains a list of five public Invidious instances and randomly selects one for each video played (spreading load and providing redundancy):
yewtu.beinvidious.snopyta.orginvidious.privacydev.netinv.vern.ccinvidious.private.coffee
- The selected instance serves the video stream directly inside the WebView
- No connection is made to
youtube.com,googlevideo.com,doubleclick.net, or any Google domain - The embed page has no ads, no comments, no recommendations sidebar
What is communicated:
- The Invidious instance receives the YouTube video ID (required to serve the video)
- The app's IP address is visible to the Invidious instance (just as it would be to any website you visit)
- Invidious instances are run by privacy-focused volunteers; check each instance's privacy policy for details
If the selected Invidious instance is unreachable, the player will show an error. In that case, try again — a different instance will be selected.
SponsorBlock Integration
FresshReader optionally integrates with SponsorBlock, a crowd-sourced database of sponsored segments, intros, outros, and other non-content sections in YouTube videos.
How it works:
- When you play a video, the app fetches skip segments from
https://sponsor.ajay.app/api/skipSegments?videoID=VIDEO_ID - If segments exist, the app injects JavaScript into the Invidious embed page that automatically skips them while you watch
- Sponsors, intros, outros, and self-promotional segments are all skipped seamlessly
What is communicated to SponsorBlock:
- The YouTube video ID only
- No IP or user identification is sent (unless SponsorBlock's server logs it, as with any HTTP request)
Privacy: SponsorBlock uses video IDs as its database key. There are no user accounts, no tracking, and no analytics. The API endpoint is free and open without authentication.
Card Layout
The Discover feed uses a repeating card-size pattern for a varied, magazine-style feel:
| Position (0-indexed) | Card Size |
|---|---|
| 0 | Hero (large image, big headline) |
| 1 | Medium (standard card) |
| 2 | Medium |
| 3 | Compact (text-forward, small image) |
| 4 | Hero |
| 5 | Medium |
| 6 | Compact |
The pattern repeats every 7 cards. If an article lacks an image, it gracefully steps down to the next smaller size (Hero → Medium → Compact).
Other feed modes use subsets:
- Starred — All Hero cards (falls to Medium if no image)
- Latest — Medium and Compact cards
- Catch Up — Hero and Medium cards
Data & Privacy
| Data | Where it lives | Who has access |
|---|---|---|
| Your FreshRSS server URL, username, password, auth token | Your device, in encrypted storage (AES-256-GCM) | Only you |
| Your feed subscriptions and articles | Your device, in the local Room database; also on your FreshRSS server | Only you and your FreshRSS server operator |
| Your read/star state | Your device and your FreshRSS server | Only you and your FreshRSS server operator |
| YouTube video IDs, when playing videos | Sent to a random Invidious instance (for streaming) and to SponsorBlock (for skip segments) | The Invidious instance operator; the SponsorBlock project |
| Article images and feed icons | Fetched from original publisher servers by Coil | The publisher's CDN/logs |
No analytics, no crash reporting, no telemetry of any kind are included in this app. There is no Firebase, no Google Analytics, no Sentry, no bug tracker. Crash logs are written to a local file on your device and are never transmitted anywhere.
License
FresshReader is proposed for release under the Apache License 2.0.