Publica el estado de seguridad del dispositivo

Si eres un fabricante de equipos originales (OEM) o mantienes un cliente de actualización inalámbrica (OTA) privilegiado, puedes brindar a las apps sensibles a la seguridad del dispositivo visibilidad de las actualizaciones de seguridad pendientes para que puedan evaluar con precisión la postura de seguridad del dispositivo. Para aplicar políticas sólidas de confianza cero, las apps deben poder verificar no solo el nivel de parche instalado en el dispositivo (nivel de parche de seguridad del dispositivo o DSPL), sino también qué actualizaciones de seguridad están disponibles y listas para instalarse (nivel de parche de seguridad disponible o ASPL).

Debido a que las apps cliente sin privilegios no pueden leer directamente las propiedades del firmware, inspeccionar las bases de datos privadas del actualizador ni consultar los extremos internos del backend del OEM, la biblioteca de AndroidX Security State Provider proporciona una arquitectura de comunicación entre procesos (IPC) estandarizada y segura que los clientes de actualización usan para compartir información sobre las actualizaciones disponibles. Si implementas un UpdateInfoService en tu cliente de actualización OTA, puedes publicar metadatos de ASPL para el sistema sin exponer integraciones de backend propietarias. Si bien Google proporciona la implementación de actualizadores de componentes modulares del sistema (Mainline) para dispositivos GMS, los dispositivos que no son GMS también pueden publicar metadatos de ASPL para estos componentes modulares del sistema.

Descripción general de la arquitectura

En el siguiente diagrama, se ilustra cómo la biblioteca de AndroidX Security State Provider establece un marco de IPC estandarizado y seguro entre las apps cliente sin privilegios y los servicios de actualización integrados en el dispositivo:

La biblioteca de AndroidX Security State Provider establece un framework de IPC seguro y estandarizado entre las apps cliente sin privilegios y los servicios de actualización integrados en el dispositivo.

Modelos de entrega de datos

Las apps cliente consultan la disponibilidad de actualizaciones llamando a queryAllAvailableUpdates o fetchAvailableSecurityPatchLevel. En segundo plano, la biblioteca cliente descubre automáticamente todos los servicios registrados que extienden la clase UpdateInfoService en el dispositivo desde las apps del sistema que tienen el permiso READ_PRIVILEGED_PHONE_STATE y se vincula a ellos.

Como se ilustra en el diagrama anterior, la biblioteca de security-state-provider admite dos modelos de entrega de datos:

Modelo de entrega Activador de sincronización Respuesta del cliente Casos de uso recomendados
Modelo de envío (sincronización en segundo plano) Los trabajadores en segundo plano programados (WorkManager o JobScheduler) se sincronizan con tu backend y escriben registros en UpdateInfoManager. Tu servicio siempre se entrega desde la memoria caché del disco local (shouldFetchUpdates() = false). Se entrega de inmediato desde la caché local. Actualizadores OTA del sistema del OEM y actualizadores de componentes modulares sincronizados en segundo plano.
Modelo de extracción (sincronización on demand) Las consultas de IPC del cliente entrantes activan una recuperación de red cuando los registros almacenados en caché están desactualizados (shouldFetchUpdates() = true). La combinación de mutex y la limitación de frecuencia (shouldThrottle()) protegen tu backend de los picos. Espera la recuperación del backend cuando la caché está inactiva. Actualizadores OTA monolíticos del OEM sin trabajadores de sincronización en segundo plano programados.

Varios proveedores de actualizaciones

En los dispositivos Android de producción, coexisten varios proveedores de actualizaciones independientes de forma simultánea. Por ejemplo, Mainline publica la disponibilidad de los componentes modulares (COMPONENT_SYSTEM_MODULES), mientras que tu cliente de OTA del OEM publica actualizaciones para la imagen del SO principal (COMPONENT_SYSTEM).

Tu servicio solo necesita registrar actualizaciones para los componentes específicos que administra. Si varios proveedores en un dispositivo publican actualizaciones para el mismo componente, las apps cliente evalúan el nivel de parche disponible más alto (con fetchAvailableSecurityPatchLevel()) o inspeccionan registros UpdateInfo individuales (con queryAllAvailableUpdates()) para la auditoría empresarial. Asegúrate de que tu servicio siempre publique el formato canónico de tu componente (DateBasedSecurityPatchLevel para COMPONENT_SYSTEM).

Guía paso a paso para integrar tu cliente de actualizaciones

Sigue estos pasos para integrar la biblioteca de AndroidX Security State Provider en tu cliente de actualización y comenzar a publicar la disponibilidad de las actualizaciones de seguridad de tu dispositivo.

