Dagger2 / Modules / Muselee

Muselee 7: Top Artists Network

Muselee is a demo app which allows the user to browse popular music artists. It is not intended to be a fully-featured user app, but a vehicle to explore good app architecture, how to implement current best-practice, and explore how the two often go hand in hand. Moreover it will be used to explore how implementing some specific patterns can help to keep our app both maintainable, and easy to extend.

Previously we’ve looked at both the App and Core modules which include infrastructure but no behaviour. Now we’ll start looking at our first feature module which will provide a list of the most popular artists on last.fm. We obtain the list of top artists from the Chart.getTopArtists endpoint which returns a list of the most popular artists. last.fm requires developers to register and use their own API key in requests to this endpoint. For obvious reasons I am not including mine in the source repo, so you’ll need to add your own if you want to build and run the app.

The first thing that we’ll do is add a Network Dagger module for dependency injection:

@Module(includes = [CoreNetworkModule::class])
object NetworkModule {

    @Provides
    @Named("API_KEY")
    @JvmStatic
    internal fun providesApiKey() =
        Interceptor { chain ->
            val newRequest = chain.request().let { request ->
                val newUrl = request.url().newBuilder()
                    .addQueryParameter("api_key", BuildConfig.LAST_FM_APIKEY)
                    .build()
                request.newBuilder()
                    .url(newUrl)
                    .build()
            }
            chain.proceed(newRequest)
        }

    @Provides
    @Named("JSON")
    @JvmStatic
    internal fun providesJson() =
        Interceptor { chain ->
            val newRequest = chain.request().let { request ->
                val newUrl = request.url().newBuilder()
                    .addQueryParameter("format", "json")
                    .build()
                request.newBuilder()
                    .url(newUrl)
                    .build()
            }
            chain.proceed(newRequest)
        }

    @Provides
    @JvmStatic
    internal fun providesOkHttpClient(
        builder: OkHttpClient.Builder,
        @Named("API_KEY") apiKeyInterceptor: Interceptor,
        @Named("JSON") jsonInterceptor: Interceptor
    ): OkHttpClient =
        builder.addInterceptor(apiKeyInterceptor)
            .addInterceptor(jsonInterceptor)
            .build()

    @Provides
    @Singleton
    @JvmStatic
    internal fun providesRetrofit(okHttpClient: OkHttpClient): Retrofit =
        Retrofit.Builder()
            .baseUrl("https://ws.audioscrobbler.com/2.0/")
            .client(okHttpClient)
            .addConverterFactory(MoshiConverterFactory.create())
            .build()

    @Provides
    @JvmStatic
    internal fun providesLastFmTopArtistsApi(retrofit: Retrofit): LastFmTopArtistsApi =
        retrofit.create(LastFmTopArtistsApi::class.java)

    @Provides
    @JvmStatic
    fun testString() = "Hello World!"
}

We’ll leave the final testString() provider in for the moment because we aren’t updating the Fragment which requires is quite yet.

The CoreNetworkModule was added in the Core module, and provided a common OkHttp implementation. We define a new interceptor in providesApiKey() which will add the last.fm API key to all transactions. Then we define another Interceptor in providesJson() to specify that we require JSON data in all responses. We use the Dagger @Named annotation to differentiate between these because they both provide instances the same class. providesOkHttpClient() then uses these interceptors along with the OkHttp.Builder instance provided by CoreNetworkModule to create an OkHttpClient instance. providesRetrofit() uses this OkHttpClient instance to construct a Retrofit instance for the base URL of the last.fm API which includes a MoshiConverterFactory to deserialise the JSON in the response. Finally providesLastFmTopArtistsApi() uses this to construct an instance of LastFmTopArtistsApi:

interface LastFmTopArtistsApi {

    @GET("?method=chart.gettopartists")
    fun getTopArtists(): Call
}

The getTopArtists() method is what we’ll call to retrieve the list of most popular artists, and we can now inject a LastFmTopArtistsApi instance wherever we need it.

For Moshi will deserialise the JSON from the response in to Java / Kotlin objects, and this has been modelled as LastFmArtists which is returned by the getTopArtists()method:

data class LastFmArtists(val artists: ArtistsList)

data class ArtistsList(val artist: List)

data class LastFmArtist(
        val name: String,
        @field:Json(name = "playcount") val playCount: Long,
        val listeners: Long,
        val mbid: String,
        val url: String,
        val streamable: Int,
        @field:Json(name = "image") val images: List
)

data class LastFmImage(
        @field:Json(name = "#text") val url: String,
        val size: String
)

This directly models the JSON returned by the API call.

We really don’t want to use this data model internally because it is defined externally, and it could cause us problems and extra work if last.fm were to change it at any point. In order to keep our app resilient to such changes it is best to convert it to an internal format as soon as we receive it. To do this we’ll implement a DataMapper, which is an interface that we defined in the Core module:

