Dev-Mind

15/08/2024
Android
 

In this lesson, we will see how to call a remote HTTP API with an external library called Retrofit. Retrofit was not done by Google. But when a library created by the community is widely used, well designed, the Android team does not hesitate to encourage its use.

Call an HTTP API with Android

Explore API

If you followed the previous code labs to build a Spring application, you will be able to use your own app. You should have an API to list building rooms and other to load detailed information on a room.

For the moment data are static in com.automacorp.model.RoomService. Now we will update this service to read data stored on a web server, as a REST web service.

You can use your own Spring API if you followed Spring course or my implementation available on https://automacorp.devmind.cleverapps.io/swagger-ui/index.html. This app is secured by basic auth and you can the username user and his password password.

Retrofit

To interact with a remote HTTP API in Android app, your app needs to

  • establish a network connection to remote server which exposes your REST service and

  • communicate with that server, and then

  • receive its response data and

  • parse the data to be usable in your code.

Retrofit was made to do all these steps easily. For the last one, we need a converter to deserialize HTTP body. Several converters are available. We will use Moshi library

The mains goal of Retrofit is to turn your HTTP API into a Java interface. For example

interface RoomsApiService {
    @GET("rooms")
    fun findAll(): Call<List<RoomDto>>

    @GET("rooms/{id}")
    fun findById(@Path("id") id: Long): Call<RoomDto>

    @PUT("rooms/{id}")
    fun updateRoom(@Path("id") id: Long, @Body room: RoomCommandDto): Call<RoomDto>

    //...
}

Annotations (GET, POST, PUT, DELETE,…​) on the interface methods and its parameters indicate how a request will be handled.

A request URL can be updated dynamically using replacement blocks and parameters on the method. A replacement block is an alphanumeric string surrounded by { and }.

You can bind a parameter in path

@GET("rooms/{id}")
fun findById(@Path("id") id: Long): Call<RoomDto>

or a parameter in query

@GET("rooms")
fun findAll(@Query("sort") sort: String): Call<List<RoomDto>>

An object can be specified for POST or PUT HTTP requests @Body annotation. In this case, Retrofit will use converter defined in your conf to serialize body object in JSON

@PUT("rooms/{id}")
fun updateRoom(@Path("id") id: Long, @Body room: RoomCommandDto): Call<RoomDto>

In my example RoomCommandDto is different than RoomDto. If you use my remote API available on on https://automacorp.devmind.cleverapps.io you could define these objects in your code

data class RoomDto(
    val id: Long,
    val name: String,
    val currentTemperature: Double?,
    val targetTemperature: Double?,
    val windows: List<WindowDto>
)

data class RoomCommandDto(
    val name: String,
    val currentTemperature: Double?,
    val targetTemperature: Double?,
    val floor: Int = 1,
    // Set to the default building ID (useful when you have not created screens to manage buildings)
    val buildingId: Long = -10
)

These 2 objects are 2 projections of a Room: one for the read, one for the update. You will find more information on Retrofit website

It is the time to test by yourself.

: Configure Retrofit

As I said we need to install Retrofit to call a remote API and we also need another library to serialize/deserialize our Kotlin objects in/from JSON.

Android project use now the Gradle catalog version. Open the file libs.versions.toml. This file register all versions of libraries used in your project. You can add a new line to register the version of Retrofit and Moshi

Each section are defined by [] and the name of the section.

In the section [versions] you can add the version of Retrofit and Moshi

retrofit = "2.9.0"

In the section [libraries] you can add the dependency of Retrofit and Moshi

retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }

Now open build.gradle.kts (Module: automacorp.app). and add the following dependencies

implementation (libs.retrofit)
implementation (libs.converter.moshi)

As you updated your gradle configuration, Android Studio display a message to synchronize your projet. Click on Sync now

Sync Gradle project

Now you are ready to write the code to call your API.

  1. In package com.automacorp.service create a new interface called RoomsApiService

  2. You can apply the examples given above. In this interface we declare methods used to launch a remote call to

    • read all rooms

    • read one room by its id

    • update a room

    • create a room

    • delete a room by its id

  3. We need to create an implementation of this interface. This implementation will be created by the Retrofit Builder. In package com.automacorp.service create a new class called ApiServices. This class will use a Retrofit builder to return an instance of interface RoomsApiService

    object ApiServices {
        val roomsApiService : RoomsApiService by lazy {
            Retrofit.Builder()
                    .addConverterFactory(MoshiConverterFactory.create()) // (1)
                    .baseUrl("http://automacorp.devmind.cleverapps.io/api/") // (2)
                    .build()
                    .create(RoomsApiService::class.java)
        }
    }

    (1) a converter factory to tell Retrofit what do with the data it gets back from the web service.

    (2) an URL of the remote service (In this example I use an URL on my website but you can use your own API)