Paso 1: Agrega dependencias

Para implementar un proveedor de actualizaciones, asegúrate de que tu proyecto incluya el repositorio de Maven de Google y, luego, agrega la biblioteca security-state-provider al archivo build.gradle.kts (Kotlin DSL) o build.gradle (Groovy DSL) de tu módulo:

Kotlin

// Kotlin DSL (build.gradle.kts)
dependencies {
    // Core provider library for OTA and system update clients
    implementation("androidx.security:security-state-provider:1.0.0")
    // Required to construct UpdateInfo and DateBasedSecurityPatchLevel records
    implementation("androidx.security:security-state:1.1.0")
    // Optional: Guava ListenableFuture support for Java implementations
    implementation("androidx.concurrent:concurrent-futures:1.2.0")
    implementation("com.google.guava:guava:33.0.0-android")
}

Groovy

// Groovy DSL (build.gradle)
dependencies {
    // Core provider library for OTA and system update clients
    implementation 'androidx.security:security-state-provider:1.0.0'
    // Required to construct UpdateInfo and DateBasedSecurityPatchLevel records
    implementation 'androidx.security:security-state:1.1.0'
    // Optional: Guava ListenableFuture support for Java implementations
    implementation 'androidx.concurrent:concurrent-futures:1.2.0'
    implementation 'com.google.guava:guava:33.0.0-android'
}

Paso 2: Declara el servicio de actualización en tu manifiesto

Declara tu servicio en el AndroidManifest.xml de tu app con un <intent-filter> que coincida con androidx.security.state.provider.UPDATE_INFO_SERVICE. El servicio debe exportarse (android:exported="true") y configurarse como un servicio de un solo usuario (android:singleUser="true") para que la biblioteca cliente pueda vincularse a él a través de los límites del proceso y del usuario, en especial para los perfiles de trabajo:

<!-- AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.android.updater">
    <application>
        <service
            android:name=".MyUpdateInfoService"
            android:exported="true"
            android:singleUser="true"
            tools:ignore="ExportedService">
            <intent-filter>
                <action android:name="androidx.security.state.provider.UPDATE_INFO_SERVICE" />
            </intent-filter>
        </service>
    </application>
</manifest>

Si tu actualizador no se ejecuta como android.uid.system, también debes declarar los siguientes permisos en tu manifiesto y asegurarte de agregarlos a tu lista de entidades permitidas de permisos privilegiados:

  • READ_PRIVILEGED_PHONE_STATE: Se requiere para que los clientes confíen en tu proveedor.
  • INTERACT_ACROSS_USERS: Se requiere para android:singleUser="true".

Paso 3: Implementa UpdateInfoService

Para publicar el estado de actualización, debes implementar la clase UpdateInfoService y crear registros de actualización que coincidan con el modelo de datos esperado.

Especificación del modelo de datos de UpdateInfo

Ya sea que elijas el modelo Push o el modelo Pull, construye registros UpdateInfo con UpdateInfo.Builder según la siguiente especificación:

Nombre del campo Método getter Tipo de dato Requisitos de validación y formato Semántica del sistema y del propósito
component getComponent() String (@Component) Constantes canónicas en SecurityPatchState: COMPONENT_SYSTEM, COMPONENT_SYSTEM_MODULES o COMPONENT_KERNEL. Identifica el subsistema de software o firmware al que se dirige esta actualización.
securityPatchLevel getSecurityPatchLevel() SecurityPatchLevel Debe ser una instancia de DateBasedSecurityPatchLevel (YYYY-MM-DD) o VersionedSecurityPatchLevel (major.minor.patch), o bien se debe analizar con SecurityPatchState.getComponentSecurityPatchLevel(). Es el nivel de parche de seguridad objetivo que se alcanzará una vez que se instale esta actualización.
publishedDateMillis getPublishedDateMillis() long Milisegundos desde la época de Unix (System.currentTimeMillis()). Debe ser > 0. Fecha y hora en que la actualización estuvo disponible para los usuarios, como la hora de lanzamiento de la OTA. No uses el tiempo de descarga o instalación de la carga útil.
lastCheckTimeMillis getLastCheckTimeMillis() long Milisegundos desde la época de Unix. Debe ser > 0. Es la marca de tiempo en la que tu proveedor verificó o descubrió este registro de actualización durante la sincronización.

Elige el modelo de entrega que se adapte a la arquitectura de tu actualizador entre las siguientes opciones:

Opción A: Modelo push (recomendado)

