Simple RSS Feed Reader - Jetpack Compose

vtsen

Vincent Tsen

Posted on September 24, 2022

Simple RSS Feed Reader - Jetpack Compose

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 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

High-level Architecture

This is the high-level architecture design which is based on MVVM / recommended Android app architecture.

Simple_RSS_Feed_Reader_Jetpack_Compose.drawio.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 composable function.

@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)
    }
    /*...*/
}
Enter fullscreen mode Exit fullscreen mode

Navigation Graph

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

The screen navigation back stack looks like this.

Simple_RSS_Feed_Reader_Navigation_Backstack.drawio.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.

@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)
                    }
                }
            }
        },
        /*...*/
    )
}
Enter fullscreen mode Exit fullscreen mode

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

Image Loading

For image loading, I used the rememberImagePainter() composable function from the coil image loading library.

@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)
    )
}
Enter fullscreen mode Exit fullscreen mode

coil is the only image loading libary that supports Jetpack Compose as far as I know

There is this 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 library, which is the multiplatform asynchronous HTTP client. The implementation is super simple here.

class WebService {

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

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

However, it is not a direct comparison, as this usage is pretty straightforward. It doesn't use 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.

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() ?: ""
    }
}
Enter fullscreen mode Exit fullscreen mode

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 library. FeedPaser.parse() is the high-level implementation. It converts the XML string to List<ArticleFeed>.

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
    }
    /*...*/
}
Enter fullscreen mode Exit fullscreen mode

Local SQLite Database

I used the Room database 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 below.

object DatabaseConstants {
    const val ARTICLE_TABLE_NAME = "article"
}
Enter fullscreen mode Exit fullscreen mode

Then, I use it in ArticleEntity

@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,
)
Enter fullscreen mode Exit fullscreen mode

and also in ArticlesDao interface.

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

    /*...*/
}
Enter fullscreen mode Exit fullscreen mode

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.

@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}'")
    }
    /*...*/
}
Enter fullscreen mode Exit fullscreen mode

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. I just need to wrap it inside the AndroidView composable function.

@Composable
private fun ArticleWebView(url: String) {

    if (url.isEmpty()) {
        return
    }

    Column {

        AndroidView(factory = {
            WebView(it).apply {
                webViewClient = WebViewClient()
                loadUrl(url)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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 library from Accompanist to call MainViewModel.refresh() when you swipe down the screen.

@Composable
fun ArticlesScreen() {
    /*...*/
    SwipeRefresh(
        state = rememberSwipeRefreshState(viewModel.isRefreshing),
        onRefresh = { viewModel.refresh() }
    ) {
        /*..*/
    }
}
Enter fullscreen mode Exit fullscreen mode

[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.

@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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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 are used to implement this data mapping / transformation:

  • ArticleFeed.asArticleEntity()
  • ArticleEnitty.asArticle()
  • Article.asArticleEntity()

Simple_RSS_Feed_Reader_Data_Mapper.drawio.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,
    )
}
Enter fullscreen mode Exit fullscreen mode

Splash Screen

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

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

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

For a more detailed example, you can refer to the following article.

Folder Structure

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

Simple_RSS_Feed_Reader_Jetpack_Compose_01.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.

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.

@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)
    }
}
Enter fullscreen mode Exit fullscreen mode

FakeArticlesRepositoryImpl implementation can be found here.

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

@RunWith(AndroidJUnit4::class)
class AppContextTest {
    @Test
    fun useAppContext() {
        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
        assertEquals("vtsen.hashnode.dev.androidnews", appContext.packageName)
    }
}
Enter fullscreen mode Exit fullscreen mode
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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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

@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:

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.

Maybe you can download and install the app and let me know any feedbacks?
google-play-badge.png

Source Code

GitHub Repository:


Originally published at https://vtsen.hashnode.dev.

💖 💪 🙅 🚩
vtsen
Vincent Tsen

Posted on September 24, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Simple RSS Feed Reader - Jetpack Compose
android Simple RSS Feed Reader - Jetpack Compose

September 24, 2022