When an API is secured by a basic authentication, we need to adapt the settings. For that we can add 2 constant in object ApiServices

const val API_USERNAME = "user"
const val API_PASSWORD = "password"

As often, when we have to manage credential in an HTTP request, we will create an interceptor to intercept the outgoing requests and add the authentication credential inside.

class BasicAuthInterceptor(val username: String, val password: String): Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain
            .request()
            .newBuilder()
            .header("Authorization", Credentials.basic(username, password))
            .build()
        return chain.proceed(request)
    }
}

When your interceptor is created, you can adapt the Retrofit builder.

val roomsApiService : RoomsApiService by lazy {
    val client = OkHttpClient.Builder()
            .addInterceptor(BasicAuthInterceptor(API_USERNAME, API_PASSWORD))
            .build()

    Retrofit.Builder()
        .addConverterFactory(MoshiConverterFactory.create())
        .client(client)
        .baseUrl("https://automacorp.devmind.cleverapps.io/api/")
        .build()
        .create(RoomsApiService::class.java)
}

If your application is served over HTTPS (this is the default on Clever Cloud), you also need to customize the OkHttpClient. In the real life we use a real certificate. In our dev we just check the hostname of our remote server

val roomsApiService : RoomsApiService by lazy {
    val client = getUnsafeOkHttpClient()
            .addInterceptor(BasicAuthInterceptor(API_USERNAME, API_PASSWORD))
            .build()

    // ...
}

private fun getUnsafeOkHttpClient(): OkHttpClient.Builder =
  OkHttpClient.Builder().apply {
      val trustManager = object : X509TrustManager {
          @Throws(CertificateException::class)
          override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
          }

          @Throws(CertificateException::class)
          override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
          }

          override fun getAcceptedIssuers(): Array<X509Certificate> {
              return arrayOf()
          }
      }
      val sslContext = SSLContext.getInstance("SSL").also {
          it.init(null, arrayOf(trustManager), SecureRandom())
      }
      sslSocketFactory(sslContext.socketFactory, trustManager)
      hostnameVerifier { hostname, _ -> hostname.contains("cleverapps.io") }
      addInterceptor(BasicAuthInterceptor(API_USERNAME, API_PASSWORD))
  }

: Use Retrofit

We can now adapt our code to use this API when we want to display the room list. In RoomListActivity, you can move the code to display the list of rooms in a new Composable function called RoomList.

@Composable
fun RoomList(
    rooms: List<RoomDto>,
    navigateBack: () -> Unit,
    openRoom: (id: Long) -> Unit
) {
    AutomacorpTheme {
        Scaffold(
            topBar = { AutomacorpTopAppBar("Rooms", navigateBack) }
        ) { innerPadding ->
            if (rooms.isEmpty()) {
                Text(
                    text = "No room found",
                    modifier = Modifier.padding(innerPadding)
                )
            } else {
                LazyColumn(
                    contentPadding = PaddingValues(4.dp),
                    verticalArrangement = Arrangement.spacedBy(8.dp),
                    modifier = Modifier.padding(innerPadding),
                ) {
                    items(rooms, key = { it.id }) {
                        RoomItem(
                            room = it,
                            modifier = Modifier.clickable { openRoom(it.id) },
                        )
                    }
                }
            }
        }
    }
}

Adapt the RoomListActivity to use the RoomList composable. Now you can update the onCreate function in the activity. We don’t want to use the RoomService.findAll() to load the list of rooms. We will use the roomsApiService object to call the remote API.

override fun onCreate(savedInstanceState: Bundle?) {
  // ... existing code

  runCatching { // (1)
    ApiServices.roomsApiService.findAll().execute()  // (2)
  }
            .onSuccess { // (3)
                val rooms = it.body() ?: emptyList()
                // Display the component with the list on room
                setContent {
                    RoomList(rooms, navigateBack, openRoom)
                }
            }
            .onFailure {
                setContent {
                    RoomList(emptyList(), navigateBack, openRoom)
                }
                it.printStackTrace() // (4)
                Toast.makeText(this, "Error on rooms loading $it", Toast.LENGTH_LONG).show() // (5)
            }
  // ...
}
  • (1) we use runCatching to manage successes and failures. This block is like a try/catch block in Java

  • (2) ApiServices.roomsApiService return an implementation of our object written to call a remote API. We call the method execute to run a synchronous call

  • (3) On success we update adapter with the result contained in body property. If this response is null the list is empty.

  • (4) We use this line to have the real stack trace in your device log file

  • (5) on error we display a message in a Toast notation