Cuando el worker de sincronización en segundo plano verifique tu servidor OTA, valida que cualquier actualización descubierta avance el nivel de parche actual del dispositivo y persístelo con UpdateInfoManager.registerUpdate(), o llama a UpdateInfoManager.unregisterUpdate() si no hay ninguna actualización de seguridad pendiente. Siempre llama a UpdateInfoManager.setLastCheckTimeMillis() al final de cada sincronización (incluso después de llamar a registerUpdate(), que persiste el registro UpdateInfo por componente, pero no actualiza la marca de tiempo global de la última verificación que se devuelve a los clientes). Puedes implementar esto con un WorkManager CoroutineWorker en Kotlin o Worker en Java:

Kotlin

import android.content.Context
import androidx.security.state.SecurityPatchState
import androidx.security.state.SecurityPatchState.DateBasedSecurityPatchLevel
import androidx.security.state.UpdateInfo
import androidx.security.state.provider.UpdateInfoManager
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import kotlin.math.max

class OtaSyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result {
        val updateInfoManager = UpdateInfoManager(applicationContext)
        val securityPatchState = SecurityPatchState(applicationContext)
        val currentSpl = securityPatchState.getDeviceSecurityPatchLevel(SecurityPatchState.COMPONENT_SYSTEM)

        // 1. Fetch available update metadata from OEM backend
        val latestUpdate = MyOtaClient.fetchLatestSystemUpdate()
        val targetSplString = latestUpdate?.spl?.trim()
        val targetSpl = if (!targetSplString.isNullOrEmpty()) {
            DateBasedSecurityPatchLevel.fromString(targetSplString)
        } else {
            null
        }

        // 2. Defensively verify that target SPL is non-blank AND strictly newer than installed DSPL.
        // If an update is a maintenance patch with no SPL increment (or if no update is available),
        // unregister any stale cached record for this component.
        if (latestUpdate != null && targetSpl != null && targetSpl > currentSpl) {
            val updateInfo = UpdateInfo.Builder()
                .setComponent(SecurityPatchState.COMPONENT_SYSTEM)
                .setSecurityPatchLevel(targetSpl)
                .setPublishedDateMillis(latestUpdate.releaseTimeMillis)
                .setLastCheckTimeMillis(System.currentTimeMillis())
                .build()
            updateInfoManager.registerUpdate(updateInfo)
        } else {
            val clearTarget = UpdateInfo.Builder()
                .setComponent(SecurityPatchState.COMPONENT_SYSTEM)
                .build()
            updateInfoManager.unregisterUpdate(clearTarget)
        }

        // 3. Update global freshness timestamp (monotonic synchronization)
        val currentCheckTime = System.currentTimeMillis()
        val previousCheckTime = updateInfoManager.getLastCheckTimeMillis()
        updateInfoManager.setLastCheckTimeMillis(max(previousCheckTime, currentCheckTime))
        return Result.success()
    }
}

Java

import android.content.Context;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.security.state.SecurityPatchState;
import androidx.security.state.SecurityPatchState.DateBasedSecurityPatchLevel;
import androidx.security.state.SecurityPatchState.SecurityPatchLevel;
import androidx.security.state.UpdateInfo;
import androidx.security.state.provider.UpdateInfoManager;
import androidx.work.Worker;
import androidx.work.WorkerParameters;

public class OtaSyncWorker extends Worker {
    public OtaSyncWorker(@NonNull Context context, @NonNull WorkerParameters params) {
        super(context, params);
    }

    @NonNull
    @Override
    public Result doWork() {
        // In Java, pass null for customSecurityState because UpdateInfoManager does not declare @JvmOverloads
        UpdateInfoManager updateInfoManager =
                new UpdateInfoManager(getApplicationContext(), /* customSecurityState= */ null);
        SecurityPatchState securityPatchState = new SecurityPatchState(getApplicationContext());
        SecurityPatchLevel currentSpl =
                securityPatchState.getDeviceSecurityPatchLevel(SecurityPatchState.COMPONENT_SYSTEM);

        // 1. Fetch available update metadata from OEM backend
        MyOtaUpdate latestUpdate = MyOtaClient.fetchLatestSystemUpdate();
        String targetSplString = (latestUpdate != null && latestUpdate.getSpl() != null)
                ? latestUpdate.getSpl().trim()
                : null;
        DateBasedSecurityPatchLevel targetSpl =
                !TextUtils.isEmpty(targetSplString)
                        ? DateBasedSecurityPatchLevel.fromString(targetSplString)
                        : null;

        // 2. Defensively verify that target SPL is non-blank AND strictly newer than installed DSPL.
        // If an update is a maintenance patch with no SPL increment (or if no update is available),
        // unregister any stale cached record for this component.
        if (latestUpdate != null && targetSpl != null && targetSpl.compareTo(currentSpl) > 0) {
            UpdateInfo updateInfo = new UpdateInfo.Builder()
                    .setComponent(SecurityPatchState.COMPONENT_SYSTEM)
                    .setSecurityPatchLevel(targetSpl)
                    .setPublishedDateMillis(latestUpdate.getReleaseTimeMillis())
                    .setLastCheckTimeMillis(System.currentTimeMillis())
                    .build();
            updateInfoManager.registerUpdate(updateInfo);
        } else {
            UpdateInfo clearTarget = new UpdateInfo.Builder()
                    .setComponent(SecurityPatchState.COMPONENT_SYSTEM)
                    .build();
            updateInfoManager.unregisterUpdate(clearTarget);
        }

        // 3. Update global freshness timestamp (monotonic synchronization)
        long currentCheckTime = System.currentTimeMillis();
        long previousCheckTime = updateInfoManager.getLastCheckTimeMillis();
        updateInfoManager.setLastCheckTimeMillis(Math.max(previousCheckTime, currentCheckTime));
        return Result.success();
    }
}

