Panel Informativo MeteoEscuela

Visualización continua de datos meteorológicos y noticias de actividades escolares

Este proyecto consiste en el desarrollo de un panel informativo basado en Android que muestra, de forma automática y sin intervención manual, los datos meteorológicos locales y un carrusel de imágenes y vídeos con las actividades realizadas en el centro educativo. El sistema:

Demostración en vídeo

Código fuente principal (Kotlin)

Mostrar / ocultar clase MainActivity.kt

// — MainActivity.kt 
package com.edumakers.sanfernando

import android.content.Context
import android.graphics.drawable.Drawable
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.*
import android.util.DisplayMetrics
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.view.WindowManager
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem as ExoMediaItem
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerView
import okhttp3.*
import org.json.JSONObject
import org.jsoup.Jsoup
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.TimeUnit

class MainActivity : AppCompatActivity() {

    private val client = OkHttpClient.Builder()
        .connectTimeout(120, TimeUnit.SECONDS)
        .readTimeout(120, TimeUnit.SECONDS)
        .writeTimeout(120, TimeUnit.SECONDS)
        .build()

    private lateinit var viewFlipper: FrameLayout
    private val handler = Handler(Looper.getMainLooper())

    private val WEATHER_UPDATE_INTERVAL: Long = 60 * 60 * 1000
    private val MEDIA_UPDATE_INTERVAL: Long = 60 * 60 * 1000

    private val weatherHandler = Handler(Looper.getMainLooper())
    private lateinit var weatherRunnable: Runnable

    private val mediaHandler = Handler(Looper.getMainLooper())
    private lateinit var mediaRunnable: Runnable

    private var currentMediaDuration: Long = 5000
    private var isTransitioning = false

    private lateinit var valorTemperatura: TextView
    private lateinit var valorHumedad: TextView
    private lateinit var valorViento: TextView
    private lateinit var valorIndiceUV: TextView
    private lateinit var valorCalidadAire: TextView
    private lateinit var weatherIcon: ImageView

    private lateinit var currentMediaDir: File
    private lateinit var nextMediaDir: File

    private val mediaItemsList = mutableListOf()
    private var currentMediaIndex = 0

