Simple RSS Feed Reader - Jetpack Compose
Tags: android, kotiln, beginners, jetpackcompose
Published: 2022-09-24 00:17:39

**How I build a clean architecture RSS Feed Reader Android app using Kotlin and Jetpack Compose?**

This is the very first Jetpack Compose Android app that I built. It is a simple app that reads my blog's [rss.xml](https://vtsen.hashnode.dev/rss.xml) and stores them in a local database. You can bookmark articles, mark articles as read, share articles and search articles by title. It shows the full article content within the app.

![Android_News_Overview.gif](https://cdn.hashnode.com/res/hashnode/image/upload/v1646904119176/66PHvRR-l.gif)

## High-level Architecture

This is the high-level architecture design which is based on [MVVM](https://vtsen.hashnode.dev/mvc-vs-mvp-vs-mvvm-design-patterns) / recommended [Android app architecture](https://developer.android.com/topic/architecture).

![Simple_RSS_Feed_Reader_Jetpack_Compose.drawio.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1660817453436/iG2Y9mb44.png)

As you may already know, the UI event flows downward and data flows upward through callback or `Flow`. The dependency direction is also one way from UI layer to Data layer. 

The following table summarizes the responsibility of all the components in UI, domain and data layers.

| UI Layer | Responsibility|
| --- | --- |
| `MainActivity` | Constructs `MainViewModel` and all its dependencies such as `ArticlesRepositoryImpl`, `ArticlesDatabase` and `WebService` |
| `MainScreen` | Setup top bar and bottom bar navigation, build navigation graph, setup snack bar UI display | 
| `HomeScreen` | Acts as start destination screen which lists all the articles from rss.xml. Provides the ability to bookmark, share, mark as unread on each article, add search articles feature at top bar |
| `UnreadScreen` | Lists all unread articles here |
| `BookmarkScreen` | Lists all bookmarked articles here |
| `SearchScreen` | Shows the article search results |
| `MainViewModel` | Provides UI states (data needed by all the composable functions), collect flows from `ArticlesRepository`, refresh the articles in `ArticlesRepository` |

| Domain Layer | Responsibility|
| --- | --- |
| `ArticlesRepository` | Acts as interface between UI layer and data layer. Provides domain data model (articles information) to the UI layer through `Flow` | 

| Data Layer | Responsibility|
| --- | --- |
| `ArticlesRepositoryImpl` |  Implements the `ArticlesRepository` interface, fetches articles from `WebService` and write into the `ArticlesDatabase`, map and transform local data to domain data |
| `ArticlesDatabase` | Implements local `RoomDatabase` which acts as single source of truth |
| `WebServce` | Fetches XML string using `ktor client`, parses the XML feed and converts the XML to remote data (which is transformed to local data for local database writing)|

> **Important note:** This app is under heavy development. So, the information provided in this article may be outdated. For example, I have
> 
> * Changed the app to use multiple view models instead of a single view model
>     
> * Added usecase classes in the domain layer and move all repository-related classes (including the `ArticlesRepository` interface) from the domain to the data layer.
>     
> * Implemented Proto DataStore to store user preferences instead of using the same `ArticlesDatabase` room database.
>     

## Implementation Details
I just highlight the high-level implementations that are worth mentioning. The source code shown here may not be complete. For details, please refer to the source code directly.

### Top and Bottom App Bars
The top and bottom app bars are implemented using [Scaffold](https://developer.android.com/jetpack/compose/layouts/material#scaffold) composable function.

```kotlin
@Composable
fun MainScreen(viewModel: MainViewModel, useSystemUIController: Boolean) {
    /*...*/
    val scaffoldState = rememberScaffoldState()
    val navHostController = rememberNavController()

    Scaffold(
        scaffoldState = scaffoldState,
        topBar = { TopBar(navHostController, viewModel) },
        bottomBar = { BottomBarNav(navHostController) }
    ) {
        NavGraph(viewModel, navHostController)
    }
    /*...*/
}
```

### Navigation Graph
The navigation graph implementation is very similar to what I did in this article:

- [Simple Jetpack Compose Navigation Example](https://vtsen.hashnode.dev/simple-jetpack-compose-navigation-example)

The screen navigation back stack looks like this.

![Simple_RSS_Feed_Reader_Navigation_Backstack.drawio.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1660876946419/5s14gnLnH.png)

`HomeScreen` is the start destination which navigates to different screens. Because the bottom navigation can navigate from and to any screen, calling ` popUpTo(NavRoute.Home.path)` us to ensure the back stack is always 2-level depth.

```kotlin
@Composable
private fun BottomNavigationItem() {
    /*...*/
    val selected = currentNavRoutePath == targetNavRoutePath
    rowScope.BottomNavigationItem(
        /*...*/
        onClick = {
            if(!selected) {
                navHostController.navigate(targetNavRoutePath) {
                    popUpTo(NavRoute.Home.path) {
                        inclusive = (targetNavRoutePath == NavRoute.Home.path)
                    }
                }
            }
        },
        /*...*/
    )
}
```

For bottom navigation implementation, you can refer to this article:

- [How to Add Bottom Navigation in Jetpack Compose?](https://vtsen.hashnode.dev/how-to-add-bottom-navigation-in-jetpack-compose)

### Image Loading
For image loading, I used the `rememberImagePainter()` composable function from the [coil](https://coil-kt.github.io/coil/) image loading library.

```kotlin
@Composable
private fun ArticleImage(article: Article) {
    Image(
        painter = rememberImagePainter(
            data = article.image,
            builder = {
                placeholder(R.drawable.loading_animation)
            }
        ),
        contentScale = ContentScale.Crop,
        contentDescription = "",
        modifier = Modifier
            .size(150.dp, 150.dp)
            .clip(MaterialTheme.shapes.medium)
    )
}
```
> coil is the only image loading libary that supports Jetpack Compose as far as I know

There is this [landscapist](https://github.com/skydoves/landscapist) library that wraps around other image-loading libraries for Jetpack Compose, but I don't know if there are any advantages of using it.

### XML Fetching and Parsing
To fetch the XML remotely, I use [Ktor Client](https://ktor.io/docs/getting-started-ktor-client.html) library, which is the multiplatform asynchronous HTTP client. The implementation is super simple here.

```kotlin
class WebService {

    suspend fun getXMlString(url: String): String {
        val client = HttpClient()
        val response: HttpResponse = client.request(url)
        client.close()
        return response.body()
    }
}
```

The issue with Ktor Client is probably its performance.  Based on the little experience I did in the following article, it runs 2x slower!

- [Simple REST API Android App in Kotlin - Various HTTP Client Library Implementations](https://vtsen.hashnode.dev/simple-rest-api-android-app-in-kotlin-various-http-client-library-implementations)

However, it is not a direct comparison, as this usage is pretty straightforward. It doesn't use [Kotlin Serialization](https://vtsen.hashnode.dev/simple-rest-api-android-app-in-kotlin-various-http-client-library-implementations#heading-4-ktor-client-kotlin-serialization) which potentially is the main issue here.  Well, this is something for me to experiment in the future.

> **\[Updated - Jan 15, 2023\]:** Ktor Client throws the following exception on API 21
> 
> java.util.concurrent.ExecutionException: java.lang.NoClassDefFoundError: io.ktor.util.collections.ConcurrentMap$$ExternalSyntheticLambda0
> 
> To workaround this issue, I use the `OkHttpClient`.

```kotlin
interface WebService {
    suspend fun getXMlString(url: String): String
}

class OkHttpWebService : WebService {

    override suspend fun getXMlString(url: String): String {
        val client = OkHttpClient()
        val request: Request = Request.Builder()
            .url(url)
            .build()
        var response = client.newCall(request).execute()

        return response.body?.string() ?: ""
    }
}
```

> Please note that I have extracted out the `WebService` as an interface as I want to keep both Ktor Client and OkHttp Client implementations.

To parse the XML, I used the [XmlPullParser](https://developer.android.com/reference/org/xmlpull/v1/XmlPullParser) library. `FeedPaser.parse()` is the high-level implementation.  It converts the XML string to `List<ArticleFeed>`.

```kotlin
class FeedParser {

    private val pullParserFactory = XmlPullParserFactory.newInstance()
    private val parser = pullParserFactory.newPullParser()

    fun parse(xml: String): List<ArticleFeed> {

        parser.setInput(xml.byteInputStream(), null)

        val articlesFeed = mutableListOf<ArticleFeed>()
        var feedTitle = ""

        while (parser.eventType != XmlPullParser.END_DOCUMENT) {

            if (parser.eventType  == XmlPullParser.START_TAG && parser.name == "title") {
                feedTitle = readText(parser)

            } else if (parser.eventType  == XmlPullParser.START_TAG && parser.name == "item") {
                val feedItem = readFeedItem(parser)
                val articleFeed = ArticleFeed(
                    feedItem = feedItem,
                    feedTitle = feedTitle)
                articlesFeed.add(articleFeed)
            }
            parser.next()
        }

        return articlesFeed
    }
    /*...*/
}
```

### Local SQLite Database
I used the [Room database](https://developer.android.com/jetpack/androidx/releases/room) library from Android Jetpack to build the SQLite local database.  The usage is pretty standard, so I'm not going to talk about it. Instead, I share with you what I did a bit differently in the following.

Instead of hard coding the table name, I declare a [singleton](https://vtsen.hashnode.dev/how-to-create-singleton-class-in-kotlin) below.
```kotlin
object DatabaseConstants {
    const val ARTICLE_TABLE_NAME = "article"
}
```

Then, I use it in `ArticleEntity`

```kotlin
@Entity(tableName = DatabaseConstants.ARTICLE_TABLE_NAME)
data class ArticleEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Int,
    val title: String,
    val link: String,
    val author: String,
    val pubDate: Long,
    val image: String,
    val bookmarked: Boolean,
    val read: Boolean,

    val feedTitle: String,
)
```

and also in `ArticlesDao` interface.

```kotlin
@Dao
interface ArticlesDao {
    @Query("SELECT * FROM ${DatabaseConstants.ARTICLE_TABLE_NAME} ORDER by pubDate DESC")
    fun selectAllArticles(): Flow<List<ArticleEntity>>
    
    /*...*/
}
```

Another problem I faced is deleting all the articles does not reset the auto-increment of the primary key. To fix this, I need to bypass Room and run SQL query directly using `runSqlQuery()`  to delete the `sqlite_sequence`.

```kotlin
@Database(
    version = 1,
    entities = [ArticleEntity::class],
    exportSchema = false)
abstract class ArticlesDatabase : RoomDatabase() {
    protected abstract val dao: ArticlesDao
    /*...*/
    fun deleteAllArticles() {
        dao.deleteAllArticles()
        // reset auto increment of the primary key
        runSqlQuery("DELETE FROM sqlite_sequence WHERE name='${DatabaseConstants.ARTICLE_TABLE_NAME}'")
    }
    /*...*/
}
```

### Article Screen
By right, I should be able to build the article screen from the feed's data, but I took the shortcut to implement an in-app web browser using [WebView](https://developer.android.com/reference/android/webkit/WebView). I just need to wrap it inside the [AndroidView](https://developer.android.com/reference/kotlin/androidx/compose/ui/viewinterop/package-summary#AndroidView(kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Function1)) composable function.

```kotlin
@Composable
private fun ArticleWebView(url: String) {

    if (url.isEmpty()) {
        return
    }

    Column {

        AndroidView(factory = {
            WebView(it).apply {
                webViewClient = WebViewClient()
                loadUrl(url)
            }
        })
    }
}
```
It is very simple, isn't it? The drawback is it doesn't support offline view. I did try to work around by loading the HTML instead of URL, but no luck. 

### Swipe Refresh
To refresh the articles, I use the [Swipe Refresh](https://google.github.io/accompanist/swiperefresh/) library from Accompanist to call `MainViewModel.refresh()` when you swipe down the screen.

```kotlin
@Composable
fun ArticlesScreen() {
    /*...*/
    SwipeRefresh(
        state = rememberSwipeRefreshState(viewModel.isRefreshing),
        onRefresh = { viewModel.refresh() }
    ) {
        /*..*/
    }
}
```
> **[Updated - Jan 2, 2023]:** Swipe refresh library from Accompanish  is deprecated and replaced by `Modifier.pullRefresh()` in `androidx.compose.material` library.

After the migration, the code looks like this.

```kotlin
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ArticlesScreen(
    onReadClick: (Article) -> Unit,
) {
    /*...*/
    val pullRefreshState = rememberPullRefreshState(
        viewModel.isRefreshing, 
        viewModel.Refresh)
        
    Box(Modifier.pullRefresh(pullRefreshState)) {
        /*..*/

        PullRefreshIndicator(
            viewModel.isRefreshing, 
            pullRefreshState, 
            Modifier.align(Alignment.TopCenter))
    }
}
```

### Data Mapper
`Article` is the domain data used by the UI layer. `ArticleEntity` is the local database data and `ArticleFeed` is the remote data in the data layer. The following Kotlin's [extension functions](https://vtsen.hashnode.dev/complete-c-to-kotlin-syntax-comparisons#heading-extension-methods-functions) are used to implement this data mapping / transformation:
- `ArticleFeed.asArticleEntity()`
- `ArticleEnitty.asArticle()`
- `Article.asArticleEntity()`

![Simple_RSS_Feed_Reader_Data_Mapper.drawio.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1660903643454/sB2LCezq9.png)

To store `ArticleFeed` into the `ArticlesDatabase`(single source of truth), `ArticleFeed` is required to be converted or mapped to `ArticleEntity` first.
 
To display the `Article` from `ArticlesDatabse`,  `ArticleEntity` is required to be converted or mapped to `Article` first.

To update the `ArticlesDatabase` (e.g. bookmark the article), `Article` is required to be converted or mapped to the `ArticleEntity` first.

This is `asArticle()` extension function as an example (which also includes the `List<ArticleEntity>` -> `List<Article>` transformation):

```
fun List<ArticleEntity>.asArticles() : List<Article> {
    return map { articleEntity ->
        articleEntity.asArticle()
    }
}

fun ArticleEntity.asArticle(): Article {
    return Article(
        id = id,
        title = title,
        link = link,
        author = author,
        pubDate = pubDate,
        image = image,
        bookmarked = bookmarked,
        read = read,

        feedTitle = feedTitle,
    )
}
```
### Splash Screen

> **[Updated - Jan 29, 2023]:** Added this splash screen implementation into this app.

- [Implement Default Splash Screen Prior to Android 12](https://vtsen.hashnode.dev/implement-default-splash-screen-prior-to-android-12)

### WorkManager and Notification

> **\[Updated - Feb 11, 2023\]:** Implemented a background task using WorkManager to synch the latest articles and post a notification when new articles arrived.

These are the high-level steps to schedule the work request that can be done in `onCreate()` in your `Application()`

1. Set the work constraints (required internet connection)
    
2. Create the periodic work request (that runs every 24 hours)
    
3. Enqueue a periodic work request using WorkManager
    

```kotlin
class AndroidNewsApplication: Application() {

    override fun onCreate() {
        super.onCreate()
        // (1)
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()

        // (2)
        val syncWorkRequest = PeriodicWorkRequestBuilder<SyncWorker>(
            24, 
            TimeUnit.HOURS
        )
            .setConstraints(constraints)
            .build()

        // (3)
        val workManager = WorkManager.getInstance(this)
        workManager.enqueueUniquePeriodicWork(
            "SyncWorker",
            ExistingPeriodicWorkPolicy.REPLACE,
            syncWorkRequest)
    }
}
```

For a more detailed example, you can refer to the following article.
- [Simple Example to Use WorkManager and Notification](https://vtsen.hashnode.dev/simple-example-to-use-workmanager-and-notification#heading-set-work-constraints)


## Folder Structure
The high-level folder structure looks like this, which is organized by layer. 

![Simple_RSS_Feed_Reader_Jetpack_Compose_01.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1660904718761/NZ8VhuWF-.png)

Since this is a simple app, organizing by layer makes sense to me. For more details about organizing Android package folder structure, refer to this article.

- [How to Organize Android Package / Folder Structure?](https://vtsen.hashnode.dev/how-to-organize-android-package-folder-structure)

## Unit and Instrumented Tests

I did not write a lot of testing here. The unit test simply checks all articles in `MainViewModel` are not null.

```kotlin
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class HomeViewModelTest {

    private lateinit var viewModel: AllArticlesViewModel

    @get:Rule
    var instantExecutorRule = InstantTaskExecutorRule()

    @Before
    fun setupViewModel() {
        val repository = FakeArticlesRepositoryImpl()
        viewModel = AllArticlesViewModel(repository)
    }

    @Test
    fun allArticles_areNotNull() = runTest {

        Assert.assertNotEquals(null, viewModel.articles.first())

        delay(1000)
        Assert.assertNotEquals(null, viewModel.articles)
    }
}
```

> `FakeArticlesRepositoryImpl` implementation can be found [here](https://github.com/vinchamp77/AndroidNews/blob/master/app/src/main/java/vtsen/hashnode/dev/androidnews/data/repository/FakeArticlesRepositoryImpl.kt).

For the instrumented test, I just checked the package name and the bottom navigation names.

```kotlin
@RunWith(AndroidJUnit4::class)
class AppContextTest {
    @Test
    fun useAppContext() {
        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
        assertEquals("vtsen.hashnode.dev.androidnews", appContext.packageName)
    }
}
```

```kotlin
class ComposeTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Test
    fun bottomNavigationNames_areValid() {
        var text = composeTestRule.activity.getString(R.string.home)
        composeTestRule.onNodeWithText(text).assertExists()

        text = composeTestRule.activity.getString(R.string.unread_articles)
        composeTestRule.onNodeWithText(text).assertExists()

        text = composeTestRule.activity.getString(R.string.bookmarks)
        composeTestRule.onNodeWithText(text).assertExists()
    }
}
```

## Future Work
One mistake I made is naming conversion of a composable function, that I didn't start with a noun.  This is quoted from [Compose API guidelines](https://github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md)

> `@Composable` annotation using `PascalCase`, and the name MUST be that of a noun, not a verb or verb phrase, nor a nouned preposition, adjective or adverb. Nouns MAY be prefixed by descriptive adjectives.

For example, `BuildNavGraph()` should be renamed to `NavGraph()`. It is a component / widget, not an action. It shouldn't start with a verb `BuildXxx`.

I also tried to convert the `MainViewModel` to use hilt dependency inject. I documented the steps I did in this article:

- [Convert View Model to Use Hilt Dependency Injection](https://vtsen.hashnode.dev/convert-view-model-to-use-hilt-dependency-injection)

Since this is my first Jetpack Compose app, I'm sure there is room for improvement. All the potential enhancements that can be done for this app is documented in the GitHub's issues [here](https://github.com/vinchamp77/AndroidNews/issues).

Maybe you can download and install the app and let me know any feedbacks?
[![google-play-badge.png](https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png)](https://play.google.com/store/apps/details?id=vtsen.hashnode.dev.androidnews)

## Source Code
GitHub Repository:
* [Android News(master branch)](https://github.com/vinchamp77/AndroidNews) - latest development
    
* [Android News(master\_org branch](https://github.com/vinchamp77/AndroidNews/tree/master_org)) - original implementation which is no longer updated

---

*Originally published at [https://vtsen.hashnode.dev](https://vtsen.hashnode.dev/simple-rss-feed-reader-jetpack-compose)*.