Publier l'état de sécurité de l'appareil

Si vous êtes un fabricant d'équipement d'origine (OEM) ou que vous gérez un client de mise à jour OTA (Over-The-Air) privilégié, vous pouvez donner aux applications sensibles à la sécurité sur l'appareil une visibilité sur les mises à jour de sécurité en attente afin qu'elles puissent évaluer précisément la posture de sécurité de l'appareil. Pour appliquer des règles strictes de type "zero trust", les applications doivent pouvoir vérifier non seulement le niveau du correctif installé sur l'appareil (niveau du correctif de sécurité de l'appareil ou DSPL), mais aussi les mises à jour de sécurité disponibles et prêtes à être installées (niveau du correctif de sécurité disponible ou ASPL).

Étant donné que les applications clientes non privilégiées ne peuvent pas lire directement les propriétés du micrologiciel, inspecter les bases de données privées du programme de mise à jour ni interroger les points de terminaison internes du backend OEM, la bibliothèque AndroidX Security State Provider fournit une architecture de communication interprocessus (IPC) standardisée et sécurisée que les clients de mise à jour utilisent pour partager des informations sur les mises à jour disponibles. En implémentant un UpdateInfoService dans votre client de mise à jour OTA, vous pouvez publier des métadonnées ASPL pour le système sans exposer les intégrations de backend propriétaires. Bien que Google fournisse une implémentation pour les mises à jour des composants système modulaires (Mainline) pour les appareils GMS, les appareils non GMS peuvent également publier des métadonnées ASPL pour ces composants système modulaires.

Présentation de l'architecture

Le schéma suivant illustre la façon dont la bibliothèque AndroidX Security State Provider établit un framework IPC sécurisé et standardisé entre les applications clientes non privilégiées et les services de mise à jour sur l'appareil :

La bibliothèque AndroidX Security State Provider établit un framework IPC sécurisé et standardisé entre les applications clientes non privilégiées et les services de mise à jour sur l'appareil.

Modèles de diffusion des données

Les applications clientes interrogent la disponibilité des mises à jour en appelant queryAllAvailableUpdates ou fetchAvailableSecurityPatchLevel. En arrière-plan, la bibliothèque cliente détecte automatiquement tous les services enregistrés qui étendent la classe UpdateInfoService sur l'appareil à partir des applications système qui détiennent l'autorisation READ_PRIVILEGED_PHONE_STATE et s'y lie.

Comme illustré dans le schéma précédent, la bibliothèque security-state-provider est compatible avec deux modèles de diffusion de données :

Modèle de diffusion Déclencheur de synchronisation Réponse du client Cas d'utilisation recommandés
Modèle push (synchronisation en arrière-plan) Les nœuds de calcul en arrière-plan planifiés (WorkManager ou JobScheduler) se synchronisent avec votre backend et écrivent des enregistrements dans UpdateInfoManager. Votre service diffuse toujours le contenu à partir du cache du disque local (shouldFetchUpdates() = false). Elles sont diffusées immédiatement à partir du cache local. Les outils de mise à jour OTA du système OEM et les outils de mise à jour des composants modulaires synchronisés en arrière-plan.
Modèle pull (synchronisation à la demande) Les requêtes IPC client entrantes déclenchent une récupération réseau lorsque les enregistrements mis en cache sont obsolètes (shouldFetchUpdates() = true). La coalescence des mutex et la limitation du débit (shouldThrottle()) protègent votre backend contre les pics. Attend la récupération du backend lorsque le cache est obsolète. Mises à jour OTA OEM monolithiques sans planificateurs de synchronisation en arrière-plan.

Plusieurs fournisseurs de mises à jour

Sur les appareils Android de production, plusieurs fournisseurs de mises à jour indépendants coexistent simultanément. Par exemple, Mainline publie la disponibilité des composants modulaires (COMPONENT_SYSTEM_MODULES), tandis que votre client OTA OEM publie les mises à jour de l'image OS principale (COMPONENT_SYSTEM).

Votre service n'a besoin d'enregistrer les mises à jour que pour les composants spécifiques qu'il gère. Si plusieurs fournisseurs sur un appareil publient des mises à jour pour le même composant, les applications clientes évaluent le niveau de correctif le plus élevé disponible (à l'aide de fetchAvailableSecurityPatchLevel()) ou inspectent les enregistrements UpdateInfo individuels (à l'aide de queryAllAvailableUpdates()) pour l'audit d'entreprise. Assurez-vous que votre service publie toujours le format canonique de votre composant (DateBasedSecurityPatchLevel pour COMPONENT_SYSTEM).

Guide par étapes pour intégrer votre client de mise à jour

Suivez ces étapes pour intégrer la bibliothèque AndroidX Security State Provider à votre client de mise à jour et commencer à publier la disponibilité des mises à jour de sécurité de votre appareil.

Étape 1 : ajoutez des dépendances

Pour implémenter un fournisseur de mise à jour, assurez-vous que votre projet inclut le dépôt Maven de Google, puis ajoutez la bibliothèque security-state-provider au fichier build.gradle.kts (DSL Kotlin) ou build.gradle (DSL Groovy) de votre module :

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'
}