    private var currentMediaView: View? = null
    private var currentExoPlayer: ExoPlayer? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Establecer un manejador global de excepciones no capturadas
        Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
            Log.e("UncaughtException", "Excepción no capturada en el hilo ${thread.name}", throwable)
            // Aquí podrías mostrar un mensaje al usuario o reiniciar la actividad si es necesario
        }

        supportActionBar?.hide()
        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
        setContentView(R.layout.activity_main)

        viewFlipper = findViewById(R.id.viewFlipper)

        valorTemperatura = findViewById(R.id.valorTemperatura)
        valorHumedad = findViewById(R.id.valorHumedad)
        valorViento = findViewById(R.id.valorViento)
        valorIndiceUV = findViewById(R.id.valorIndiceUV)
        valorCalidadAire = findViewById(R.id.valorCalidadAire)
        weatherIcon = findViewById(R.id.weatherIcon)

        adjustSideRectangles()
        hideSystemUI()

        currentMediaDir = File(getExternalFilesDir(null), "currentMedia")
        nextMediaDir = File(getExternalFilesDir(null), "nextMedia")
        currentMediaDir.mkdirs()
        nextMediaDir.mkdirs()

        startMediaLoading()
        startWeatherLoading()
    }

    private fun adjustSideRectangles() {
        val metrics = DisplayMetrics()
        windowManager.defaultDisplay.getMetrics(metrics)
        val screenWidth = metrics.widthPixels
        val rectWidth = (screenWidth * 0.15).toInt()

        val rectIzquierdo = findViewById(R.id.rectanguloIzquierdo)
        val rectDerecho = findViewById(R.id.rectanguloDerecho)

        rectIzquierdo.layoutParams.width = rectWidth
        rectDerecho.layoutParams.width = rectWidth
        rectIzquierdo.requestLayout()
        rectDerecho.requestLayout()
    }

    private fun hideSystemUI() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            window.setDecorFitsSystemWindows(false)
            window.insetsController?.let {
                it.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
                it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
            }
        } else {
            @Suppress("DEPRECATION")
            window.decorView.systemUiVisibility = (
                    View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
                            or View.SYSTEM_UI_FLAG_FULLSCREEN
                            or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
                            or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                            or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                            or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                    )
        }
    }

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)
        if (hasFocus) {
            hideSystemUI()
        }
    }

    private fun startMediaLoading() {
        val sheetId = "19kBkfQtQwT-gnqo5wH0zR0qJabuy1B7mlE1QbVntlTU"
        val sheetUrl = "https://docs.google.com/spreadsheets/d/$sheetId/gviz/tq?tqx=out:json"

        mediaRunnable = object : Runnable {
            override fun run() {
                if (isNetworkAvailable()) {
                    fetchMediaUrls(sheetUrl)
                } else {
                    Log.e("Network", "No hay conexión a internet")
                }
                mediaHandler.postDelayed(this, MEDIA_UPDATE_INTERVAL)
            }
        }
        mediaHandler.post(mediaRunnable)
    }

    private fun startWeatherLoading() {
        val weatherSheetUrl = "https://docs.google.com/spreadsheets/d/e/2PACX-1vR-xRq3QNZXIsoGLrwtl6FQP9ECDsuckbB86CxPzAZj-LTBLsUpQUWFpfcIZ9A2l3mE-wFFLYlOz0Mr/pubhtml?gid=0&single=true"

        weatherRunnable = object : Runnable {
            override fun run() {
                if (isNetworkAvailable()) {
                    Thread {
                        fetchWeatherData(weatherSheetUrl) { weatherData ->
                            runOnUiThread {
                                updateWeatherUI(weatherData)
                            }
                        }
                    }.start()
                } else {
                    Log.e("Network", "No hay conexión a internet para actualizar el clima")
                }
                weatherHandler.postDelayed(this, WEATHER_UPDATE_INTERVAL)
            }
        }
        weatherHandler.post(weatherRunnable)
    }

    private fun fetchMediaUrls(url: String) {
        val request = Request.Builder().url(url).build()

        client.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {
                Log.e("Network", "Error al obtener las URLs de los medios", e)
            }

            override fun onResponse(call: Call, response: Response) {
                val responseBody = response.body
                if (responseBody == null) {
                    Log.e("Network", "El cuerpo de la respuesta es nulo")
                    return
                }

                val jsonString = responseBody.string()
                val json = jsonString.substringAfter("setResponse(", "")
                    .substringBeforeLast(");", "")

                try {
                    val jsonObj = JSONObject(json)
                    val rows = jsonObj.getJSONObject("table").getJSONArray("rows")
                    mediaItemsList.clear()

                    Thread {
                        try {
                            for (i in 0 until rows.length()) {
                                val row = rows.getJSONObject(i).getJSONArray("c")
                                if (row.optJSONObject(0)?.optString("v") == null) continue

                                val fileName = row.optJSONObject(0)?.optString("v") ?: continue
                                val fileUrl = row.optJSONObject(1)?.optString("v") ?: continue
                                val fileType = row.optJSONObject(2)?.optString("v") ?: continue
                                val fileSizeCell = row.optJSONObject(3)
                                val fileSizeValue = fileSizeCell?.optDouble("v")
                                val fileSize = fileSizeValue?.toLong() ?: 0L

                                val mediaItem = MediaItemData(
                                    fileName = fileName,
                                    url = fileUrl,
                                    type = fileType,
                                    expectedSize = fileSize
                                )

                                val file = downloadFile(mediaItem.url, mediaItem.fileName, mediaItem.expectedSize, nextMediaDir)
                                if (file != null) {
                                    mediaItemsList.add(mediaItem.copy(localPath = file.absolutePath))
                                } else {
                                    Log.e("Download", "Error al descargar el archivo: ${mediaItem.fileName}")
                                }
                            }

                            runOnUiThread {
                                if (mediaItemsList.isNotEmpty()) {
                                    currentMediaIndex = 0
                                    startCurrentMedia()
                                    swapMediaDirectories()
                                } else {
                                    Log.e("Media", "No se pudieron cargar los nuevos medios")
                                }
                            }
                        } catch (e: Exception) {
                            Log.e("fetchMediaUrls", "Error procesando los elementos de medios", e)
                        }
                    }.start()

                } catch (e: Exception) {
                    Log.e("JSON", "Error al procesar JSON", e)
                }
            }
        })
    }

    private fun downloadFile(fileUrl: String, fileName: String, expectedSize: Long, destinationDir: File, maxRetries: Int = 20): File? {
        var attempt = 0
        while (attempt < maxRetries) {
            try {
                attempt++
                val url = java.net.URL(fileUrl)
                val connection = url.openConnection()
                connection.connect()

                val file = File(destinationDir, fileName)

                connection.getInputStream().use { inputStream ->
                    FileOutputStream(file).use { outputStream ->
                        inputStream.copyTo(outputStream)
                    }
                }

                val downloadedSize = file.length()
                val sizeDifference = kotlin.math.abs(downloadedSize - expectedSize)
                val tolerance = 1024L

                if (sizeDifference <= tolerance) {
                    Log.d("Download", "Descarga exitosa y tamaño verificado para $fileName")
                    return file
                } else {
                    throw Exception("El tamaño del archivo descargado no coincide.")
                }
            } catch (e: OutOfMemoryError) {
                Log.e("Download", "Memory error downloading $fileName", e)
                File(destinationDir, fileName).delete()
                System.gc()
                return null
            } catch (e: Exception) {
                Log.e("Download", "Error al descargar el archivo: $fileUrl (Intento $attempt de $maxRetries)", e)
                File(destinationDir, fileName).delete()
                if (attempt >= maxRetries) return null
                Thread.sleep(2000)
            }
        }
        return null
    }

    private fun createImageView(filePath: String?, onLoaded: (ImageView?) -> Unit): ImageView? {
        if (filePath == null || !File(filePath).exists()) return null

        val imageView = ImageView(this).apply {
            layoutParams = FrameLayout.LayoutParams(
                (resources.displayMetrics.widthPixels * 0.7).toInt(),
                FrameLayout.LayoutParams.MATCH_PARENT
            ).apply {
                gravity = Gravity.CENTER
            }
            adjustViewBounds = true
            scaleType = ImageView.ScaleType.FIT_CENTER
        }

        try {
            val width = resources.displayMetrics.widthPixels * 0.7
            val height = resources.displayMetrics.heightPixels.toDouble()

            Glide.with(this)
                .load(File(filePath))
                .override(width.toInt(), height.toInt())
                .diskCacheStrategy(DiskCacheStrategy.NONE)
                .skipMemoryCache(true)
                .into(object : CustomTarget() {
                    override fun onResourceReady(resource: Drawable, transition: Transition?) {
                        imageView.setImageDrawable(resource)
                        onLoaded(imageView)
                    }

                    override fun onLoadCleared(placeholder: Drawable?) {
                        // No es necesario manejar esto
                    }

                    override fun onLoadFailed(errorDrawable: Drawable?) {
                        super.onLoadFailed(errorDrawable)
                        Log.e("Glide", "Error al cargar la imagen: $filePath")
                        // Llamamos a onLoaded con null para indicar que hubo un error
                        onLoaded(null)
                    }
                })

            return imageView
        } catch (e: Exception) {
            Log.e("createImageView", "Error al crear ImageView", e)
            return null
        }
    }

    private fun createVideoView(filePath: String?, onPrepared: (PlayerView?) -> Unit): PlayerView? {
        if (filePath == null || !File(filePath).exists()) return null

        try {
            val exoPlayer = ExoPlayer.Builder(this).build()
            val mediaItem = ExoMediaItem.fromUri(Uri.fromFile(File(filePath)))
            exoPlayer.setMediaItem(mediaItem)

            val playerView = PlayerView(this).apply {
                layoutParams = FrameLayout.LayoutParams(
                    (resources.displayMetrics.widthPixels * 0.7).toInt(),
                    FrameLayout.LayoutParams.MATCH_PARENT
                ).apply {
                    gravity = Gravity.CENTER
                }
                player = exoPlayer
                useController = false
            }

            exoPlayer.addListener(object : Player.Listener {
                override fun onPlaybackStateChanged(state: Int) {
                    if (state == Player.STATE_READY) {
                        exoPlayer.removeListener(this)
                        onPrepared(playerView)
                    } else if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) {
                        exoPlayer.removeListener(this)
                        Log.e("ExoPlayer", "Error al preparar el video: $filePath")
                        onPrepared(null)
                    }
                }

                override fun onPlayerError(error: PlaybackException) {
                    exoPlayer.removeListener(this)
                    Log.e("ExoPlayer", "Error al reproducir el video: $filePath", error)
                    onPrepared(null)
                }
            })

            exoPlayer.prepare()

            return playerView
        } catch (e: Exception) {
            Log.e("createVideoView", "Error al crear VideoView", e)
            return null
        }
    }

    private fun showNextMedia() {
        if (isTransitioning || mediaItemsList.isEmpty()) return

        isTransitioning = true

        var attempts = 0
        val maxAttempts = mediaItemsList.size

        fun attemptToShowNextMedia() {
            if (attempts >= maxAttempts) {
                Log.e("showNextMedia", "No se pudo mostrar ningún medio, lista vacía o corrupta")
                isTransitioning = false
                return
            }

            val nextMediaIndex = (currentMediaIndex + 1) % mediaItemsList.size
            val nextMediaItem = mediaItemsList[nextMediaIndex]

            when (nextMediaItem.type) {
                "imagen" -> {
                    createImageView(nextMediaItem.localPath) { nextImageView ->
                        if (nextImageView != null) {
                            handler.post {
                                performTransition(nextImageView)
                                currentMediaIndex = nextMediaIndex
                                isTransitioning = false
                            }
                        } else {
                            attempts++
                            currentMediaIndex = nextMediaIndex
                            attemptToShowNextMedia()
                        }
                    }
                }
                "video" -> {
                    createVideoView(nextMediaItem.localPath) { nextPlayerView ->
                        if (nextPlayerView != null) {
                            handler.post {
                                performTransition(nextPlayerView)
                                currentMediaIndex = nextMediaIndex
                                isTransitioning = false
                            }
                        } else {
                            attempts++
                            currentMediaIndex = nextMediaIndex
                            attemptToShowNextMedia()
                        }
                    }
                }
                else -> {
                    attempts++
                    currentMediaIndex = nextMediaIndex
                    attemptToShowNextMedia()
                }
            }
        }

        attemptToShowNextMedia()
    }

    private fun performTransition(nextView: View?) {
        if (nextView == null) {
            Log.e("performTransition", "El siguiente view es null, saltando al siguiente medio")
            isTransitioning = false
            showNextMedia()
            return
        }

        try {
            currentMediaView?.let { viewFlipper.removeView(it) }
            releaseCurrentMedia()

            currentMediaView = nextView
            viewFlipper.addView(nextView)

            when (nextView) {
                is ImageView -> {
                    handler.postDelayed({
                        showNextMedia()
                    }, currentMediaDuration)
                }
                is PlayerView -> {
                    val exoPlayer = nextView.player as? ExoPlayer
                    currentExoPlayer = exoPlayer
                    exoPlayer?.playWhenReady = true

                    exoPlayer?.addListener(object : Player.Listener {
                        override fun onPlaybackStateChanged(state: Int) {
                            if (state == Player.STATE_ENDED) {
                                exoPlayer.removeListener(this)
                                handler.post {
                                    showNextMedia()
                                }
                            }
                        }

                        override fun onPlayerError(error: PlaybackException) {
                            exoPlayer.removeListener(this)
                            Log.e("ExoPlayer", "Error durante la reproducción", error)
                            handler.post {
                                showNextMedia()
                            }
                        }
                    })
                }
            }
        } catch (e: Exception) {
            Log.e("performTransition", "Error durante la transición de medios", e)
            isTransitioning = false
            showNextMedia()
        }
    }

    private fun releaseCurrentMedia() {
        currentMediaView?.let { view ->
            when (view) {
                is ImageView -> {
                    Glide.with(this).clear(view)
                    view.setImageDrawable(null)
                }
                is PlayerView -> {
                    val exoPlayer = view.player as? ExoPlayer
                    exoPlayer?.stop()
                    exoPlayer?.release()
                    view.player = null
                    currentExoPlayer = null
                }
            }
            currentMediaView = null
        }
    }

    private fun startCurrentMedia() {
        if (mediaItemsList.isEmpty()) return

        var attempts = 0
        val maxAttempts = mediaItemsList.size

        fun attemptToStartMedia() {
            if (attempts >= maxAttempts) {
                Log.e("startCurrentMedia", "No se pudo iniciar ningún medio, lista vacía o corrupta")
                return
            }

            val mediaItem = mediaItemsList[currentMediaIndex]

            when (mediaItem.type) {
                "imagen" -> {
                    createImageView(mediaItem.localPath) { imageView ->
                        if (imageView != null) {
                            currentMediaView = imageView
                            viewFlipper.addView(imageView)
                            handler.postDelayed({
                                showNextMedia()
                            }, currentMediaDuration)
                        } else {
                            attempts++
                            currentMediaIndex = (currentMediaIndex + 1) % mediaItemsList.size
                            attemptToStartMedia()
                        }
                    }
                }
                "video" -> {
                    createVideoView(mediaItem.localPath) { playerView ->
                        if (playerView != null) {
                            currentMediaView = playerView
                            viewFlipper.addView(playerView)
                            val exoPlayer = playerView.player as? ExoPlayer
                            currentExoPlayer = exoPlayer
                            exoPlayer?.playWhenReady = true

                            exoPlayer?.addListener(object : Player.Listener {
                                override fun onPlaybackStateChanged(state: Int) {
                                    if (state == Player.STATE_ENDED) {
                                        exoPlayer.removeListener(this)
                                        handler.post {
                                            showNextMedia()
                                        }
                                    }
                                }

                                override fun onPlayerError(error: PlaybackException) {
                                    exoPlayer.removeListener(this)
                                    Log.e("ExoPlayer", "Error durante la reproducción", error)
                                    handler.post {
                                        showNextMedia()
                                    }
                                }
                            })
                        } else {
                            attempts++
                            currentMediaIndex = (currentMediaIndex + 1) % mediaItemsList.size
                            attemptToStartMedia()
                        }
                    }
                }
                else -> {
                    attempts++
                    currentMediaIndex = (currentMediaIndex + 1) % mediaItemsList.size
                    attemptToStartMedia()
                }
            }
        }

        attemptToStartMedia()
    }

    private fun swapMediaDirectories() {
        deleteMediaFiles(currentMediaDir)
        val tempDir = currentMediaDir
        currentMediaDir = nextMediaDir
        nextMediaDir = tempDir
        nextMediaDir.mkdirs()
    }

    private fun deleteMediaFiles(directory: File) {
        directory.listFiles()?.forEach { file ->
            if (file.isFile) {
                file.delete()
            }
        }
    }

    private fun updateWeatherUI(weatherData: WeatherData?) {
        weatherData?.let {
            val drawableName = it.iconName.replace(".png", "")
            val resourceId = resources.getIdentifier(drawableName, "drawable", packageName)
            val defaultResourceId = R.drawable.icono01d

            Glide.with(this)
                .load(resourceId.takeIf { it != 0 } ?: defaultResourceId)
                .diskCacheStrategy(DiskCacheStrategy.ALL)
                .into(weatherIcon)

            valorTemperatura.text = "${it.temperatura}°C"
            valorHumedad.text = "${it.humedad}%"
            valorViento.text = "${it.viento} m/s"
            valorIndiceUV.text = it.indiceUV
            valorCalidadAire.text = it.calidadAire
        } ?: run {
            Glide.with(this)
                .load(R.drawable.icono01d)
                .diskCacheStrategy(DiskCacheStrategy.ALL)
                .into(weatherIcon)

            valorTemperatura.text = "--°C"
            valorHumedad.text = "--%"
            valorViento.text = "-- m/s"
            valorIndiceUV.text = "--"
            valorCalidadAire.text = "--"
        }
    }

    private fun fetchWeatherData(url: String, callback: (WeatherData?) -> Unit) {
        val request = Request.Builder().url(url).build()
        try {
            client.newCall(request).execute().use { response ->
                if (!response.isSuccessful) {
                    Log.e("Network", "Error al obtener datos del clima. Código: ${response.code}")
                    callback(null)
                    return
                }

                response.body?.use { responseBody ->
                    try {
                        val htmlString = responseBody.string()
                        val doc = Jsoup.parse(htmlString)
                        val rows = doc.select("table tbody tr")
                        val firstRow = rows.firstOrNull()

                        firstRow?.let { row ->
                            val cells = row.select("td")
                            if (cells.size >= 6) {
                                val weatherData = WeatherData(
                                    iconName = cells[0].text().trim(),
                                    temperatura = cells[1].text().trim(),
                                    humedad = cells[2].text().trim(),
                                    viento = cells[3].text().trim(),
                                    indiceUV = cells[4].text().trim(),
                                    calidadAire = cells[5].text().trim()
                                )
                                callback(weatherData)
                            } else {
                                Log.e("Weather", "Datos del clima incompletos")
                                callback(null)
                            }
                        } ?: run {
                            Log.e("Weather", "No se encontraron datos del clima")
                            callback(null)
                        }
                    } catch (e: Exception) {
                        Log.e("WeatherData", "Error al procesar datos del clima", e)
                        callback(null)
                    }
                } ?: run {
                    Log.e("Network", "El cuerpo de la respuesta es nulo")
                    callback(null)
                }
            }
        } catch (e: IOException) {
            Log.e("Network", "Error al obtener datos del clima", e)
            callback(null)
        }
    }

    private fun isNetworkAvailable(): Boolean {
        val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
            ?: return false
        val networkCapabilities = connectivityManager.activeNetwork ?: return false
        val actNw = connectivityManager.getNetworkCapabilities(networkCapabilities) ?: return false
        return when {
            actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
            actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
            actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true
            else -> false
        }
    }

    override fun onPause() {
        super.onPause()
        currentExoPlayer?.playWhenReady = false
        handler.removeCallbacksAndMessages(null)
    }

    override fun onResume() {
        super.onResume()
        currentExoPlayer?.playWhenReady = true
    }

    override fun onDestroy() {
        super.onDestroy()
        weatherHandler.removeCallbacks(weatherRunnable)
        mediaHandler.removeCallbacks(mediaRunnable)
        handler.removeCallbacksAndMessages(null)

        releaseCurrentMedia()

        deleteMediaFiles(currentMediaDir)
        deleteMediaFiles(nextMediaDir)

        Glide.get(this).clearMemory()
        Thread {
            Glide.get(this).clearDiskCache()
        }.start()
    }
}

data class WeatherData(
    val iconName: String,
    val temperatura: String,
    val humedad: String,
    val viento: String,
    val indiceUV: String,
    val calidadAire: String
)

data class MediaItemData(
    val fileName: String,
    val url: String,
    val type: String,
    val expectedSize: Long,
    val localPath: String? = null
)
      

El proyecto completo está alojado en Github para que cualquiera pueda reproducir este proyecto.