Ejercicio con Retrofit: Repositorios en Kotlin
Obtener repositorios de un usuario en GitHub usando Kotlin
Ejemplo: Tutorial de Retrofit en Kotlin
Crear una aplicación que pida un usuario y obtenga todos sus repositorios en Github
https://api.github.com/users/paco-portada/repos
Se mostrará una lista (usando un RecyclerView) con los nombres, descripciones y fechas de actualización de todos sus repositorios.
Cuando se pulse en un elemento de la lista, se abrirá el navegador para mostrar el repositorio elegido.
¿Esquema de red?
Creación de la aplicación con Android Studio Iguana 2023.2.1: RepositoriosKotlin
– Dar permisos en el manifiesto
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.repositorios"> <uses-permission android:name="android.permission.INTERNET" /> <application android:usesCleartextTraffic="true" </application> </manifest>
– Usar view binding
build.gradle:
buildFeatures { viewBinding = true }
– Añadir dependencias:
implementation ("androidx.recyclerview:recyclerview:1.3.2") implementation ("androidx.cardview:cardview:1.0.0") // https://square.github.io/okhttp/#releases implementation ("com.squareup.okhttp3:okhttp:4.12.0") // https://github.com/square/retrofit implementation ("com.squareup.retrofit2:retrofit:2.9.0") // https://github.com/google/gson implementation ("com.squareup.retrofit2:converter-gson:2.9.0") implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.5.1")
Replace with new library catalog declaration:
implementation (libs.androidx.recyclerview) implementation (libs.androidx.cardview) // https://square.github.io/okhttp/#releases implementation (libs.okhttp) // https://github.com/square/retrofit implementation (libs.retrofit) // https://github.com/google/gson implementation (libs.converter.gson) implementation (libs.kotlinx.coroutines.core) implementation (libs.androidx.lifecycle.runtime.ktx)
– Repositorios en GitHub
https://api.github.com/users/paco-portada/repos
Generate Plain Old Java Objects from JSON or JSON-Schema: jsonschema2pojo
clase Owner?
data class Owner ( @SerializedName("login") var login: String, @SerializedName("avatar_url") var avatarUrl: String )
clase Repo?
data class Repo ( @SerializedName("name") var name: String, @SerializedName("owner") var owner: Owner, @SerializedName("html_url") var htmlUrl: String, @SerializedName("description") var description: String, @SerializedName("created_at") var createdAt: String )
– Creación del layout
Floating Action Button:
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/viewRoot" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.appcompat.widget.SearchView android:id="@+id/svRepos" android:layout_width="match_parent" android:layout_height="wrap_content" android:elevation="7dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintVertical_bias="0.0" app:queryHint="paco-portada, ArisGuimera"/> <androidx.coordinatorlayout.widget.CoordinatorLayout android:id="@+id/coordinatorLayout" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/svRepos"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rvRepos" android:layout_width="match_parent" android:layout_height="match_parent"> </androidx.recyclerview.widget.RecyclerView> <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/fab" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="bottom|right" android:layout_margin="24dp" android:src="@drawable/ic_baseline_autorenew_24" app:layout_anchor="@id/rvRepos" app:layout_anchorGravity="bottom|right|end" /> </androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.constraintlayout.widget.ConstraintLayout>
/res/values/colors.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <color name="black">#FF000000</color> <color name="white">#FFFFFFFF</color> <color name="purple_200">#FFBB86FC</color> <color name="purple_500">#FF6200EE</color> <color name="purple_700">#FF3700B3</color> <color name="teal_200">#FF03DAC5</color> <color name="teal_700">#FF018786</color> </resources>
/res/values/strings.xml
<resources> <string name="app_name">Repositorios Kotlin</string> <string name="name">Name</string> <string name="description">Description</string> <string name="createdat">CreatedAt</string> </resources>
repo_item.xml
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <androidx.cardview.widget.CardView android:layout_width="match_parent" android:layout_height="wrap_content"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <TextView android:id="@+id/tvName" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" android:text="@string/name" android:textAppearance="@android:style/TextAppearance.Material.Large" android:textColor="@color/teal_700" /> <TextView android:id="@+id/tvDescription" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" android:text="@string/description" android:textColor="@color/purple_500" /> <TextView android:id="@+id/tvCreatedAt" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" android:text="@string/createdat" android:textColor="@color/teal_200" /> </LinearLayout> </androidx.cardview.widget.CardView> </LinearLayout>
– Creación del RecyclerView y el adapter
ReposAdapter.java
class ReposAdapter(private val reposList : List<Repo>) : RecyclerView.Adapter<ReposAdapter.RepoViewHolder>() { lateinit var onItemClick: ((Repo) -> Unit) lateinit var onLongItemClick: ((Repo) -> Unit) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RepoViewHolder { val itemBinding = RepoItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) return RepoViewHolder(itemBinding) } override fun getItemCount(): Int = reposList.size override fun onBindViewHolder(holder: RepoViewHolder, position: Int) { val item = reposList[position] holder.render(item) } inner class RepoViewHolder(binding: RepoItemBinding) : RecyclerView.ViewHolder(binding.root) { private val name = binding.tvName private val description = binding.tvDescription private val date = binding.tvCreatedAt init { itemView.setOnClickListener { onItemClick.invoke(reposList[layoutPosition]) } itemView.setOnLongClickListener { onLongItemClick.invoke(reposList[layoutPosition]) false } } fun render(repo: Repo) { name.text = repo.name description.text = repo.description date.text = repo.createdAt } } }
Patrón Singleton
– Retrofit
Consuming APIs with Retrofit
Cómo consumir una API y procesar la respuesta usando Retrofit
|
ApiService.java
interface ApiService { // No usar suspend con Call // https://stackoverflow.com/questions/75139082/jsonioexception-interfaces-cant-be-instantiated-register-an-instancecreator-o @GET("users/{username}/repos") fun listRepos(@Path("username") username: String?): Call<ArrayList<Repo>> @GET("users/{username}/repos") suspend fun listReposResponse(@Path("username") username: String?): Response<ArrayList<Repo>> @GET("ficheros/repos.json") fun getRepos(): Call<ArrayList<Repo>> @GET("ficheros/repos.json") suspend fun getReposResponse(): Response<ArrayList<Repo>> }
Creating the Retrofit instance:
ApiAdapter.java
object ApiAdapter { private var API_SERVICE: ApiService? = null const val BASE_URL = "https://api.github.com/" // const val BASE_URL = "https://dam.org.es/" // const val BASE_URL = "http://192.168.0.33/" // Añadir al manifiesto // android:usesCleartextTraffic="true" @get:Synchronized val instance: ApiService? get() { if (API_SERVICE == null) { val okHttpClient = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .build() val gson = GsonBuilder() .setDateFormat("dd-MM-yyyy") .create() val retrofit = Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create(gson)) .client(okHttpClient) .build() API_SERVICE = retrofit.create(ApiService::class.java) } return API_SERVICE } }
Android Kotlin Coroutine with Retrofit
lifecycleScope.launch { val response = retrofit.getUsers() if (response.isSuccessful) { launch(Dispatchers.Main) { if (!response.body().isNullOrEmpty()) { val recyclerAdapter = response.body()?.let { RecyclerAdapter(it) } binding.recyclerView.adapter = recyclerAdapter } } } }
MainActivity.java
class MainActivity : AppCompatActivity(), View.OnClickListener, SearchView.OnQueryTextListener { private lateinit var binding: ActivityMainBinding private lateinit var adapter: ReposAdapter private val listRepos = mutableListOf<Repo>() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.svRepos.setOnQueryTextListener(this) binding.fab.setOnClickListener(this) initRecyclerView() //getRepos() } private fun initRecyclerView() { adapter = ReposAdapter(listRepos) // binding.rvDogs.setHasFixedSize(true) binding.rvRepos.layoutManager = LinearLayoutManager(this) binding.rvRepos.adapter = adapter adapter.onItemClick = { showMessage("Single Click: \n" + it.htmlUrl) val uri = Uri.parse(it.htmlUrl) val intent = Intent(Intent.ACTION_VIEW, uri) if (intent.resolveActivity(packageManager) != null) startActivity(intent) else showMessage("No hay un navegador") } adapter.onLongItemClick = { showMessage("Long Click: ${it.name}") } } private fun searchByName(query: String) { lateinit var call: Call<ArrayList<Repo>> CoroutineScope(Dispatchers.IO).launch { call = instance!!.listRepos(query) call.enqueue(object : Callback<ArrayList<Repo>> { override fun onResponse( call: Call<ArrayList<Repo>>, response: Response<ArrayList<Repo>> ) { runOnUiThread { hideKeyboard() if (response.isSuccessful) { // Handle the retrieved post data val repos: ArrayList<Repo> = (response.body() ?: emptyArray<Repo>()) as ArrayList<Repo> listRepos.clear() listRepos.addAll(repos) adapter.notifyDataSetChanged() } else { // Handle error showError(response.code().toString()) } } } override fun onFailure(call: Call<ArrayList<Repo>>, t: Throwable) { // Handle failure runOnUiThread { showMessage(t.toString()) } } }) } } private fun searchByNameResponse(query: String) { // lateinit var call: Call<ArrayList<Repo>> //CoroutineScope(Dispatchers.IO).launch { lifecycleScope.launch { try { val response = instance!!.listReposResponse(query) // val response = instance!!.getReposResponse() //runOnUiThread { launch(Dispatchers.Main) { hideKeyboard() if (response.isSuccessful) { // Handle the retrieved post data val repos: ArrayList<Repo> = (response.body() ?: emptyArray<Repo>()) as ArrayList<Repo> listRepos.clear() listRepos.addAll(repos) adapter.notifyDataSetChanged() } else { // Handle error showError(response.code().toString()) } } } catch (e: Exception) { launch(Dispatchers.Main) { hideKeyboard() showError("Error:\n" + e.message.toString()) Log.e("Error", e.message.toString()) } } } /* override fun onFailure(call: Call<ArrayList<Repo>>, t: Throwable) { // Handle failure runOnUiThread { showMessage(t.toString()) } } */ } fun getRepos() { var call: Call<ArrayList<Repo>> = instance!!.getRepos() call.enqueue(object : Callback<ArrayList<Repo>> { override fun onResponse( call: Call<ArrayList<Repo>>, response: Response<ArrayList<Repo>> ) { // hideKeyboard() if (response.isSuccessful) { // Handle the retrieved post data val repos: ArrayList<Repo> = (response.body() ?: emptyArray<Repo>()) as ArrayList<Repo> listRepos.clear() listRepos.addAll(repos) adapter.notifyDataSetChanged() } else { // Handle error showError(response.code().toString()) } } override fun onFailure(call: Call<ArrayList<Repo>>, t: Throwable) { // Handle failure showMessage(t.toString()) Log.e("Error", t.toString()) } }) } fun showMessage(message: String) { Toast.makeText(this, "$message", Toast.LENGTH_SHORT).show() } fun showError(message: String) { Toast.makeText(this, "Ha ocurrido un error:\n $message", Toast.LENGTH_SHORT).show() } override fun onQueryTextSubmit(query: String): Boolean { if (!query.isNullOrEmpty()) // searchByName(query.lowercase()) searchByNameResponse(query.lowercase()) return true } override fun onQueryTextChange(newText: String?): Boolean { return true } private fun hideKeyboard() { val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(binding.viewRoot.windowToken, 0) } override fun onClick(v: View?) { if (v === binding.fab) { val user = binding.svRepos.query.toString() if (user.isNullOrEmpty()) showMessage("Introduzca un usuario") else searchByName(user) //searchByNameResponse(query.lowercase()) } } }
Prueba de acceso al servidor local:
Añadir el fichero repos.json al servidor local en la carpeta ficheros
ApiAdapter
object ApiAdapter { private var API_SERVICE: ApiService? = null // const val BASE_URL = "https://api.github.com/" // const val BASE_URL = "https://dam.org.es/" const val BASE_URL = "http://192.168.0.10/" // Añadir al manifiesto // android:usesCleartextTraffic="true" @get:Synchronized val instance: ApiService? get() { if (API_SERVICE == null) { val okHttpClient = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .build() val gson = GsonBuilder() .setDateFormat("dd-MM-yyyy") .create() val retrofit = Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create(gson)) .client(okHttpClient) .build() API_SERVICE = retrofit.create(ApiService::class.java) } return API_SERVICE } }
Acceso a Github:
ApiAdapter
object ApiAdapter { private var API_SERVICE: ApiService? = null const val BASE_URL = "https://api.github.com/" // const val BASE_URL = "https://dam.org.es/" // const val BASE_URL = "http://192.168.0.33/" // Añadir al manifiesto // android:usesCleartextTraffic="true" @get:Synchronized val instance: ApiService? get() { if (API_SERVICE == null) { val okHttpClient = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .build() val gson = GsonBuilder() .setDateFormat("dd-MM-yyyy") .create() val retrofit = Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(GsonConverterFactory.create(gson)) .client(okHttpClient) .build() API_SERVICE = retrofit.create(ApiService::class.java) } return API_SERVICE } }
Uso de searchByName
Uso de searchByNameResponse
Errores:
– El servidor no responde
– No se encuentra el fichero
– Error de sintaxis en el archivo json
Deja una respuesta
Lo siento, debes estar conectado para publicar un comentario.