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:
- Consulta una hoja de cálculo de Google Sheets para actualizar los archivos multimedia (imágenes y vídeos) sin necesidad de recompilar la app.
- Obtiene los datos meteorológicos desde otra hoja de cálculo pública que actualiza la estación MeteoEscuela.
- Reproduce el contenido multimedia con
ExoPlayery gestiona transiciones fluidas entre elementos. - Actualiza automáticamente la interfaz cada hora, tanto para el tiempo como para los medios.
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.