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
.
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.
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
Now you are ready to write the code to call your API.
In package com.automacorp.service
create a new interface called RoomsApiService
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
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))
}
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 :
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
To resolve the problem we have to understand the next chapters
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.
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.
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
.
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
We can now adapt the code used in RoomListActivity
to load the room list.
Open com.automacorp.RoomListActivity
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.
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.
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
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.
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.