Étape 2 : Déclarez le service de mise à jour dans votre fichier manifeste

Déclarez votre service dans le AndroidManifest.xml de votre application avec un <intent-filter> correspondant à androidx.security.state.provider.UPDATE_INFO_SERVICE. Le service doit être exporté (android:exported="true") et configuré en tant que service mono-utilisateur (android:singleUser="true") afin que la bibliothèque cliente puisse s'y lier au-delà des limites de processus et d'utilisateur, en particulier pour les profils professionnels :

<!-- 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 votre programme de mise à jour ne s'exécute pas en tant que android.uid.system, déclarez également les autorisations suivantes dans votre fichier manifeste et assurez-vous de les ajouter à votre liste d'autorisation des autorisations privilégiées :

  • READ_PRIVILEGED_PHONE_STATE : obligatoire pour que les clients fassent confiance à votre fournisseur.
  • INTERACT_ACROSS_USERS : obligatoire pour android:singleUser="true".

Étape 3 : Implémenter UpdateInfoService

Pour publier l'état de votre mise à jour, vous devez implémenter la classe UpdateInfoService et créer des enregistrements de mise à jour qui correspondent au modèle de données attendu.

Spécification du modèle de données UpdateInfo

Que vous choisissiez le modèle Push ou Pull, construisez des enregistrements UpdateInfo à l'aide de UpdateInfo.Builder en suivant les spécifications suivantes :

Field Name (Nom du champ) Méthode Getter Type de données Exigences concernant la validation et le format Finalité et sémantique du système
component getComponent() String (@Component) Constantes canoniques dans SecurityPatchState : COMPONENT_SYSTEM, COMPONENT_SYSTEM_MODULES ou COMPONENT_KERNEL. Identifie le sous-système logiciel ou micrologiciel ciblé par cette mise à jour.
securityPatchLevel getSecurityPatchLevel() SecurityPatchLevel Doit être une instance de DateBasedSecurityPatchLevel (YYYY-MM-DD) ou VersionedSecurityPatchLevel (major.minor.patch), ou être analysé à l'aide de SecurityPatchState.getComponentSecurityPatchLevel(). Niveau du correctif de sécurité cible qui sera atteint une fois cette mise à jour installée.
publishedDateMillis getPublishedDateMillis() long Millisecondes depuis l'epoch Unix (System.currentTimeMillis()). Doit être > 0. Date à laquelle la mise à jour a été mise à la disposition des utilisateurs, par exemple l'heure de publication OTA. N'utilisez pas le temps de téléchargement ni d'installation de la charge utile.
lastCheckTimeMillis getLastCheckTimeMillis() long Millisecondes depuis l'epoch Unix. doit être > 0 Code temporel indiquant quand votre fournisseur a vérifié ou découvert cet enregistrement de mise à jour lors de la synchronisation.

Choisissez le modèle de diffusion qui correspond à l'architecture de votre programme de mise à jour parmi les options suivantes :

Option A : Modèle Push (recommandé)

Lorsque votre worker de synchronisation en arrière-plan vérifie votre serveur OTA, validez toute mise à jour découverte qui fait progresser le niveau de correctif actuel de l'appareil et conservez-le à l'aide de UpdateInfoManager.registerUpdate(), ou appelez UpdateInfoManager.unregisterUpdate() si aucune mise à jour de sécurité avancée n'est en attente. Appelez toujours UpdateInfoManager.setLastCheckTimeMillis() à la fin de chaque synchronisation (même après avoir appelé registerUpdate(), qui conserve l'enregistrement UpdateInfo par composant, mais ne met pas à jour le code temporel de la dernière vérification globale renvoyé aux clients). Vous pouvez implémenter cela avec un WorkManager CoroutineWorker en Kotlin ou un 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();
    }
}

Dans un modèle basé sur le push, les tâches en arrière-plan conservent les enregistrements de mise à jour directement dans UpdateInfoManager. Pour indiquer au framework de toujours diffuser les enregistrements à partir du stockage sur disque local, remplacez shouldFetchUpdates() pour renvoyer false en étendant UpdateInfoService en Kotlin ou 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());
    }
}

Option B : Modèle pull (à la demande)

Dans une architecture basée sur l'extraction, votre service gère les demandes d'actualisation à la demande déclenchées par les applications clientes lorsque le cache local est obsolète.

