Android Stock App: Retrofit with Dagger-Hilt, Kotlin Coroutines, and LiveData
Retrofit is a great Android library to construct communication between mobile devices and Restful API. In this project, I will use the free stock API, Tiingo API, as a test server. Tiingo is one of the best API service suppliers to check the Stock Exchange. Don’t you want to play stock under your command? You can register a free account before practice on this project.
After you have an API token, you can follow me to implement Hilt; inject API service, and show stock information on your phone.
— ===MeNu=== —
🔗1. Gradel Dependencies
🔑2. Test the API Token
💊3. API key and API Interface
🔪4. Dagger and Retrofit
⛲5. Simple UI with progressBar
⬅️6. Application, Repository, ViewModel, Activity
⛅7. Tests: Failures and Success
🔗1. Gradel Dependencies
=> Menu
You can get more info about Retrofit from square.github.io. Here is my Gradle project:
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = "1.4.0"
repositories {
google()
jcenter()
maven { url "https://oss.jfrog.org/libs-snapshot" }
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.0-alpha07'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
}
}allprojects {
repositories {
google()
jcenter()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
ext {
// UI
appCompat_version = '1.2.0'
recyclerView_version = '1.1.0'
cardView_version = '1.0.0'
constraintLayout_version = '1.1.3'
legacySupport_version = '1.0.0'
material_version = '1.2.0'
// test
espresso_version = '3.2.0'
junit_version = '4.13'
androidx_test_version = '1.2.0'
androidx_coretest_version = '2.0.0-alpha1'
// jetpack
lifecycle_version = '2.2.0'
lifecycleExtensions_version = '2.2.0'
room_version = '2.2.5'
savedState_version = '2.2.0'
// kotlin
kotlinCore = '1.3.1'
kotlinVM = '2.3.0-alpha06'
activity_version = '1.2.0-alpha07'
fragment_version = '1.3.0-alpha07'
coroutines_version = '1.3.7'
// mockito
mockito_version = '3.3.1'
// dagger hilt
dagger_version = '2.27'
hilt_jetpack_version = '1.0.0-alpha02'
hilt_version = '2.28-alpha'
// retrofit
retrofit_version = '2.7.2'
okhttp_version = '4.0.0'
gson_version = '2.8.5'
converter_version = '2.7.2'
}
Gradle app module:
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-android-extensions'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}android {
compileSdkVersion 29
buildToolsVersion "30.0.1"
defaultConfig {
applicationId "com.homan.huang.stockrestapi"
minSdkVersion 24
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
sourceSets {
String sharedTestDir = 'src/sharedTest/java'
test {
java.srcDir sharedTestDir
}
androidTest {
java.srcDir sharedTestDir
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
// UI
implementation "androidx.appcompat:appcompat:$appCompat_version"
implementation "androidx.constraintlayout:constraintlayout:$constraintLayout_version"
// LifeCycle
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "android.arch.lifecycle:extensions:$lifecycle_version"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
// kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "androidx.core:core-ktx:$kotlinCore"
implementation "androidx.activity:activity-ktx:$activity_version"
implementation "androidx.fragment:fragment-ktx:$fragment_version"
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
// unit test
testImplementation "junit:junit:$junit_version"
testImplementation "org.mockito:mockito-core:$mockito_version"
testImplementation "android.arch.core:core-testing:$androidx_coretest_version"
// app test
androidTestImplementation "androidx.test:runner:$androidx_test_version"
androidTestImplementation "androidx.test:core-ktx:$androidx_test_version"
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
kaptAndroidTest "com.google.dagger:dagger-compiler:$dagger_version"
// Hilt dependencies
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
// Hilt ViewModel extension
implementation "androidx.hilt:hilt-lifecycle-viewmodel:$hilt_jetpack_version"
kapt "androidx.hilt:hilt-compiler:$hilt_jetpack_version"
// Hilt testing dependencies
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
kaptAndroidTest "androidx.hilt:hilt-compiler:$hilt_jetpack_version"
// Retrofit
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.google.code.gson:gson:$gson_version"
implementation "com.squareup.retrofit2:converter-gson:$converter_version"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version"
}
Open a new project, you need to backup your applicationId in a text document; copy and paste upper contents to your Gradle files; restore the applicationId, and sync.
🔑2. Test the API Token
=> Menu
After you activate your account, please check “1.2 Connecting” to discover your API token.
The test method is simple: server address+token.
https://api.tiingo.com/api/test?token=YOUR_API_TOKEN
This primitive method use API. We can use Curl to connect the API on the terminal.
If you don’t have Curl on your Windows, please install it. If you are using Linux or Macs, please continue.
Here is the curl command: curl -v address -H “Http Header”
D:\Android\StockRestApi>curl -v "https://api.tiingo.com/api/test?token= YOUR API TOKEN " ^
More? -H "Content-Type: application/json"
* ...
{"message": "You successfully sent a request"}* Connection #0 to host api.tiingo.com left intact
The JSON message proves your token is working.
Bad token:
D:\Android\StockRestApi>curl -v "https://api.tiingo.com/api/test?token= BAD API TOKEN " ^
More? -H "Content-Type: application/json"
* ...
{"message": "Auth Token was not correct"}* Connection #0 to host api.tiingo.com left intact
Single quotes are using around Http Header,
{"message": "You did not set the content type to 'application/json'"}* Connection #0 to host api.tiingo.com left intact
* Could not resolve host: application
* Closing connection 1
curl: (6) Could not resolve host: application
Hide API Token in the Header
You can hide the token inside the Http Header. Python,
headers = {
'Content-Type': 'application/json',
'Authorization' : 'Token YOUR TOKEN'
// ^space
}
We can use Curl w/. two “-H” arguements.
D:\Android\StockRestApi>curl -v "https://api.tiingo.com/api/test" ^
More? -H "Content-Type: application/json" ^
More? -H "Authorization: Token YOUR TOKEN"
* Trying 147.75.105.211:443...
<
{"message": "You successfully sent a request"}* Connection #0 to host api.tiingo.com left intact
I will choose the second method to hide the token.
Now, we know the token is working. Let’s save it in the “secret” package, class as RestConfig.kt.
class RestConfig {
companion object {
const val DEBUG = true
const val TEST_END = "/api/test"
const val API_TOKEN = "YOUR TOKEN"
const val API_SERVER = "api.tiingo.com"
}
}
💊3. API key and API Interface
=> Menu
JK Plugin For Data Class
Let’s get a plugin to help us generate data class, called “JSON To Kotlin Class”.
File => Settings => Plugins => “JSON” (Search) => JK: Install
It’s easy to create a data class by JK. I create a “data” package to store data class.
Copy and paste the JSON content into JK; generate. Then you have a fresh Kotlin data class.
The data class will map the value from return JSON.
API Service
Some coders are calling HTTP commands of RestApi as API Interface or API Service. In C, I call it as API Header. They are the same thing.
Let’s create a “network” package, and use Message data class as our return data in ApiService.kt.
interface ApiService {
@Headers(
"Content-Type: application/json",
"Authorization: Token ${RestConfig.API_TOKEN}"
)
@GET(RestConfig.TEST_END)
suspend fun getTestMessage(): Message
}
@Header is convenient than the older version of Retrofit.
@Get is getting data. The parameter is the path of an endpoint. Here is RestInfo.TEST_END = “/api/test”. To send your data to server, Retrofit will use @Post. You can check detail from https://square.github.io/retrofit/.
The suspend function is the specialty of coroutines, acts as a shadow man waiting for the summons.
Add ApiServiceHelper.kt, at “network” package. Don’t be confused with ApiService. This helper serves for regular function instead of an API call.
// Wrapped with Resource Type to offer:
// loading, success and error
interface ApiServiceHelper {
suspend fun getTestMsg(): Message
}
You need to inject the ApiService instance to get the data into the Helper.
ApiServiceHelperImpl.kt, at the “network” package.
@Singleton
class ApiServiceHelperImpl @Inject constructor(
private val apiService: ApiService
) : ApiServiceHelper {
override suspend fun getTestMsg() = apiService.getTestMessage()
}
🔪4. Dagger and Retrofit
=> Menu
ApiService serves for API Client. You may have caught the code like this.
fun getApiClient(): Retrofit {
val gson = GsonBuilder()
.setLenient()
.create()
val okHttpClient = OkHttpClient.Builder()
.readTimeout(100, TimeUnit.SECONDS)
.connectTimeout(100, TimeUnit.SECONDS)
.build()
if (retrofit == null) {
retrofit = Retrofit.Builder()
.baseUrl(RestInfo.API_SERVER)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
return retrofit!!
}
Dagger can help us to inject the instance of Retrofit. It usually provides those instances of Retrofit in Module, like AppModule.kt, ApplicationModule.kt, or ApiModule.kt at the “dagger” package. I like the short version.
@Module
@InstallIn(ApplicationComponent::class)
class AppModule {}
Let’s fill in the codes:
Provide Base URL
@Provides
fun provideBaseUrl() = RestConfig.API_SERVER
Provide GSON
@Provides
fun provideGson(): Gson = GsonBuilder().setLenient().create()
Provide OKHttp
@Provides
@Singleton
fun provideOkHttpClient() =
if (RestConfig.DEBUG) { // debug ON
val logger = HttpLoggingInterceptor()
logger.level = HttpLoggingInterceptor.Level.BODY
OkHttpClient.Builder()
.addInterceptor(logger)
.readTimeout(100, TimeUnit.SECONDS)
.connectTimeout(100, TimeUnit.SECONDS)
.build()
} else // debug OFF
OkHttpClient.Builder()
.readTimeout(100, TimeUnit.SECONDS)
.connectTimeout(100, TimeUnit.SECONDS)
.build()
Provide Retrofit
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, BaseURL: String): Retrofit =
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
You can verify the link onsite by clicking Hilt anchors.
Click them, have fun!
Provide API Service
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit) =
retrofit.create(ApiService::class.java)
Provide ApiServiceHelper
AppModule.kt, at the “dagger” package. Let’s provide ApiServiceHelper.
@Provides
@Singleton
fun provideApiServiceHelper(apiHelper: ApiServiceHelperImpl):
ApiServiceHelper = apiHelper
Smart Wrapper: Status+Resource(API Service)
You can wrap API with an ENUM class, Status.kt, at the “utils” package.
enum class Status { SUCCESS, ERROR, LOADING }
Resource.kt, at the “utils” package.
// wrap data with status and message
data class Resource<out T>(val status: Status, val data: T?, val message: String?) {
companion object {
fun <T> success(data: T): Resource<T> =
Resource(
status = Status.SUCCESS,
data = data,
message = null)
fun <T> error(data: T?, message: String): Resource<T> =
Resource(
status = Status.ERROR,
data = data,
message = message)
fun <T> loading(data: T?): Resource<T> =
Resource(
status = Status.LOADING,
data = data,
message = null)
}
}
Now you can wrap any type of data, for example, Resource<Message>.
⛲5. Simple UI with progressBar
=> Menu
UI: activity_main.xml
Let’s keep the UI simple: add an Id to TextView and progressBar.
<TextView
android:id="@+id/message_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="Hello World!"
android:textColor="@color/black"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" /><ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="200dp"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
The JSON data will send to the message_tv.
⬅️6. Application, Repository, ViewModel, Activity
=> Menu
MVVM Path
The path of MVVM data flow: Activity => ViewModel => Repository => NetworkSource => UpdateObserver
You sure need to understand that is one way trip to avoid the data leakage on the UI layer.
Application: StockApplication.kt
You need to install the application to make Dagger work. This is StockApplication.kt at the “application” package.
@HiltAndroidApp
class StockApplication : Application()
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.homan.huang.stockrestapi">
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission
android:name="android.permission.INTERNET"/>
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:name=".appliciation.StockApplication"
android:allowBackup="true"
...
My code will be outdate because WRITE_EXTERNAL_STROAGE will not work on Android 11.
Repository: StockRepository.kt
@Singleton
class StockRepository @Inject constructor(
private val apiServiceHelper: ApiServiceHelper
) {
suspend fun getTestMsg(): Message {
delaySimulation() // simulate slow network
val msg = apiServiceHelper.getTestMsg()
return msg
}
private fun delaySimulation() {
try {
Thread.sleep(DELAY_MS)
} catch (ignored: InterruptedException) {
}
}
companion object {
const val DELAY_MS = 3000L
}
}
I add a delay to simulate a slow network. You can track the injection path by clicking the Hilt anchors.
StockRepository <= provideApiServiceHelper() <= ApiServiceHelperImpl <= provideApiService()
NetworkHelper: Network Status
NetworkHelper.kt: Check the network status, at the “network” package.
@Singleton
class NetworkHelper @Inject constructor(
@ApplicationContext private val context: Context
) {
fun isNetworkConnected(): Boolean {
val connectivityManager =
context.getSystemService(
Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val networkCapabilities =
connectivityManager.activeNetwork ?: return false
val activeNetwork =
connectivityManager.getNetworkCapabilities(
networkCapabilities) ?: return false
return when {
activeNetwork.hasTransport(
NetworkCapabilities.TRANSPORT_WIFI) -> true
activeNetwork.hasTransport(
NetworkCapabilities.TRANSPORT_CELLULAR) -> true
activeNetwork.hasTransport(
NetworkCapabilities.TRANSPORT_ETHERNET) -> true
else -> false
}
}
}
The ApplicationContext is automatically injected by Hilt.
ViewModel: MainViewModel.kt
MainViewModel.kt at the “main” package (w/. MainActivity),
class MainViewModel @ViewModelInject constructor(
private val stockRepository: StockRepository,
networkHelper: NetworkHelper
) : ViewModel() { }
The message is separated into hidden and public.
// cached
private val _message = MutableLiveData<Resource<Message>>()
// public
val message: LiveData<Resource<Message>> = _message
Initial with network status,
init {
_message.postValue(Resource.loading(null))
if (!networkHelper.isNetworkConnected()) {
fetchMessage()
} else {
_message.postValue(
Resource.error(
data = null,
message = internetErr))
}
}companion object {
private val TAG = "MYLOG MainVM"
const val internetErr = "Network is down.\n" +
"Please check\n" +
"your INTERNET connection!"
const val otherErr = "Error Occurred!"
fun lgd(s: String) = Log.d(TAG, s)
fun lge(s: String) = Log.e(TAG, s)
fun lgi(s: String) = Log.i(TAG, s)
}
Don’t worry, I make the Network fail at the beginning.
Fetch data from the repository,
private fun fetchMessage() {
viewModelScope.launch {
try {
_message.value = Resource.success(
data = stockRepository.getTestMsg())
} catch (exception: Exception) {
_message.value = Resource.error(
data = null,
message = exception.message ?: otherErr
)
}
}
}
Please remember, the suspend functions only work under the scope of coroutines.
MainActivity.kt: Observe LiveData
The MainActivity.kt will move to the “main” package.
Global Vars and Header:
private const val REQUEST_CODE_PERMISSIONS = 1111
private val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.INTERNET,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_NETWORK_STATE
)
@AndroidEntryPoint // Hilt
class MainActivity : AppCompatActivity() { }
Companion:
companion object {
private val TAG = "MYLOG " + MainActivity::class.java.simpleName
fun lgd(s: String) = Log.d(TAG, s)
fun lge(s: String) = Log.e(TAG, s)
fun lgi(s: String) = Log.i(TAG, s)
// len: 0=>short; 1=>long
fun msg(context: Context, s: String, len: Int) =
if (len == 0)
Toast.makeText(context, s, Toast.LENGTH_SHORT).show()
else
Toast.makeText(context, s, Toast.LENGTH_LONG).show()
const val INTERNET = "INTERNET"
}
Permissions:
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(
requestCode,
permissions,
grantResults)
if (requestCode == REQUEST_CODE_PERMISSIONS) {
if (allPermissionsGranted()) {
// TODO something
} else {
msg(this, "Permissions not granted by the user.", 0)
finish()
}
}
}
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(baseContext, it) ==
PackageManager.PERMISSION_GRANTED
}
Observer:
fun setupObserver() {
mainViewModel.message.observe(this, Observer {
when (it.status) {
Status.SUCCESS -> {
message_tv.text = it.data!!.message
progressBar.visibility = View.GONE
}
Status.LOADING -> {
progressBar.visibility = View.VISIBLE
}
Status.ERROR -> {
progressBar.visibility = View.GONE
val tempStr = it.message
// internet error
val foundIdx = tempStr?.indexOf(INTERNET, 0)!!
if (foundIdx > 0) {
val spStr = SpannableString(tempStr)
val color = Color.RED
spStr.setSpan(ForegroundColorSpan(color),
foundIdx,
foundIdx+8,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
message_tv.text = spStr
} else { // other error
message_tv.text = tempStr
}
msg(this, it.message.toString(), 1)
}
}
})
}
It observes the message in MainViewModel as the observable. Let’s call the function.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setupObserver()
}
⛅7. Tests: Failures and Success
=> Menu
Let’s run some raw tests. I am lazy. You shall write some Test cases.
Passed!
You need to remove “!” in if statement on MainViewModel.kt to go to the next step.
Passed!
Bad Token test,
Passed!
Bad endpoint test,
Passed!
Bad server address test,
Passed!
Good app test,
Passed!
Enjoy!
===> Part TWO