En un modelo basado en la transmisión, las tareas en segundo plano conservan los registros de actualización directamente en UpdateInfoManager. Para indicarle al framework que siempre publique registros desde el almacenamiento en disco local, anula shouldFetchUpdates() para que devuelva false extendiendo UpdateInfoService en Kotlin o ListenableFutureUpdateInfoService en Java:

Kotlin

import androidx.security.state.UpdateInfo
import androidx.security.state.provider.UpdateInfoService

class PushUpdateInfoService : UpdateInfoService() {
    // Cache is populated out-of-band by background sync tasks
    override fun shouldFetchUpdates(): Boolean = false

    // Never invoked under normal flow because shouldFetchUpdates() returns false
    override suspend fun fetchUpdates(): List<UpdateInfo> = emptyList()
}

Java

import androidx.annotation.NonNull;
import androidx.security.state.UpdateInfo;
import androidx.security.state.provider.ListenableFutureUpdateInfoService;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.Collections;
import java.util.List;

public class PushUpdateInfoService extends ListenableFutureUpdateInfoService {
    @Override
    protected boolean shouldFetchUpdates() {
        return false;
    }

    @NonNull
    @Override
    protected ListenableFuture<List<UpdateInfo>> fetchUpdatesAsync() {
        return Futures.immediateFuture(Collections.emptyList());
    }
}

Opción B: Modelo de extracción (a pedido)

En una arquitectura basada en extracción, tu servicio controla las solicitudes de actualización a pedido que activan las apps cliente cuando la caché local está desactualizada.

Para controlar las consultas de actualización a pedido, extiende UpdateInfoService en Kotlin (implementa la función de suspensión fetchUpdates()) o ListenableFutureUpdateInfoService en Java (implementa fetchUpdatesAsync() que devuelve un ListenableFuture de Guava):

Kotlin

package com.example.android.updater

import androidx.security.state.SecurityPatchState
import androidx.security.state.SecurityPatchState.DateBasedSecurityPatchLevel
import androidx.security.state.UpdateInfo
import androidx.security.state.provider.UpdateInfoManager
import androidx.security.state.provider.UpdateInfoService
import java.util.concurrent.TimeUnit

class MyUpdateInfoService : UpdateInfoService() {
    // Manage local update records and check timestamps
    private val updateInfoManager by lazy { UpdateInfoManager(this) }

    override suspend fun fetchUpdates(): List<UpdateInfo> {
        val currentSpl = SecurityPatchState(this)
            .getDeviceSecurityPatchLevel(SecurityPatchState.COMPONENT_SYSTEM)

        // 1. Execute network request to OTA backend
        val response = MyOtaBackendClient.checkAvailableUpdates()

        // 2. Defensively filter out blank or non-advancing SPLs and map to UpdateInfo objects
        val validUpdates = response.updates
            .mapNotNull { updateItem ->
                val splString = updateItem.targetSpl?.trim()
                if (splString.isNullOrEmpty()) return@mapNotNull null
                val parsedSpl = DateBasedSecurityPatchLevel.fromString(splString)
                if (parsedSpl > currentSpl) {
                    UpdateInfo.Builder()
                        .setComponent(SecurityPatchState.COMPONENT_SYSTEM)
                        .setSecurityPatchLevel(parsedSpl)
                        .setPublishedDateMillis(updateItem.releaseTimestampMillis)
                        .setLastCheckTimeMillis(System.currentTimeMillis())
                        .build()
                } else {
                    null
                }
            }

        // 3. If no advancing SYSTEM update is available (or if a previously offered update was revoked),
        // proactively unregister any cached record for this component.
        if (validUpdates.isEmpty()) {
            val clearTarget = UpdateInfo.Builder()
                .setComponent(SecurityPatchState.COMPONENT_SYSTEM)
                .build()
            updateInfoManager.unregisterUpdate(clearTarget)
        }
        return validUpdates
    }

