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

Documentation

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

Extensión JSON para Firefox

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:

Codepath: FAB

Android developer

Material.io: Using fabs

Icon autorenew

activity_main.xml
<?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

Using the RecyclerView

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

Ejemplo de Patrón Singleton

Patrones de diseño

– Retrofit

Consuming APIs with Retrofit

Cómo consumir una API y procesar la respuesta usando Retrofit

Customize Network Timeouts

 

Define the endpoints:

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
        }
}

Accesing the API

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