Filtros de intent: declarar capacidades en el manifiesto
Un filtro de intent es la declaración pública mediante la cual una aplicación anuncia al resto del sistema qué sabe hacer, y su sintaxis en el manifiesto es engañosamente sencilla para el número de decisiones de diseño y de seguridad que encierra. Esta lección desmonta la anatomía del elemento `intent-filter`, explica por qué las acciones y las categorías forman un vocabulario compartido cuya estabilidad es más importante que su elegancia, y detalla la regla de coincidencia de los elementos `data` con sus trampas de esquema, anfitrión y tipo. Aborda después la consecuencia que casi nadie enuncia: declarar un filtro exporta el componente y lo convierte en superficie de ataque, lo que obliga a razonar sobre `android:exported`, permisos personalizados y validación de la entrada antes de escribir la primera línea del manifiesto.
La lección anterior dejó a la plataforma buscando candidatos entre los filtros declarados por las aplicaciones instaladas, y ahora toca ponerse del otro lado del mostrador. Un filtro de intent es lo que convierte a tu aplicación de consumidora en proveedora: es la frase con la que le dices al sistema que si alguien alguna vez quiere ver un documento de cierto tipo, o abrir una dirección de cierto dominio, o compartir una imagen, tú puedes encargarte. Y es también, sin excepción y sin aviso, una puerta que se abre. La ingenuidad más extendida en este terreno consiste en pensar el filtro como una configuración; es un contrato con desconocidos, escrito en un fichero declarativo, que entra en vigor en el momento de la instalación y que a partir de ahí cualquier aplicación del dispositivo puede invocar con los argumentos que le convenga.
- Leer y escribir la anatomía completa de un
intent-filtercon sus tres clases de elementos. - Manejar el vocabulario de acciones y categorías como contrato público y no como cadena arbitraria.
- Dominar la regla de coincidencia de
data, incluidas las trampas del esquema, el anfitrión y el tipo MIME. - Razonar sobre la exposición que todo filtro implica y protegerla con exportación explícita y validación.
Anatomía del elemento
Un filtro vive dentro de la declaración de un componente y contiene tres clases de elementos: uno o más action, cero o más category y cero o más data. Un componente puede declarar varios filtros, y eso casi siempre es mejor que declarar uno solo con muchos elementos, porque la coincidencia se evalúa filtro a filtro y mezclar capacidades distintas en el mismo bloque produce combinaciones que nunca quisiste ofrecer.
<activity
android:name=".VisorActivity"
android:exported="true">
<intent-filter android:label="@string/abrir_documento">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/pdf" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
El atributo de etiqueta del filtro tiene una función que se descubre tarde: es el texto con el que tu aplicación aparece en el diálogo de elección para esa capacidad concreta, y puede diferir del nombre de la aplicación. Una actividad que ofrece dos capacidades muy distintas puede presentarse con un nombre en cada una, lo que en el momento de elegir aporta al usuario exactamente la información que necesita. Es un detalle de un minuto de trabajo que mejora de forma desproporcionada la tasa de acierto en la elección.
La lectura correcta de ese bloque es que la actividad declara dos capacidades distintas y no una capacidad con cuatro variantes. Si ambos conjuntos vivieran en un único filtro, el sistema consideraría válida la combinación de enviar un documento y de ver una imagen, que probablemente no es lo que la actividad sabe hacer. La regla mental es directa: un filtro equivale a una frase completa sobre lo que la aplicación es capaz de atender.
flowchart LR
A[Componente en el manifiesto] --> B[Filtro uno]
A --> C[Filtro dos]
B --> D[action VIEW]
B --> E[category DEFAULT]
B --> F[data mimeType pdf]
C --> G[action SEND]
C --> H[category DEFAULT]
C --> I[data mimeType imagen]
D --> J{Coincidencia evaluada filtro a filtro}
G --> JAcciones y categorías como vocabulario compartido
Antes de escribir ninguna, conviene interiorizar que este vocabulario es probablemente la interfaz pública más longeva de toda la plataforma: acciones definidas en las primeras versiones siguen funcionando hoy sin cambios, y esa estabilidad es exactamente lo que las hace valiosas.
Las acciones son cadenas y podrías inventarte las que quisieras, pero el valor de una acción reside enteramente en que otros la conozcan. Las constantes de la plataforma —ver, enviar, editar, seleccionar, marcar un número, buscar— existen porque son el punto de encuentro donde emisores y receptores que no se conocen consiguen entenderse. Inventar una acción propia solo tiene sentido dentro de una familia de aplicaciones bajo el mismo control, y aun entonces conviene prefijarla con el nombre del paquete para no colisionar con nadie.
Conviene también saber que los filtros no significan lo mismo en todos los tipos de componente. En una actividad describen una capacidad ofrecida al usuario. En un servicio describen una capacidad ofrecida a otro proceso, y desde hace varias versiones arrancar un servicio con un intent implícito está prohibido, precisamente porque nadie puede elegir conscientemente entre servicios invisibles. En un receptor de difusión la restricción es todavía mayor: los receptores declarados en el manifiesto dejaron de recibir la mayoría de las difusiones implícitas del sistema, y el registro en tiempo de ejecución pasó a ser la vía normal. La misma sintaxis, por tanto, tiene tres semánticas y tres conjuntos de límites.
Las categorías cumplen un papel distinto y a menudo mal entendido: no dicen qué se hace sino en qué circunstancia se hace. CATEGORY_DEFAULT significa estar disponible para intents implícitos y es obligatoria en la práctica. CATEGORY_LAUNCHER significa aparecer en la pantalla de inicio. CATEGORY_BROWSABLE significa poder ser invocado desde un navegador o desde otro contexto en el que el enlace lo haya escrito un tercero, y por eso su presencia debería activar de inmediato una revisión de seguridad sobre lo que la pantalla hace con los datos que recibe.
Un data se compara por partes y cada parte solo se evalúa si la anterior está declarada. Si el filtro declara únicamente scheme, acepta cualquier anfitrión y cualquier ruta bajo ese esquema. Si declara esquema y anfitrión, acepta cualquier ruta de ese anfitrión. Y hay una asimetría clásica que produce fallos difíciles: un intent que lleva tipo MIME nunca casará con un filtro que no declara tipos, y un intent sin tipo tampoco casará con un filtro que solo declara tipos. Cuando quieras aceptar ambas formas, declara dos filtros en lugar de intentar que uno cubra todo.
Exponer una capacidad es abrir una puerta
Aquí está la parte que el tutorial habitual omite. Declarar un filtro convierte al componente en exportado, es decir, invocable por cualquier aplicación instalada. Desde el nivel de API 31 la plataforma obliga a que esa decisión sea explícita: todo componente con filtro debe declarar android:exported con un valor, y si falta, la aplicación ni siquiera se instala. El objetivo de esa ruptura deliberada fue forzar a millones de desarrolladores a mirar durante un segundo una decisión de seguridad que llevaban años tomando por omisión.
La consecuencia práctica es que la actividad que atiende un filtro debe tratar el intent entrante exactamente como se trata la entrada de un formulario en un servidor público: como datos de origen desconocido, posiblemente malformados, posiblemente hostiles y nunca garantizados. Un identificador que llega en un extra puede no existir, puede ser de otro usuario, puede venir con un tipo distinto al esperado. Una ruta puede apuntar fuera del directorio previsto. Un enlace puede querer llevar al usuario a una pantalla que solo debía alcanzarse después de autenticarse.
class VisorActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// nada de lo que llega aqui es de fiar
val id = intent?.getStringExtra(EXTRA_ID)
?.takeIf { it.matches(FORMATO_ID) }
if (id == null || !sesion.puedeVer(id)) {
irADestinoSeguro()
return
}
setContent { Visor(id) }
}
private companion object { val FORMATO_ID = Regex("^[a-zA-Z0-9_-]{1,32}$") }
}
Merece la pena insistir en un matiz que se pasa por alto al leer código ajeno: la actividad que atiende un filtro no controla quién la arrancó. Existe una forma de averiguar el paquete que originó la llamada cuando se espera un resultado, pero no está disponible en el caso general y, sobre todo, conocer el origen no autoriza a confiar en él, porque cualquiera puede instalar una aplicación con el nombre de paquete que quiera mientras no colisione con una ya instalada. La autorización se construye con permisos y con firmas, nunca con comprobaciones de nombre.
Cuando la capacidad no debe estar al alcance de cualquiera existe una vía intermedia entre exportar y no exportar: definir un permiso personalizado con nivel de protección de firma y exigirlo en el componente. Solo las aplicaciones firmadas con tu misma clave podrán invocarlo, lo que resuelve limpiamente el caso de una familia de aplicaciones que colaboran sin quedar expuestas al resto del dispositivo.
<permission
android:name="com.ejemplo.permiso.SINCRONIZAR"
android:protectionLevel="signature" />
<service
android:name=".SincronizacionService"
android:exported="true"
android:permission="com.ejemplo.permiso.SINCRONIZAR">
<intent-filter>
<action android:name="com.ejemplo.accion.SINCRONIZAR" />
</intent-filter>
</service>
Queda una práctica de higiene que cuesta muy poco y evita sorpresas: inventariar periódicamente la superficie expuesta. El manifiesto fusionado que produce la compilación incluye los componentes y filtros que aportan todas las dependencias, y no es infrecuente descubrir ahí actividades exportadas que nadie de tu equipo escribió ni conoce. Revisar ese fichero generado antes de cada publicación importante es la forma más barata de saber qué está realmente abierto, frente a lo que uno cree haber abierto leyendo solo el manifiesto de su módulo.
Un filtro, una frase
Varios filtros pequeños describen capacidades reales; uno grande describe combinaciones que nunca quisiste ofrecer.
Filtro igual a exportado
Declarar android:exported es obligatorio desde el nivel de API 31 y la aplicación no instala si falta.
Entrada hostil
Todo extra y todo dato del intent entrante se valida como si viniera de la red pública.
Permiso de firma
La vía intermedia para que solo tus propias aplicaciones puedan invocar una capacidad interna.
Hay una diferencia de naturaleza entre el código de una aplicación y su manifiesto que rara vez se explicita y que explica por qué los errores de esta lección son tan caros. El código es privado: lo lees, lo cambias, lo refactorizas y nadie fuera del proceso se entera. El manifiesto es público, y no solo en el sentido de que se puede leer desde fuera, sino en el sentido mucho más fuerte de que sus declaraciones crean expectativas en terceros que no controlas y que no puedes avisar. Cuando declaras un filtro estás publicando una interfaz, y las interfaces publicadas tienen la incómoda propiedad de que solo se pueden ampliar, nunca reducir, porque en el momento en que otra aplicación —o un correo, o una página web, o un código de barras impreso en un cartel— empieza a emitir intents que confían en tu declaración, retirarla deja de ser una refactorización y pasa a ser una ruptura de contrato hacia gente que jamás supiste que existía. De ahí se derivan dos disciplinas que separan a los equipos que entienden la plataforma de los que la sufren. La primera es tratar cada filtro nuevo como se trata la publicación de un endpoint: con versión, con validación de entrada, con un registro de quién lo invoca y con una idea clara de qué ocurre si mañana llega un argumento que no previste. La segunda, más profunda, es aceptar que el manifiesto invierte el modelo mental habitual de la programación defensiva. Dentro del proceso puedes razonar sobre invariantes porque controlas todos los caminos que llevan a una función. Detrás de un filtro no controlas ninguno: el intent que llega puede haber sido construido por una aplicación maliciosa, por un enlace de un mensaje, por un desarrollador de otra empresa que malinterpretó tu documentación o por una versión tuya de hace tres años que sigue instalada en un dispositivo olvidado. La pantalla que atiende un filtro no es una pantalla más de tu aplicación: es el borde del sistema, y todo lo que se sabe sobre defender bordes se aplica ahí íntegramente, sin descuento por tratarse de código local. Quien interioriza eso escribe manifiestos aburridos y aplicaciones difíciles de romper; quien no lo hace descubre el problema en un informe de seguridad, que es el peor sitio y el peor momento posibles.
- Declara dos filtros separados en una actividad y demuestra con intents construidos a mano que fusionarlos en uno crea combinaciones inválidas.
- Escribe un filtro que solo declare esquema y comprueba qué acepta. Añade anfitrión, después ruta, y documenta la escalera de precisión con casos reales.
- Construye un intent con tipo MIME hacia un filtro sin tipos y al revés. Explica la asimetría y resuélvela con dos filtros.
- Retira
android:exportedde un componente con filtro en un proyecto con nivel de API 31 o superior y anota exactamente en qué momento falla. - Escribe desde una segunda aplicación un intent hostil contra un filtro tuyo, con extras ausentes y valores fuera de rango, y endurece la actividad hasta que ninguno consiga nada.