Run your app to see the changes when and open the room list.

Unfortunately you should have a toast notification with the following error message :

Network error

To analyse the errors you can open the LogCat tab and filter on Error level. In my example below, we can see the same error

Logger

To resolve the problem we have to understand the next chapters

Main thread

When the system launches your application, that application runs in a thread called Main thread. This main thread manages user interface operations (rendering, events …​), system calls…​

Calling long-running operations from this main thread can lead to freezes and unresponsiveness.

Making a network request on the main thread forces it to wait, or block, until it receives a response.

When the thread is blocked, the OS isn’t able to manage UI events, which causes your app to freeze and potentially leads to an Application Not Responding (ANR) dialog. To avoid these performance issues, Android throws a MainThreadException and kills your app if you try to block this main thread.

Main thread

The solution is to run your network call, your long-running task in another thread, and when the result is available you can reattach the main thread to display the result. Only the main thread can update the interface.

If you develop in Java, Thread development can be difficult. With Kotlin and coroutines, the development is really simple.

Coroutines

A coroutine is a concurrency design pattern that you can use on Android to simplify code that executes asynchronously tasks as an HTTP request. Coroutines help to manage long-running tasks that might otherwise block the main thread and cause your app to become unresponsive.

In Kotlin, all coroutines run inside a CoroutineScope. A scope controls the lifetime of coroutines through its job. When you cancel the job of a scope, it cancels all coroutines started in that scope.

On Android, you can use a scope to cancel all running coroutines when, for example, the user navigates away from an Activity or Fragment. Scopes also allow you to specify a default dispatcher. A dispatcher controls which thread runs a coroutine.

Each object in Android which has a lifecycle (Activity, Fragment…​), has a CoroutineScope.

: Use coroutines to resolve main thread error

We need to add the coroutine library in your project. The dependency should be already present

Open build.gradle.kts (Module: automacorp.app) to check the presence of the following dependency (in dependencies block)

implementation(libs.androidx.lifecycle.runtime.ktx)

Android Studio display a message to synchronize your projet. Click on Sync now

Sync Gradle project