Pour gérer les requêtes de mise à jour à la demande, étendez UpdateInfoService en Kotlin (en implémentant la fonction de suspension fetchUpdates()) ou ListenableFutureUpdateInfoService en Java (en implémentant fetchUpdatesAsync() renvoyant un ListenableFuture 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);
    }
}

Étape 4 : Supprimez les mises à jour appliquées après le redémarrage de l'appareil

Bien que UpdateInfoManager supprime automatiquement les mises à jour obsolètes chaque fois que registerUpdate() est appelé, votre programme de mise à jour n'appellera plus registerUpdate() une fois l'installation d'une mise à jour OTA terminée, jusqu'au prochain cycle de synchronisation du serveur planifié. Pour empêcher les applications clientes de considérer une mise à jour déjà installée comme toujours en attente immédiatement après le redémarrage, écoutez ACTION_BOOT_COMPLETED et appelez UpdateInfoManager.unregisterUpdate() lorsqu'une mise à jour OTA a fini de s'installer pour effacer l'enregistrement du cache local. Cela permet d'éviter d'effacer inconditionnellement les mises à jour en attente (désinstallées) à chaque redémarrage normal de l'appareil. Étant donné que les clés UpdateInfoManager mettent à jour les enregistrements par composant, il vous suffit de spécifier le composant cible lors de la construction de l'objet UpdateInfo pour la désinscription :

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());

Étape 5 : Vérifiez votre intégration

Exécutez les vérifications suivantes sur un appareil ou un émulateur Android à l'aide d'Android Debug Bridge (ADB) pour valider l'intégration de bout en bout et éviter les pièges courants liés au déploiement OEM :

  1. Vérifiez que les clients font confiance à votre fournisseur : les applications clientes ignorent tout fournisseur qui ne détient pas READ_PRIVILEGED_PHONE_STATE, même s'il est préinstallé. Vérifiez que l'autorisation est accordée :

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

    Vérifiez ensuite que votre service est détectable et qu'il ne dispose d'aucune autorisation de service. Dans le résultat de votre service, recherchez exported=true et permission=null :

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

    Si un client ne voit toujours pas votre fournisseur, vérifiez si Ignoring untrusted update provider est présent dans logcat à partir de la balise SecurityPatchState.

  2. Vérifiez la résolution de l'intent dans le profil utilisateur 0 et le profil professionnel : assurez-vous que l'PackageManager du système d'exploitation Android résout votre filtre d'intent UPDATE_INFO_SERVICE exporté à la fois dans l'utilisateur principal (User 0) et dans tout profil professionnel Android Enterprise actif (tel que 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. Vérifiez l'état du service et les enregistrements mis en cache à l'aide de dumpsys : UpdateInfoService remplace dump() pour signaler Global Last Check, Should Throttle (l'état du limiteur de fréquence) et Cached Updates. Étant donné que UpdateInfoService est un service lié et que les clients se dissocient immédiatement après l'interrogation, dumpsys activity service génère (nothing) lorsqu'aucun client n'est lié. Démarrez explicitement le service avant d'exécuter 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>
    

    Exemple de résultat de diagnostic :

    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. Déclenchez l'association du client et vérifiez les résultats de la télémétrie : à partir d'une application de test non privilégiée (ne disposant pas des autorisations de signature système), appelez SecurityPatchState.queryAllAvailableUpdates(). Si vous avez implémenté les rappels de télémétrie, vérifiez les points suivants :

    • Vérifiez que le client non privilégié se lie sans SecurityException et déclenche onClientConnected(packageName, callerUid).
    • Pour les fournisseurs de modèle Push (shouldFetchUpdates() == false) : vérifiez que les journaux onRequestCompleted(telemetry) UpdateFetchOutcome.CACHE_HIT (1) avec fetchDurationMillis == 0 sont présents pour chaque requête.
    • Pour les fournisseurs de modèle Pull (shouldFetchUpdates() == true) : vérifiez que les journaux onRequestCompleted(telemetry) UpdateFetchOutcome.FETCHED (3) s'affichent sur la requête initiale de cache obsolète, suivis de CACHE_HIT (1) sur les requêtes suivantes immédiates. (Pour réinitialiser le limiteur de fréquence persistant d'une heure entre les exécutions de tests du modèle Pull, exécutez adb shell pm clear <your_package_name>.)

Configurations facultatives et avancées

Règles de mise en cache et limitation du débit

Lorsqu'un client interroge les mises à jour, UpdateInfoService exécute un workflow de verrouillage à double vérification pour équilibrer la fraîcheur des données par rapport à la charge du serveur de backend :

