News Aggregator
Build an offline-first news reader with a home-screen widget. This project owns the Paging 3 + Room + WorkManager + Glance skill set — the foundation of every content-heavy app from Twitter to Pocket.
The user journey
Home screen widget (latest 3 headlines)
│
▼
Main feed (paged, offline) ─→ Article reader ─→ Share / Save
│
├─ Topic filter
└─ Search (FTS)
Features (by milestone)
M1 — Skeleton + feed
:core:designwith reading-optimized typography (Literata or similar)- Navigation: Home, Topics, Saved, Settings
- Room schema:
Article,Topic,SavedArticle,RemoteKey - Convention plugins from Module 14
M2 — Paging 3 with Room + RemoteMediator
@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator @Inject constructor(
private val api: NewsApi,
private val db: AppDatabase
) : RemoteMediator<Int, ArticleEntity>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, ArticleEntity>): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val lastKey = db.remoteKeyDao().getLast() ?: return MediatorResult.Success(true)
lastKey.nextPage ?: return MediatorResult.Success(true)
}
}
return try {
val response = api.topHeadlines(page = page, pageSize = state.config.pageSize)
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.remoteKeyDao().clear()
db.articleDao().clear()
}
db.articleDao().insertAll(response.articles.map(ArticleDto::toEntity))
db.remoteKeyDao().insert(RemoteKey(nextPage = if (response.hasMore) page + 1 else null))
}
MediatorResult.Success(endOfPaginationReached = !response.hasMore)
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}
@Singleton
class ArticleRepository @Inject constructor(
private val db: AppDatabase,
private val mediator: ArticleRemoteMediator
) {
@OptIn(ExperimentalPagingApi::class)
fun pager(): Flow<PagingData<Article>> = Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 5),
remoteMediator = mediator,
pagingSourceFactory = { db.articleDao().pagingSource() }
).flow.map { paging -> paging.map(ArticleEntity::toDomain) }
}
M3 — Background sync
- Periodic
WorkManagerjob refreshing top headlines every 4 hours - Expedited job on boot to catch up missed news
- Constraints: network connected, not low battery
- Per-topic subscribe/unsubscribe with separate workers
M4 — Article reader + save-for-later
- Reader mode with Compose Material 3 typography
- Customizable font size + theme (light/sepia/dark)
- Save-for-later with local cache of full HTML
- Export to PDF using Android Print API
M5 — Glance home widget
class HeadlinesWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val articles = currentState(key = headlinesKey) ?: emptyList<Article>()
GlanceTheme {
Column(modifier = GlanceModifier.padding(8.dp)) {
articles.take(3).forEach { article ->
Row(modifier = GlanceModifier
.fillMaxWidth()
.clickable(actionStartActivity(ReaderActivity.intentFor(article.id)))
) {
Text(article.title, style = TextStyle(fontWeight = FontWeight.Bold))
}
}
}
}
}
}
}
class HeadlinesWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = HeadlinesWidget()
}
Update widget from a WorkManager job — Glance re-renders when state changes.
M6 — Production
- Search: Room FTS5 over article body
- Baseline profile for feed + reader
- Macrobenchmark: scroll feed, open article cold
- Play Data Safety: Reading history, optional analytics
- Accessibility: heading levels in reader, landmarks for TalkBack
Architecture diagram
┌──────────────────────────────────────────────────────────────┐
│ Home Screen Widget (Glance) │
│ reads: Top 3 headlines from Room │
└──────────────────────┬───────────────────────────────────────┘
│
┌──────────────────────▼───────────────────────────────────────┐
│ App UI (Compose) │
│ FeedScreen (Paging 3) · ReaderScreen · SearchScreen │
├──────────────────────────────────────────────────────────────┤
│ ViewModels + Paging + Room Flow │
├──────────────────────────────────────────────────────────────┤
│ ArticleRepository + RemoteMediator │
├──────────────────────────────────────────────────────────────┤
│ Room DB (source of truth) ◄──── WorkManager sync jobs ────│
│ │ │
│ ▼ │
│ NewsApi (Retrofit) │
└──────────────────────────────────────────────────────────────┘
Stretch goals
🔊
Audio articles
TTS narration with ExoPlayer for background playback, MediaSession for lockscreen controls.
🤖
On-device summarization
Gemini Nano via ML Kit for 2-sentence summaries — no network, no cost.
📌
Smart notifications
Breaking news alerts with user-level filter ("only politics and tech"). Rich notification with expanded text.
📊
Reading stats
Time spent per topic, streak tracker, most-read sources.
🌐
Share extension
Accept shared URLs from browser, save to reader, extract content via backend.
Testing strategy
- Paging source tests — assert that Room-backed PagingSource emits expected pages
- MockWebServer for Retrofit + RemoteMediator integration tests
- WorkManager tests with
SynchronousExecutorfor deterministic job runs - Glance tests with
GlanceAppWidgetUnitTestManager
Offline UX polish
- Stale indicator: subtle banner if last sync > 6 hours
- Offline mode: disable pull-to-refresh animation, show "No connection" snackbar
- Image placeholders: Coil with blurhash or dominant color fallback
- Preload images: prefetch thumbnails for next page before the user scrolls
Privacy
- No third-party tracking pixels in reader (load article text from API, not iframe the source)
- Reading history local only, with user toggle for cross-device sync via your backend
- Analytics opt-out respected; even first-party event collection gated
Next
You've completed all five projects! Return to:
- Capstone Projects overview for a recap
- Career & Interview Prep to prepare your portfolio and go interview