We can now adapt the code used in RoomListActivity to load the room list.

  1. Open com.automacorp.RoomListActivity

  2. Update code to call roomsApiService as follows

    lifecycleScope.launch(context = Dispatchers.IO) { // (1)
        runCatching { ApiServices.roomsApiService.findAll().execute() }
            .onSuccess {
                val rooms = it.body() ?: emptyList()
                withContext(context = Dispatchers.Main) { // (2)
                    // setContent ....
                }
            .onFailure {
                withContext(context = Dispatchers.Main) { // (2)
                    // setContent .... and display error
                }
            }
    }
    • (1) method lifecycleScope.launch open a new coroutine. You must specify a context other than Dispatchers. Main (Main thread) for the code to be executed. Dispatchers.IO is dedicated to Input/Output tasks

    • (2) You cant' interact with the view outside the main thread. When we receive the data we use withContext to reattach your code to another thread

Relaunch your app to test your Room list screen.

Unfortunately you should have another toast notification with another error message. The error message tells you that your app might be missing the INTERNET permission.

Android permission error

Android permission

The purpose of a permission is to protect the privacy of an Android user. Android apps must request permission to access sensitive user data or features such as contacts, SMS, Internet…​ Depending on the feature, the system might grant the permission automatically or might prompt the user to approve the request.

By default, an app has no permission to perform any operations that would adversely impact other apps, the operating system or the user.

To add a new permission we need to update the AndroidManifest.xml file (ie the id card of your app)

In the following example I add the INTERNET permission <uses-permission> tag (just before <application> tag)

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.snazzyapp">

    <uses-permission android:name="android.permission.INTERNET" />
    <application ...
         android:usesCleartextTraffic="true">
        ...
    </application>
</manifest>

Each user can accept or reject an app permission request, when this app is installed or when the user update the app settings in the device setting. So generally, you must handle this case and ask the user to reactivate the rights if he wants to use your application. In our case we will not test the authorization and we will consider that the user has accepted this permission.

You can now relaunch your app and you will be able to open the room list without error. For more information about permissions you can read this page.

Use ViewModel in your code

In last labs, we see how to use a ViewModel that can store your app data. The stored data is not lost if the framework destroys and recreates the activities during a configuration change or other events. That’s why it’s better to use a ViewModel

ViewModel state

The first thing is to create an object that will store the room list result or the error if the API call fails.

class RoomList(
    val rooms: List<RoomDto> = emptyList(),
    val error: String? = null
)

We can now update the RoomViewModel to store the result of the API call (a state) in a StateFlow object. StateFlow is a data holder observable flow that emits the current and new state updates. Its value property reflects the current state value. To update state and send it to the flow, assign a new value to the value property of the MutableStateFlow class.

class RoomViewModel : ViewModel() {
    // existing code

    val roomsState = MutableStateFlow(RoomList())
}

You can now add the function to load the room list in the RoomViewModel

class RoomViewModel : ViewModel() {
    // existing code
    //...

    fun findAll() {
        viewModelScope.launch(context = Dispatchers.IO) { // (1)
            runCatching { ApiServices.roomsApiService.findAll().execute() }
                .onSuccess {
                    val rooms = it.body() ?: emptyList()
                    roomsState.value = RoomList(rooms) // (2)
                }
                .onFailure {
                    it.printStackTrace()
                    roomsState.value = RoomList(emptyList(), it.stackTraceToString() ) // (3)
                }
        }
    }
}
  • (1) method viewModelScope.launch open a new coroutine to launch the API call in another thread

  • (2) Update the roomsState object with the result of the API call when everything is OK.

  • (3) If an error occurs, we update the roomsState object with an empty list and the error message

The last step is to update the RoomListActivity to use the RoomViewModel to load the room list.

class RoomListActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        val viewModel: RoomViewModel by viewModels()

        // existing code to manage the back button and the RoomItem click to open a room detail
        // ...

        setContent {
            val roomsState by viewModel.roomsState.asStateFlow().collectAsState() // (1)
            LaunchedEffect(Unit) { // (2)
                viewModel.findAll()
            }
            if (roomsState.error != null) {
                setContent {
                    RoomList(emptyList(), navigateBack, openRoom)
                }
                Toast
                    .makeText(applicationContext, "Error on rooms loading ${roomsState.error}", Toast.LENGTH_LONG)
                    .show() // (3)
            } else {
                RoomList(roomsState.rooms, navigateBack, openRoom) // (4)
            }
        }
    }
}
  • (1) We use the asStateFlow extension function to convert the roomsState object to a StateFlow object. We can now use the collectAsState function to observe the StateFlow object and update the UI when the value of the StateFlow object changes.

  • (2) LaunchedEffect: run suspend functions (function executed in coroutine) in the scope of a composable

  • (3) Display a toast notification if an error occurs

  • (4) Display the list of rooms if no error occurs

With this code we have to write less code, manage less coroutine. The activity will subscribe to the roomsState object to display the result, and we don’t need to juggle with the main thread.

On the first display of the screen, we display an empty list of rooms because the findAll function is launched in asynchronous mode (in a coroutine). When the API call is finished, the roomsState object is updated with the result of the API call and the screen is updated.

: Update the screen to display and update the detail

In RoomViewModel we already manage the state of the room detail screen. Add a function to load a room by its id by a remote API call

fun findRoom(id: Long) {
    viewModelScope.launch(context = Dispatchers.IO) {
        runCatching { ApiServices.roomsApiService.findById(id).execute() }
            .onSuccess {
                room = it.body()
            }
            .onFailure {
                it.printStackTrace()
                room = null
            }
    }
}

You can also add a function to update a room by its id by a remote API call

fun updateRoom(id: Long, roomDto: RoomDto) {
      val command = RoomCommandDto(
          name = roomDto.name,
          targetTemperature = roomDto.targetTemperature ?.let { Math.round(it * 10) /10.0 },
          currentTemperature = roomDto.currentTemperature,
      )
      viewModelScope.launch(context = Dispatchers.IO) {
          runCatching { ApiServices.roomsApiService.updateRoom(id, command).execute() }
              .onSuccess {
                  room = it.body()
              }
              .onFailure {
                  it.printStackTrace()
                  room = null
              }
      }
  }

You can now adapt the RoomActivity to use the RoomViewModel to load the room detail and remove the local call to our fake service. After this lab you should use your remote REST Service to load and update the room detail.

Implement the different functions to create a room, delete a room, list the windows of a room, update a window.