UpdateInfoService exécute un workflow de verrouillage à double vérification pour équilibrer la fraîcheur des données et la charge du serveur de backend.

  • Chemin rapide (shouldFetchUpdates()) : par défaut, shouldFetchUpdates() renvoie true (indiquant un cache obsolète) uniquement lorsque le lastCheckTimeMillis global a plus d'une heure (TimeUnit.HOURS.toMillis(1)). Lorsque shouldFetchUpdates() renvoie false, le service renvoie immédiatement les enregistrements mis en cache avec le résultat UpdateFetchOutcome.CACHE_HIT sans acquérir de verrous ni effectuer d'E/S réseau. Vous pouvez remplacer shouldFetchUpdates() pour personnaliser cette stratégie de mise en cache.
  • Chemin lent et fusion des requêtes : lorsque shouldFetchUpdates() renvoie true, le service acquiert un mutex de coroutine interne et réévalue shouldFetchUpdates() (en renvoyant UpdateFetchOutcome.COALESCED si une requête simultanée a déjà actualisé le cache en attendant le verrou).
  • Limiteur de débit persistant (shouldThrottle()) : pour protéger l'infrastructure de backend contre les pics de requêtes ou les échecs répétés, shouldThrottle() applique un intervalle persistant d'une heure minimum lors des redémarrages d'applications et d'appareils. UpdateInfoService enregistre chaque tentative avant d'appeler fetchUpdates(). Par conséquent, si fetchUpdates() génère une exception (en renvoyant UpdateFetchOutcome.FAILED après avoir appelé onFetchFailed(e)), les requêtes suivantes au cours des 60 minutes suivantes renvoient gracieusement des données de secours mises en cache avec le résultat UpdateFetchOutcome.THROTTLED.

Observabilité, télémétrie et diagnostics

UpdateInfoService fournit des hooks d'observabilité intégrés pour suivre l'adoption par les clients, surveiller la latence IPC et enregistrer les erreurs de backend sans instrumenter les stubs AIDL de bas niveau :

Remplacez ces rappels dans UpdateInfoService (Kotlin) ou 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);
    }
}

Résultats de la télémétrie et métriques de latence

UpdateCheckTelemetry mesure les durées écoulées monotones (SystemClock.elapsedRealtime()) et renvoie l'un des cinq résultats définis dans UpdateFetchOutcome :

Constante de résultat @IntDef Code Propriétés des métriques enregistrées Description et état du système
UpdateFetchOutcome.CACHE_HIT 1 totalDurationMillis, processingDurationMillis, callerUid Diffusé immédiatement à partir du cache de disque/mémoire local sur le chemin rapide (shouldFetchUpdates() renvoyé false). lockWaitDurationMillis et fetchDurationMillis sont 0.
UpdateFetchOutcome.COALESCED 2 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, callerUid Requête mise en file d'attente derrière une autre actualisation active ; les données étaient à jour lors de l'acquisition du verrou. Récupération de réseau en double évitée (fetchDurationMillis est 0).
UpdateFetchOutcome.FETCHED 3 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, fetchDurationMillis et callerUid La synchronisation du réseau backend a bien été effectuée (fetchUpdates() terminé). Nouveaux enregistrements conservés sur le disque.
UpdateFetchOutcome.THROTTLED 4 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, callerUid Requête bloquée par le limiteur de débit (shouldThrottle() renvoie true). Les données mises en cache sont renvoyées de manière sécurisée au client (fetchDurationMillis est 0).
UpdateFetchOutcome.FAILED 5 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, fetchDurationMillis et callerUid Une exception a été générée lors de la vérification des mises à jour ou d'une requête réseau. Caught by exception firewall, fired onFetchFailed(e), returned cached fallback.

Hook d'agent de service avancé : getCallerUid()

Lorsqu'un client se connecte, UpdateInfoService évalue automatiquement getCallerUid() sur le thread Binder initial (avant d'appeler Binder.clearCallingIdentity() avant fetchUpdates()), vérifie la propriété du package et transmet l'UID de l'appelant validé directement à onClientConnected(), onClientDisconnected() et telemetry.callerUid dans onRequestCompleted(telemetry).

Pour les composants <service> Android standards, vous n'avez pas besoin d'appeler ni de remplacer getCallerUid(). La méthode protected open getCallerUid() (qui délègue à Binder.getCallingUid() par défaut) est fournie en tant que crochet de remplacement pour les applications hôtes qui acheminent Binder IPC via un agent de service interne ou une architecture de proxy, ce qui permet à la sous-classe de renvoyer l'UID du client logique plutôt que l'UID de l'agent.

Ressources supplémentaires

Pour en savoir plus sur la publication de l'état de sécurité, consultez les ressources suivantes :

Documentation

Documentation de référence de l'API