    override fun shouldFetchUpdates(): Boolean {
        // Enforce custom freshness threshold (for example, 4 hours instead of default 1 hour)
        val lastCheckMillis = updateInfoManager.getLastCheckTimeMillis()
        val dataAge = System.currentTimeMillis() - lastCheckMillis
        return dataAge > TimeUnit.HOURS.toMillis(4)
    }
}

Java

package com.example.android.updater;

import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.security.state.SecurityPatchState;
import androidx.security.state.SecurityPatchState.DateBasedSecurityPatchLevel;
import androidx.security.state.SecurityPatchState.SecurityPatchLevel;
import androidx.security.state.UpdateInfo;
import androidx.security.state.provider.ListenableFutureUpdateInfoService;
import androidx.security.state.provider.UpdateInfoManager;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class MyUpdateInfoService extends ListenableFutureUpdateInfoService {
    private UpdateInfoManager updateInfoManager;

    @Override
    public void onCreate() {
        super.onCreate();
        // Pass null for customSecurityState because UpdateInfoManager does not declare @JvmOverloads
        updateInfoManager = new UpdateInfoManager(this, /* customSecurityState= */ null);
    }

    @NonNull
    @Override
    protected ListenableFuture<List<UpdateInfo>> fetchUpdatesAsync() {
        try {
            SecurityPatchLevel currentSpl = new SecurityPatchState(this)
                    .getDeviceSecurityPatchLevel(SecurityPatchState.COMPONENT_SYSTEM);
            MyOtaBackendResponse response = MyOtaBackendClient.checkAvailableUpdates();
            List<UpdateInfo> updates = new ArrayList<>();
            for (MyOtaUpdateItem item : response.getUpdates()) {
                String trimmedSpl = (item.getTargetSpl() != null) ? item.getTargetSpl().trim() : null;
                if (!TextUtils.isEmpty(trimmedSpl)) {
                    DateBasedSecurityPatchLevel parsedSpl =
                            DateBasedSecurityPatchLevel.fromString(trimmedSpl);
                    if (parsedSpl.compareTo(currentSpl) > 0) {
                        updates.add(new UpdateInfo.Builder()
                                .setComponent(SecurityPatchState.COMPONENT_SYSTEM)
                                .setSecurityPatchLevel(parsedSpl)
                                .setPublishedDateMillis(item.getReleaseTimestampMillis())
                                .setLastCheckTimeMillis(System.currentTimeMillis())
                                .build());
                    }
                }
            }

            // If no advancing SYSTEM update is available (or if a previously offered update was revoked),
            // proactively unregister any cached record for this component.
            if (updates.isEmpty()) {
                UpdateInfo clearTarget = new UpdateInfo.Builder()
                        .setComponent(SecurityPatchState.COMPONENT_SYSTEM)
                        .build();
                updateInfoManager.unregisterUpdate(clearTarget);
            }
            return Futures.immediateFuture(updates);
        } catch (Exception e) {
            return Futures.immediateFailedFuture(e);
        }
    }

    @Override
    protected boolean shouldFetchUpdates() {
        long lastCheckMillis = updateInfoManager.getLastCheckTimeMillis();
        long dataAge = System.currentTimeMillis() - lastCheckMillis;
        return dataAge > TimeUnit.HOURS.toMillis(4);
    }
}

Paso 4: Borra las actualizaciones aplicadas después de reiniciar el dispositivo

Si bien UpdateInfoManager poda automáticamente las actualizaciones obsoletas cada vez que se invoca registerUpdate(), tu actualizador no volverá a llamar a registerUpdate() después de que finalice la instalación de una actualización OTA hasta el próximo ciclo de sincronización programado del servidor. Para evitar que las apps cliente vean una actualización ya instalada como pendiente inmediatamente después del reinicio, escucha ACTION_BOOT_COMPLETED y llama a UpdateInfoManager.unregisterUpdate() cuando finalice la instalación de una actualización OTA para borrar el registro de la caché local. Hacer esto solo cuando finaliza la instalación de una actualización evita borrar incondicionalmente las actualizaciones pendientes (sin instalar) en cada reinicio normal del dispositivo. Dado que las claves de UpdateInfoManager actualizan los registros por componente, solo debes especificar el componente de destino cuando construyas el objeto UpdateInfo para anular el registro:

