Notificación de servicio en segundo plano - Servidor web Android
En este post voy a enseñarte a programar un servidor web en Android asegurándonos de que el web server se ejecuta siempre en segundo plano y que no será detenido por el sistema.
Vamos a programar un servidor web en Android usando Kotlin y la librería NanoHTTPD. Dicho web server va a ser iniciado desde un servicio (Service) que muestra una notificación persistente para que el usuario sepa que el servidor web está ejecutándose en segundo plano.
Te voy a enseñar a implementar el servicio, crear la notificación, solicitar los permisos para notificaciones e iniciar el servidor web en Android de manera programada.
Esto puede cambiar entre proyectos pero en mi caso primero modifiqué mi libs.versions.toml. En [versions]
añadí:
nanohttpd = "2.3.1"
Luego, en [libraries]
lo siguiente:
nanohttpd = { group = "org.nanohttpd", name = "nanohttpd", version.ref = "nanohttpd" }
Finalmente mi libs.versions.toml completo quedó así:
[versions]
agp = "8.7.3"
kotlin = "2.0.0"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.0"
composeBom = "2024.04.01"
nanohttpd = "2.3.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
nanohttpd = { group = "org.nanohttpd", name = "nanohttpd", version.ref = "nanohttpd" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Luego, en build.gradle.kts añadí lo siguiente en dependencies:
implementation(libs.nanohttpd)
De modo que quedó así:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "me.parzibyte.puentehttp"
compileSdk = 35
defaultConfig {
applicationId = "me.parzibyte.puentehttp"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.nanohttpd)
}
El servidor web queda así. Toma en cuenta que ahora solo manejo una ruta que muestra un hola mundo, ya a partir de aquí puedes añadir más rutas.
Mi archivo se llama SimpleServer.kt y queda así:
package me.parzibyte.puentehttp
import android.content.Context
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoHTTPD.IHTTPSession
import fi.iki.elonen.NanoHTTPD.Method
import fi.iki.elonen.NanoHTTPD.Response
import fi.iki.elonen.NanoHTTPD.newFixedLengthResponse
class SimpleServer(port: Int, private val context: Context) : NanoHTTPD(port) {
override fun serve(session: IHTTPSession): Response {
return manejadorHttpPrincipal(session, context);
}
}
fun manejadorHttpPrincipal(session: IHTTPSession, context: Context): Response {
if (session.method == Method.GET && session.uri == "/version") {
return agregarCors(manejarPeticionVersion())
}
return agregarCors(newFixedLengthResponse("No encontrado"))
}
fun agregarCors(peticionHttp: Response): Response {
peticionHttp.addHeader("Access-Control-Allow-Origin", "*")
peticionHttp.addHeader("Access-Control-Allow-Headers", "*")
peticionHttp.addHeader("Access-Control-Allow-Methods", "POST,GET,DELETE,PUT,OPTIONS")
return peticionHttp
}
fun manejarPeticionVersion(): Response {
return newFixedLengthResponse(
Response.Status.OK,
"application/json",
"Hello"
)
}
Para usar e iniciar el servidor simplemente debemos hacer lo siguiente:
servidor = SimpleServer(8000, contexto)
servidor.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false)
Todavía no lo vamos a hacer porque esto debe ir dentro del servicio.
Ahora vamos a crear un servicio en segundo plano, iniciarlo desde nuestra actividad principal y, dentro de dicho servicio, encender el servidor web en Android en segundo plano.
Comenzamos creando el servicio, en mi caso se llama WebServerService.kt; el nombre es importante porque vamos a registrarlo en el manifiesto.
package me.parzibyte.puentehttp
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import fi.iki.elonen.NanoHTTPD
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class WebServerService() : Service() {
private lateinit var servidor: SimpleServer
override fun onCreate() {
Log.d("MI_TAG", "onCreate")
super.onCreate()
val that = this
val notificacionParaServicio = crearNotificacionParaServicio()
startForeground(1, notificacionParaServicio)
Log.d("MI_TAG", "Notificación para servicio enviada")
CoroutineScope(Dispatchers.IO).launch {
servidor = SimpleServer(8000, that)
servidor.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("WebServerService", "onStartCommand")
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? {
Log.d("WebServerService", "onBind")
return null
}
override fun onDestroy() {
super.onDestroy()
Log.d("WebServerService", "onDestroy")
servidor.stop()
}
private fun crearNotificacionParaServicio(): Notification {
Log.d("MI_TAG", "createNotification")
val ID_CANAL_NOTIFICACION = "ServicioPuenteHTTP"
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){
// Solo hace falta registrar el canal de notificación en android O y superiores,
// de lo demás se encarga NotificationCompat
val canalDeNotificacion = NotificationChannel(
ID_CANAL_NOTIFICACION,
"Puente HTTP",
NotificationManager.IMPORTANCE_DEFAULT
)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(canalDeNotificacion)
}
return NotificationCompat.Builder(this, ID_CANAL_NOTIFICACION)
.setContentTitle("Servicio de impresión")
.setContentText("Puente ejecutándose en segundo plano")
.setSmallIcon(R.drawable.ic_launcher_background) // Usa un icono adecuado
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
}
Lo importante aquí es que a partir de Android Oreo debemos registrar un canal de notificaciones para mostrar la notificación. Estoy usando NotificationCompat
para crear notificaciones sin importar la versión de Android.
Enviar una notificación cuando se crea un servicio en segundo plano (con crearNotificacionParaServicio
) es obligatorio en las versiones más recientes de Android. Además, a partir de Android 13 debemos tener permiso de enviar notificaciones y pedir dicho permiso tanto en el manifiesto como en tiempo de ejecución.
Otra parte importante es registrar el servicio en el AndroidManifest.xml dentro de application:
<service android:name=".WebServerService" android:foregroundServiceType="dataSync"/>
Además, debemos añadir los siguientes permisos:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.INTERNET" />
Solo como referencia, dejo mi AndroidManifest.xml completo:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.PuenteHTTP"
tools:targetApi="31">
<service android:name=".WebServerService" android:foregroundServiceType="dataSync"/>
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.PuenteHTTP">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
La parte importante y que realmente levanta el servidor web es el onCreate del servicio en el siguiente bloque:
CoroutineScope(Dispatchers.IO).launch {
servidor = SimpleServer(8000, that)
servidor.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false)
}
El puerto del servidor será el 8000 y puedes ajustarlo como prefieras.
Finalmente en nuestra actividad principal iniciamos el servicio en el onCreate
. Tengo la siguiente función que lo hace:
private var servidorIniciado = mutableStateOf(false)
private fun iniciarServidorSiTodosLosPermisosHanSidoConcedidos() {
Toast.makeText(this, "Iniciando...", Toast.LENGTH_LONG).show()
Log.d("MI_TAG", "Iniciando...")
try {
val serviceIntent = Intent(this, WebServerService::class.java)
ContextCompat.startForegroundService(this, serviceIntent)
servidorIniciado.value = true
Log.d("MI_TAG", "Servidor iniciado ")
} catch (e: Exception) {
Log.d("MI_TAG", "Excepción ${e.message}")
Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_LONG).show()
e.printStackTrace()
}
}
Es importante que notes que servidorIniciado
es una variable que está en conjunto con Jetpack Compose. Si tú no usas Compose entonces puedes omitir esas variables.
Ahora ya solo falta iniciar el servidor en el onCreate
:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PuenteHTTPTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
iniciarServidorSiTodosLosPermisosHanSidoConcedidos()
}
Por favor toma en cuenta que justo ahora no estoy manejando la solicitud de permisos en tiempo de ejecución para mostrar notificaciones. Lo añadiré más adelante.
Si todo sale bien, tan pronto inicie la aplicación se nos debe mostrar una notificación persistente:
Y si navegamos a localhost:8000/version vamos a ver el servidor web manejando las peticiones:
De ese modo vamos a tener un servidor web en Android ejecutándose siempre en segundo plano, con la seguridad de que el proceso no será detenido por el sistema operativo.
A partir de Android Tiramisú se necesita un permiso solicitado en tiempo de ejecución para mostrar notificaciones, además de declararlo en el manifiesto.
Yo tengo la siguiente función que me devuelve la lista de permisos que necesita mi app, en este caso solo es el de mostrar notificaciones desde Android 13:
fun obtenerPermisos(): MutableList<String> {
val permisos = mutableListOf<String>(
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permisos.add(Manifest.permission.POST_NOTIFICATIONS)
}
return permisos;
}
La siguiente función me permite saber si se han concedido todos los permisos:
private fun todosLosPermisosPermitidos(): Boolean {
val missingPermissions = obtenerPermisos().filter {
checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
}
Log.d("MI_TAG", "Los missing son: ${missingPermissions}")
return missingPermissions.isEmpty()
}
En caso de que los permisos no hayan sido concedidos, los solicito. Primero necesito el request permission launcher que será invocado después de que el usuario acepte o rechace los permisos:
val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) {
if (todosLosPermisosPermitidos()) {
iniciarServidorSiTodosLosPermisosHanSidoConcedidos()
} else {
Toast.makeText(
this,
"No has concedido todos los permisos y por lo tanto no puedes usar la app",
Toast.LENGTH_LONG
).show()
}
}
Luego los solicito así indicando el requestPermissionLauncher previamente creado:
private fun solicitarPermisos() {
if (!todosLosPermisosPermitidos()) {
requestPermissionLauncher.launch(obtenerPermisos().toTypedArray())
}
}
Y mi función que inicia el servidor revisando los permisos es la siguiente:
private fun iniciarServidorSiTodosLosPermisosHanSidoConcedidos() {
if (!todosLosPermisosPermitidos()) {
solicitarPermisos()
return
}
Log.d("MI_TAG", "Iniciando...")
try {
val serviceIntent = Intent(this, WebServerService::class.java)
ContextCompat.startForegroundService(this, serviceIntent)
servidorIniciado.value = true
Log.d("MI_TAG", "Servidor iniciado ")
} catch (e: Exception) {
Log.d("MI_TAG", "Excepción ${e.message}")
Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_LONG).show()
e.printStackTrace()
}
}
Por cierto, no es obligatorio usar NanoHTTPD; yo lo he usado porque es muy fácil de implementar. Lo realmente importante es saber manejar el servidor en segundo plano para que no sea detenido por el sistema.
El día de hoy vamos a ver cómo restablecer la impresora térmica GOOJPRT PT-210 a…
Hoy voy a enseñarte cómo imprimir en una impresora térmica conectada por USB a una…
En este post te quiero compartir un código de C++ para listar y cancelar trabajos…
Gracias a WebAssembly podemos ejecutar código de otros lenguajes de programación desde el navegador web…
Revisando y buscando maneras de imprimir un PDF desde la línea de comandos me encontré…
Esta semana estuve recreando la API del plugin para impresoras térmicas en Android (HTTP a…
Esta web usa cookies.