class LastFmArtistsMapper : DataMapper> {

    override fun map(source: LastFmArtists): List =
        source.artists.artist.map { artist ->
            Artist(artist.name, artist.normalisedImages())
        }

    private fun LastFmArtist.normalisedImages() =
        images.map { it.size.toImageSize() to it.url }.toMap()

    private fun String.toImageSize(): ImageSize =
        when (this) {
            "small" -> ImageSize.SMALL
            "medium" -> ImageSize.MEDIUM
            "large" -> ImageSize.LARGE
            "extralarge" -> ImageSize.EXTRA_LARGE
            else -> ImageSize.UNKNOWN
        }

}

This converts a LastFmArtists object to a List<Artist> which is our internal representation of of an ordered list of artists:

data class Artist(val name: String, val images: Map) {

    enum class ImageSize {
        SMALL,
        MEDIUM,
        LARGE,
        EXTRA_LARGE,
        UNKNOWN
    }
}

This distills the data down to only what we need in this feature module and separates the rest of the module from any specifics of last.fm. The LastFmTopArtistsProvider class is how the other components of the module will actually access this:

class LastFmTopArtistsProvider(
    private val topArtistsApi: LastFmTopArtistsApi,
    private val connectivityChecker: ConnectivityChecker,
    private val mapper: DataMapper>
) : DataProvider {

    override fun requestData(callback: (topArtists: TopArtistsState) -> Unit) {
        if (!connectivityChecker.isConnected) {
            callback(TopArtistsState.Error("No network connectivity"))
            return
        }
        callback(TopArtistsState.Loading)
        topArtistsApi.getTopArtists().enqueue(object : Callback {
            override fun onFailure(call: Call, t: Throwable) {
                callback(TopArtistsState.Error(t.localizedMessage))
            }

            override fun onResponse(call: Call, response: Response) {
                response.body()?.also { topArtists ->
                    callback(TopArtistsState.Success(mapper.map(topArtists)))
                }
            }
        })
    }
}

There are a couple of things here which are noteworthy. Firstly this implements the DataProvider interface that we declared in the Core module to return a TopArtistsState object which we’ll look at in a moment. It encapsulates the current status of the request in to three distinct states: Loading, Error, and Success. Initially we use the ConnectivityChecker that is provided by the Core to determine network connectivity, and return an Error state if there is no connectivity. If we have connectivity, then we set the state to Loading and enqueue the network request. In the response callback we set the state to Error if the request failed, or Success if the request succeeded. If successful, we convert the LastFmArtists object to a list of Artist objects, and set this as the payload to the TopArtistsState.Success object. TopArtistsState looks like this:

sealed class TopArtistsState {

    object Loading : TopArtistsState()

    class Success(val artists: List) : TopArtistsState()

    class Error(val message: String) : TopArtistsState()
}

Using a Kotlin sealed class in this manner is a really useful trick for providing different states, with different states holding different payloads. In this case, the Success state holds the retrieved data, whereas the Error state holds a String containing details of the error.

Finally, we need to be able to inject the LastFmTopArtistsProvider in to other components, so we create a Dagger module:

@Module
object LastFmTopArtistsRepositoryModule {

    @Provides
    @JvmStatic
    fun providesTopArtistsProvider(
        lastFmTopArtistsApi: LastFmTopArtistsApi,
        connectivityChecker: ConnectivityChecker,
        mapper: DataMapper>
    ): DataProvider =
        LastFmTopArtistsProvider(
            lastFmTopArtistsApi,
            connectivityChecker,
            mapper
        )

    @Provides
    @JvmStatic
    fun providesLastFmMapper(): DataMapper> =
        LastFmArtistsMapper()
}

It is important to note how we use both the DataProvider and DataMapper interfaces that we defined in the Core module here. Any consumers of these do not need to know about the instances that Dagger will inject (in this case LastFmTopArtistsProvider and LastFmArtistsMapper), instead they just know that they are a DataProvider<TopArtistsState> and DataMapper<LastFmArtists, List<Artist>>. This makes a really clean abstraction from the actual implementations, and using interfaces such as these in conjunction with Dagger is really useful for decoupling the different parts of the module.

Although this code will compile, it doesn’t do much without any UI to show the data retrieved. In the next article in this series we’ll look at the UI components and see how we can hook all of this up to get this feature module working.

The source code for this article is available here.

© 2019, Mark Allison. All rights reserved.

Copyright © 2019 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

2 Comments

  1. Hi Mark, you might want to check the src of the LastFmTopArtistsApi code snippet, looks like it’s not rendered correctly with all those “wp:html” tags.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.