Kotlin

// Build target identifying the component to unregister
val target = UpdateInfo.Builder()
    .setComponent(SecurityPatchState.COMPONENT_SYSTEM)
    .build()
// Unregister the update to remove it from disk cache
updateInfoManager.unregisterUpdate(target)
// Refresh last check timestamp to indicate up-to-date state
updateInfoManager.setLastCheckTimeMillis(System.currentTimeMillis())

Java

// Build target identifying the component to unregister
UpdateInfo target = new UpdateInfo.Builder()
    .setComponent(SecurityPatchState.COMPONENT_SYSTEM)
    .build();
// Unregister the update to remove it from disk cache
updateInfoManager.unregisterUpdate(target);
// Refresh last check timestamp to indicate up-to-date state
updateInfoManager.setLastCheckTimeMillis(System.currentTimeMillis());

Paso 5: Verifica tu integración

Ejecuta las siguientes verificaciones en un dispositivo Android o emulador con Android Debug Bridge (ADB) para validar la integración de extremo a extremo y evitar errores comunes en la implementación de OEM:

  1. Verifica que los clientes confíen en tu proveedor: Las apps cliente ignoran a cualquier proveedor que no tenga READ_PRIVILEGED_PHONE_STATE, incluso si está preinstalado. Confirma que se haya otorgado el permiso:

    adb shell dumpsys package <your_package_name> | grep "READ_PRIVILEGED_PHONE_STATE: granted=true"
    

    Luego, confirma que se pueda detectar tu servicio y que no tenga permisos de servicio. En el resultado de tu servicio, busca exported=true y permission=null:

    adb shell pm query-services --user 0 -a androidx.security.state.provider.UPDATE_INFO_SERVICE
    

    Si un cliente aún no ve tu proveedor, verifica si hay Ignoring untrusted update provider en logcat desde la etiqueta SecurityPatchState.

  2. Verifica la resolución de intents en el usuario 0 y en los perfiles de trabajo: Asegúrate de que el SO Android PackageManager resuelva tu filtro de intents UPDATE_INFO_SERVICE exportado en el usuario principal (User 0) y en cualquier perfil de trabajo de Android Enterprise activo (como User 10):

    adb shell pm query-services --user 0 -a androidx.security.state.provider.UPDATE_INFO_SERVICE
    adb shell pm query-services --user 10 -a androidx.security.state.provider.UPDATE_INFO_SERVICE
    
  3. Verifica el estado del servicio y los registros almacenados en caché con dumpsys: UpdateInfoService anula dump() para informar Global Last Check, Should Throttle (el estado del limitador de frecuencia) y Cached Updates. Dado que UpdateInfoService es un servicio vinculado y los clientes desvinculan inmediatamente después de realizar la consulta, dumpsys activity service genera (nothing) cuando no hay ningún cliente vinculado. Inicia el servicio de forma explícita antes de ejecutar dumpsys:

    adb shell am start-service -a androidx.security.state.provider.UPDATE_INFO_SERVICE <your_package_name>/.<service_class_name>
    adb shell dumpsys activity service <your_package_name>/.<service_class_name>
    

    Ejemplo de resultado del diagnóstico:

    UpdateInfoService State:
      Active Requests: 0
      Global Last Check: Thu Jan 01 12:00:00 UTC 2026
      Should Throttle: false
      Cached Updates (1):
        - Component: SYSTEM
          SPL: 2026-01-01
          Published: Thu Jan 01 00:00:00 UTC 2026
          Last Checked: Thu Jan 01 12:00:00 UTC 2026
    
  4. Activa la vinculación del cliente y verifica los resultados de la telemetría: Desde una app de prueba sin privilegios (que no tiene permisos de firma del sistema), invoca SecurityPatchState.queryAllAvailableUpdates(). Si implementaste las devoluciones de llamada de telemetría, verifica lo siguiente:

    • Verifica que el cliente sin privilegios se vincule sin un SecurityException y active onClientConnected(packageName, callerUid).
    • Para los proveedores del modelo de envío (shouldFetchUpdates() == false): Verifica que onRequestCompleted(telemetry) registre UpdateFetchOutcome.CACHE_HIT (1) con fetchDurationMillis == 0 en cada búsqueda.
    • Para los proveedores de modelos de extracción (shouldFetchUpdates() == true): Verifica que onRequestCompleted(telemetry) registre UpdateFetchOutcome.FETCHED (3) en la consulta inicial de caché obsoleta, seguida de CACHE_HIT (1) en las consultas posteriores inmediatas. (Para restablecer el límite de frecuencia persistente de 1 hora entre las ejecuciones de prueba del modelo de extracción, ejecuta adb shell pm clear <your_package_name>).

