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

GitHub Documentation

https://api.github.com/users/paco-portada/repos

Se mostrará una lista (usando un RecyclerView) con los nombres, descripciones y fechas de creación de todos sus repositorios públicos.
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: 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:

versión antigua:

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")

Reemplazar por la nueva declaración:

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

Otra forma de obtener las clases: JSON to Kotlin Data Class Generator Online

Otra forma de obtener las clases en Kotlin: Plugin JSon to Kotlin Class

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

/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>
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:tools="http://schemas.android.com/tools"
    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"
            android:contentDescription="list of owner repos in Github" >

        </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/baseline_autorenew_24"
            app:layout_anchor="@id/rvRepos"
            app:layout_anchorGravity="bottom|right|end"
            android:contentDescription="click to get repos" />


    </androidx.coordinatorlayout.widget.CoordinatorLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

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: Singleton

– 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 listRepos(@Path("username") username: String?): Response<ArrayList<Repo>>

    // @GET("ficheros/repos.json")
    // fun getRepos(): Call<ArrayList<Repo>>

    @GET("ficheros/repos.json")
    suspend fun getRepos(): 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://api.githubmal.com/"

    // const val BASE_URL = "https://alumno.me/"

    // ip del servidor local
    // const val BASE_URL = "http://192.168.100.50/"
    // 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 uri = it.htmlUrl.toUri()
            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}")
        }
    }

    override fun onClick(v: View?) {
        if (v === binding.fab) {
            val user = binding.svRepos.query.toString()
            if (user.isEmpty())
                showMessage("Introduzca un usuario")
            else
                searchByName(user.lowercase())
        }
    }

    /*
    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 searchByName(query: String) {

        //CoroutineScope(Dispatchers.IO).launch {
        lifecycleScope.launch {
            try {
                var response =
                // petición a Github
                //    instance!!.listRepos(query)
                // petición a un servidor local
                    instance!!.getRepos()

                //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())
                }
            }
        }
    }

    private fun showMessage(message: String) {
        Log.i("Info", message)
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }

    private fun showError(message: String) {
        Log.e("Error", message)
        Toast.makeText(this, "Error:\n $message", Toast.LENGTH_SHORT).show()
    }

    override fun onQueryTextSubmit(query: String): Boolean {

        if (query.isNotEmpty())
            searchByName(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)
    }

}

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://api.githubmal.com/"

. . .

MainActivity

private fun searchByName(query: String) {

    //CoroutineScope(Dispatchers.IO).launch {
    lifecycleScope.launch {
        try {
            var response =
            // petición a Github
                instance!!.listRepos(query)
            // petición a un servidor local
            //    instance!!.getRepos()

 

Acceso al servidor local o propio (alumno.me):

Añadir el fichero repos.json al servidor local en la carpeta ficheros

ssh usuario@alumno.me
cd /var/www/html
sudo su
mkdir ficheros
cd ficheros
nano repos.json

ApiAdapter

object ApiAdapter {
    private var API_SERVICE: ApiService? = null
    // const val BASE_URL = "https://api.github.com/"
    //const val BASE_URL = "https://api.githubmal.com/"

    const val BASE_URL = "https://alumno.me/"

    // ip del servidor local
    // const val BASE_URL = "http://192.168.100.50/"
    // Añadir al manifiesto
    // android:usesCleartextTraffic="true"

MainActivity

private fun searchByName(query: String) {

    //CoroutineScope(Dispatchers.IO).launch {
    lifecycleScope.launch {
        try {
            var response =
            // petición a Github
            //    instance!!.listRepos(query)
            // petición a un servidor local
                instance!!.getRepos()

 

Errores:

– El servidor no responde

– No se encuentra el fichero

– Error de sintaxis en el archivo json

Deja una respuesta