Configuración opcional y avanzada

Política de almacenamiento en caché y límite de frecuencia

Cuando un cliente consulta actualizaciones, UpdateInfoService ejecuta un flujo de trabajo de bloqueo verificado dos veces para equilibrar la actualización de los datos con la carga del servidor de backend:

UpdateInfoService ejecuta un flujo de trabajo de bloqueo verificado dos veces para equilibrar la actualización de los datos con la carga del servidor de backend

  • Ruta rápida (shouldFetchUpdates()): De forma predeterminada, shouldFetchUpdates() devuelve true (lo que indica una caché obsoleta) solo cuando el lastCheckTimeMillis global tiene más de 1 hora (TimeUnit.HOURS.toMillis(1)). Cuando shouldFetchUpdates() devuelve false, el servicio devuelve de inmediato los registros almacenados en caché con el resultado UpdateFetchOutcome.CACHE_HIT sin adquirir bloqueos ni realizar E/S de red. Puedes anular shouldFetchUpdates() para personalizar esta política de almacenamiento en caché.
  • Ruta lenta y coalescencia de solicitudes: Cuando shouldFetchUpdates() devuelve true, el servicio adquiere un mutex de corrutina interno y vuelve a evaluar shouldFetchUpdates() (devuelve UpdateFetchOutcome.COALESCED si una solicitud simultánea ya actualizó la caché mientras esperaba el bloqueo).
  • Limitador de frecuencia persistente (shouldThrottle()): Para proteger la infraestructura de backend de las ráfagas de consultas o las fallas repetidas, shouldThrottle() aplica un intervalo mínimo persistente de 1 hora en los reinicios de la app y el dispositivo. UpdateInfoService registra cada intento antes de invocar fetchUpdates(), por lo que, si fetchUpdates() arroja una excepción (devuelve UpdateFetchOutcome.FAILED después de invocar onFetchFailed(e)), las consultas posteriores durante los próximos 60 minutos devolverán con elegancia datos de resguardo almacenados en caché con el resultado UpdateFetchOutcome.THROTTLED.

Observabilidad, telemetría y diagnóstico

UpdateInfoService proporciona hooks de observabilidad integrados para hacer un seguimiento de la adopción del cliente, supervisar la latencia de la IPC y registrar errores de backend sin instrumentar los stubs de AIDL de bajo nivel:

Anula estas devoluciones de llamada en UpdateInfoService (Kotlin) o ListenableFutureUpdateInfoService (Java):

Kotlin

import androidx.security.state.provider.UpdateCheckTelemetry
import androidx.security.state.provider.UpdateFetchOutcome
import androidx.security.state.provider.UpdateInfoService

abstract class MonitoredUpdateInfoService : UpdateInfoService() {
    override fun onRequestCompleted(telemetry: UpdateCheckTelemetry) {
        val outcomeName = when (telemetry.outcome) {
            UpdateFetchOutcome.CACHE_HIT -> "CACHE_HIT"
            UpdateFetchOutcome.COALESCED -> "COALESCED"
            UpdateFetchOutcome.FETCHED -> "FETCHED"
            UpdateFetchOutcome.THROTTLED -> "THROTTLED"
            UpdateFetchOutcome.FAILED -> "FAILED"
            else -> "UNKNOWN"
        }
        MyAnalytics.logEvent("SECURITY_UPDATE_CHECK")
            .addParam("outcome", outcomeName)
            .addParam("total_duration_ms", telemetry.totalDurationMillis)
            .addParam("lock_wait_ms", telemetry.lockWaitDurationMillis)
            .addParam("processing_ms", telemetry.processingDurationMillis)
            .addParam("fetch_duration_ms", telemetry.fetchDurationMillis)
            .addParam("caller_uid", telemetry.callerUid)
            .send()
    }

    override fun onClientConnected(packageName: String, callerUid: Int) {
        // Track authenticated client sessions and adoption
        MyMetrics.incrementCounter("client_connected", "package", packageName)
    }

    override fun onClientDisconnected(packageName: String, callerUid: Int) {
        // Track session termination and cleanup resources
        MyMetrics.incrementCounter("client_disconnected", "package", packageName)
    }

    override fun onFetchFailed(e: Exception) {
        // Report exceptions caught during the update check workflow
        MyCrashReporter.recordException(e)
    }
}

Java

import androidx.annotation.NonNull;
import androidx.security.state.provider.ListenableFutureUpdateInfoService;
import androidx.security.state.provider.UpdateCheckTelemetry;
import androidx.security.state.provider.UpdateFetchOutcome;

public abstract class MonitoredUpdateInfoService extends ListenableFutureUpdateInfoService {
    @Override
    protected void onRequestCompleted(@NonNull UpdateCheckTelemetry telemetry) {
        String outcomeName;
        switch (telemetry.getOutcome()) {
            case UpdateFetchOutcome.CACHE_HIT: outcomeName = "CACHE_HIT"; break;
            case UpdateFetchOutcome.COALESCED: outcomeName = "COALESCED"; break;
            case UpdateFetchOutcome.FETCHED: outcomeName = "FETCHED"; break;
            case UpdateFetchOutcome.THROTTLED: outcomeName = "THROTTLED"; break;
            case UpdateFetchOutcome.FAILED: outcomeName = "FAILED"; break;
            default: outcomeName = "UNKNOWN"; break;
        }
        MyAnalytics.logEvent("SECURITY_UPDATE_CHECK")
            .addParam("outcome", outcomeName)
            .addParam("total_duration_ms", telemetry.getTotalDurationMillis())
            .addParam("lock_wait_ms", telemetry.getLockWaitDurationMillis())
            .addParam("processing_ms", telemetry.getProcessingDurationMillis())
            .addParam("fetch_duration_ms", telemetry.getFetchDurationMillis())
            .addParam("caller_uid", telemetry.getCallerUid())
            .send();
    }

    @Override
    protected void onClientConnected(@NonNull String packageName, int callerUid) {
        MyMetrics.incrementCounter("client_connected", "package", packageName);
    }

    @Override
    protected void onClientDisconnected(@NonNull String packageName, int callerUid) {
        MyMetrics.incrementCounter("client_disconnected", "package", packageName);
    }

    @Override
    protected void onFetchFailed(@NonNull Exception e) {
        MyCrashReporter.recordException(e);
    }
}

Resultados de la telemetría y métricas de latencia

UpdateCheckTelemetry mide las duraciones transcurridas monótonas (SystemClock.elapsedRealtime()) y registra uno de los cinco resultados definidos en UpdateFetchOutcome:

Constante de resultado @IntDef Código Propiedades de métricas registradas Descripción y estado del sistema
UpdateFetchOutcome.CACHE_HIT 1 totalDurationMillis, processingDurationMillis, callerUid Se entrega de inmediato desde la caché de la memoria del disco o la memoria local en la ruta rápida (se devolvió shouldFetchUpdates() false). lockWaitDurationMillis y fetchDurationMillis son 0.
UpdateFetchOutcome.COALESCED 2 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, callerUid La consulta se puso en cola detrás de otra actualización activa. Cuando se adquirió el bloqueo, los datos estaban actualizados. Se evitó la recuperación duplicada de la red (fetchDurationMillis es 0).
UpdateFetchOutcome.FETCHED 3 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, fetchDurationMillis, callerUid La sincronización de la red de backend se ejecutó correctamente (se completó fetchUpdates()). Se conservaron registros nuevos en el disco.
UpdateFetchOutcome.THROTTLED 4 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, callerUid El limitador de frecuencia bloqueó la solicitud (se devolvió shouldThrottle() true). Los datos almacenados en caché se devolvieron de forma segura al cliente (fetchDurationMillis es 0).
UpdateFetchOutcome.FAILED 5 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, fetchDurationMillis, callerUid La verificación de actualización o la solicitud de red generaron una excepción. El firewall de excepciones lo detectó, se activó onFetchFailed(e) y se devolvió la resiliencia almacenada en caché.

Hook de vinculación avanzada del agente de servicio: getCallerUid()

Cuando un cliente se conecta, UpdateInfoService evalúa automáticamente getCallerUid() en el subproceso inicial de Binder (antes de llamar a Binder.clearCallingIdentity() antes de fetchUpdates()), verifica la propiedad del paquete y pasa el UID del llamador verificado directamente a onClientConnected(), onClientDisconnected() y telemetry.callerUid en onRequestCompleted(telemetry).

En el caso de los componentes <service> estándar de Android, no es necesario que llames ni anules getCallerUid(). El método protected open getCallerUid() (que delega en Binder.getCallingUid() de forma predeterminada) se proporciona como un hook de anulación para las apps host que enrutan la comunicación entre procesos (IPC) de Binder a través de un agente de servicio interno o una arquitectura de proxy, lo que permite que la subclase devuelva el UID del cliente lógico en lugar del UID del agente.

Recursos adicionales

Para obtener más información sobre el estado de seguridad de la publicación, consulta los siguientes recursos:

Documentación

Referencia de la API