เผยแพร่สถานะความปลอดภัยของอุปกรณ์

หากคุณเป็นผู้ผลิตอุปกรณ์ดั้งเดิม (OEM) หรือดูแลไคลเอ็นต์การอัปเดตแบบ Over-The-Air (OTA) ที่มีสิทธิ์ คุณสามารถให้แอปที่มีความละเอียดอ่อนด้านความปลอดภัยในอุปกรณ์มองเห็นการอัปเดตความปลอดภัยที่รอดำเนินการ เพื่อให้แอปประเมินท่าทางด้านความปลอดภัยของอุปกรณ์ได้อย่างถูกต้อง หากต้องการบังคับใช้นโยบายแบบ Zero Trust ที่มีประสิทธิภาพ แอปต้องตรวจสอบได้ไม่เพียงแค่ระดับแพตช์ที่ติดตั้งในอุปกรณ์ (ระดับแพตช์ด้านความปลอดภัยของอุปกรณ์หรือ DSPL) แต่ยังต้องตรวจสอบได้ว่ามีการอัปเดตความปลอดภัยใดบ้าง ที่พร้อมใช้งานและพร้อมติดตั้ง (ระดับแพตช์ด้านความปลอดภัยที่พร้อมใช้งานหรือ ASPL)

เนื่องจากแอปไคลเอ็นต์ที่ไม่มีสิทธิ์อ่านพร็อพเพอร์ตี้ของเฟิร์มแวร์โดยตรง ตรวจสอบฐานข้อมูลโปรแกรมอัปเดตส่วนตัว หรือค้นหาข้อมูลปลายทางของแบ็กเอนด์ OEM ภายในไม่ได้ ไลบรารี AndroidX Security State Provider จึงมีสถาปัตยกรรมการสื่อสารระหว่างโปรเซส (IPC) ที่ได้มาตรฐานและปลอดภัย ซึ่งไคลเอ็นต์การอัปเดตใช้เพื่อแชร์ข้อมูลเกี่ยวกับการอัปเดตที่พร้อมใช้งาน การใช้ UpdateInfoService ในไคลเอ็นต์การอัปเดต OTA จะช่วยให้คุณเผยแพร่ข้อมูลเมตา ASPL สำหรับระบบได้โดยไม่ต้องเปิดเผยการผสานรวมแบ็กเอนด์ที่เป็นกรรมสิทธิ์ แม้ว่า Google จะมีการติดตั้งใช้งานสำหรับโปรแกรมอัปเดตคอมโพเนนต์ของระบบแบบแยกส่วน (Mainline) สำหรับอุปกรณ์ GMS แต่อุปกรณ์ที่ไม่ใช่ GMS ก็สามารถเผยแพร่ข้อมูลเมตา ASPL สำหรับ คอมโพเนนต์ของระบบแบบแยกส่วนเหล่านี้ได้เช่นกัน

ภาพรวมสถาปัตยกรรม

แผนภาพต่อไปนี้แสดงให้เห็นว่าไลบรารี AndroidX Security State Provider สร้างเฟรมเวิร์ก IPC ที่ได้มาตรฐานและปลอดภัยระหว่างแอปไคลเอ็นต์ที่ไม่มีสิทธิ์ และบริการอัปเดตในอุปกรณ์ได้อย่างไร

ไลบรารี AndroidX Security State Provider สร้างเฟรมเวิร์ก IPC ที่ได้มาตรฐานและปลอดภัยระหว่างแอปไคลเอ็นต์ที่ไม่มีสิทธิ์และบริการอัปเดตในอุปกรณ์

รูปแบบการนำส่งข้อมูล

แอปไคลเอ็นต์จะค้นหาการอัปเดตที่พร้อมใช้งานได้โดยการเรียกใช้ queryAllAvailableUpdates หรือ fetchAvailableSecurityPatchLevel ไลบรารี ไคลเอ็นต์จะค้นหาและเชื่อมโยงกับบริการที่ลงทะเบียนทั้งหมดซึ่งขยายคลาส UpdateInfoService ในอุปกรณ์จากแอปของระบบที่มีสิทธิ์ READ_PRIVILEGED_PHONE_STATE โดยอัตโนมัติ

ดังที่แสดงในแผนภาพก่อนหน้า ไลบรารี security-state-provider รองรับรูปแบบการนำส่งข้อมูล 2 รูปแบบ ได้แก่

รูปแบบการนำส่ง ทริกเกอร์การซิงค์ การตอบกลับของไคลเอ็นต์ กรณีการใช้งานที่แนะนำ
โมเดลพุช (การซิงค์ในเบื้องหลัง) Worker ที่ทำงานในเบื้องหลังตามกำหนดเวลา (WorkManager หรือ JobScheduler) จะซิงค์กับแบ็กเอนด์และเขียนบันทึกลงใน UpdateInfoManager บริการจะแสดงจากแคชดิสก์ในเครื่องเสมอ (shouldFetchUpdates() = false) แสดงจากแคชในเครื่องทันที โปรแกรมอัปเดต OTA ของระบบ OEM และโปรแกรมอัปเดตคอมโพเนนต์แบบโมดูลาร์ที่ซิงค์ในเบื้องหลัง
ดึงโมเดล (ซิงค์ตามต้องการ) การค้นหา IPC ของไคลเอ็นต์ขาเข้าจะทริกเกอร์การดึงข้อมูลเครือข่ายเมื่อระเบียนที่แคชไว้ล้าสมัย (shouldFetchUpdates() = true) การรวม Mutex และการจำกัดอัตรา (shouldThrottle()) จะปกป้องแบ็กเอนด์ของคุณจากการเพิ่มขึ้นอย่างรวดเร็ว รอการดึงข้อมูลจากแบ็กเอนด์เมื่อแคชล้าสมัย โปรแกรมอัปเดต OTA ของ OEM แบบ Monolithic ที่ไม่มี Worker การซิงค์ในเบื้องหลังที่กำหนดเวลาไว้

ผู้ให้บริการอัปเดตหลายราย

ในอุปกรณ์ Android ที่ใช้งานจริง ผู้ให้บริการอัปเดตอิสระหลายรายจะทำงานร่วมกัน พร้อมกัน เช่น Mainline จะเผยแพร่ความพร้อมใช้งานสำหรับคอมโพเนนต์แบบโมดูล (COMPONENT_SYSTEM_MODULES) ในขณะที่ไคลเอ็นต์ OTA ของ OEM จะเผยแพร่การอัปเดตสำหรับอิมเมจระบบปฏิบัติการหลัก (COMPONENT_SYSTEM)

บริการของคุณจะต้องลงทะเบียนการอัปเดตสำหรับคอมโพเนนต์ที่เฉพาะเจาะจงที่บริการจัดการเท่านั้น หากผู้ให้บริการหลายรายในอุปกรณ์เผยแพร่การอัปเดตสำหรับคอมโพเนนต์เดียวกัน แอปไคลเอ็นต์จะประเมินระดับแพตช์สูงสุดที่มี (โดยใช้ fetchAvailableSecurityPatchLevel()) หรือตรวจสอบบันทึก UpdateInfo แต่ละรายการ (โดยใช้ queryAllAvailableUpdates()) เพื่อการตรวจสอบระดับองค์กร ตรวจสอบว่าบริการของคุณเผยแพร่รูปแบบ Canonical สำหรับคอมโพเนนต์ (DateBasedSecurityPatchLevel สำหรับ COMPONENT_SYSTEM) เสมอ

คำแนะนำทีละขั้นตอนในการเริ่มต้นใช้งานไคลเอ็นต์การอัปเดต

ทำตามขั้นตอนต่อไปนี้เพื่อผสานรวมไลบรารี AndroidX Security State Provider เข้ากับไคลเอ็นต์การอัปเดตและเริ่มเผยแพร่ความพร้อมใช้งาน การอัปเดตความปลอดภัยของอุปกรณ์

ขั้นตอนที่ 1: เพิ่มการอ้างอิง

หากต้องการใช้ผู้ให้บริการอัปเดต โปรดตรวจสอบว่าโปรเจ็กต์มีที่เก็บ Maven ของ Google จากนั้นเพิ่มไลบรารี security-state-provider ลงในไฟล์ build.gradle.kts (Kotlin DSL) หรือ build.gradle (Groovy DSL) ของโมดูล

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

ขั้นตอนที่ 2: ประกาศบริการอัปเดตในไฟล์ Manifest

ประกาศบริการใน AndroidManifest.xml ของแอปด้วย <intent-filter> ที่ตรงกับ androidx.security.state.provider.UPDATE_INFO_SERVICE ต้องส่งออกบริการ (android:exported="true") และกำหนดค่าเป็นบริการแบบผู้ใช้คนเดียว (android:singleUser="true") เพื่อให้ไลบรารีของไคลเอ็นต์เชื่อมโยงกับบริการดังกล่าวได้ในขอบเขตของกระบวนการและผู้ใช้ โดยเฉพาะอย่างยิ่งสำหรับโปรไฟล์งาน

<!-- 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>

หากโปรแกรมอัปเดตไม่ได้ทำงานเป็น android.uid.system ให้ประกาศสิทธิ์ต่อไปนี้ในไฟล์ Manifest และตรวจสอบว่าได้เพิ่มสิทธิ์เหล่านั้นลงในรายการที่อนุญาตสำหรับสิทธิ์ ที่มีสิทธิ์เข้าถึงระดับสูงแล้ว

  • READ_PRIVILEGED_PHONE_STATE: จำเป็นเพื่อให้ลูกค้าเชื่อมั่นในผู้ให้บริการของคุณ
  • INTERACT_ACROSS_USERS: ต้องระบุสำหรับ android:singleUser="true"

ขั้นตอนที่ 3: ติดตั้งใช้งาน UpdateInfoService

หากต้องการเผยแพร่สถานะการอัปเดต คุณต้องใช้คลาส UpdateInfoService และสร้างบันทึกการอัปเดตที่ตรงกับโมเดลข้อมูลที่คาดไว้

ข้อกำหนดของโมเดลข้อมูล UpdateInfo

ไม่ว่าคุณจะเลือกรูปแบบ Push หรือ Pull ให้สร้างระเบียน UpdateInfo โดยใช้ UpdateInfo.Builder ตามข้อกำหนดต่อไปนี้

ชื่อฟิลด์ เมธอด Getter ประเภทข้อมูล ข้อกำหนดด้านการตรวจสอบและความถูกต้องของรูปแบบ จุดประสงค์และความหมายของระบบ
component getComponent() String (@Component) ค่าคงที่ Canonical ใน SecurityPatchState: COMPONENT_SYSTEM, COMPONENT_SYSTEM_MODULES หรือ COMPONENT_KERNEL ระบุซอฟต์แวร์หรือระบบย่อยของเฟิร์มแวร์ที่การอัปเดตนี้กำหนดเป้าหมาย
securityPatchLevel getSecurityPatchLevel() SecurityPatchLevel ต้องเป็นอินสแตนซ์ของ DateBasedSecurityPatchLevel (YYYY-MM-DD) หรือ VersionedSecurityPatchLevel (major.minor.patch) หรือแยกวิเคราะห์โดยใช้ SecurityPatchState.getComponentSecurityPatchLevel() ระดับแพตช์ด้านความปลอดภัยเป้าหมายที่จะได้รับเมื่อติดตั้งการอัปเดตนี้
publishedDateMillis getPublishedDateMillis() long มิลลิวินาทีตั้งแต่ Unix Epoch (System.currentTimeMillis()) ต้องเป็น > 0 เมื่อมีการอัปเดตให้ผู้ใช้ใช้งานได้ เช่น เวลาเผยแพร่ OTA อย่าใช้เวลาในการดาวน์โหลดหรือติดตั้งเพย์โหลด
lastCheckTimeMillis getLastCheckTimeMillis() long มิลลิวินาทีนับตั้งแต่ Unix Epoch ต้องเป็น > 0 การประทับเวลาเมื่อผู้ให้บริการยืนยันหรือค้นพบระเบียนการอัปเดตนี้ระหว่างการซิงค์

เลือกรุ่นการนำส่งที่เหมาะกับสถาปัตยกรรมของโปรแกรมอัปเดตจากตัวเลือกต่อไปนี้

ตัวเลือก A: โมเดลพุช (แนะนำ)

เมื่อ Worker การซิงค์ข้อมูลในเบื้องหลังตรวจสอบเซิร์ฟเวอร์ OTA ให้ตรวจสอบว่าการอัปเดตที่พบจะเลื่อนระดับแพตช์ปัจจุบันของอุปกรณ์และคงไว้โดยใช้ UpdateInfoManager.registerUpdate() หรือเรียกใช้ UpdateInfoManager.unregisterUpdate() หากไม่มีการอัปเดตความปลอดภัยที่รอดำเนินการ เรียกใช้ UpdateInfoManager.setLastCheckTimeMillis() ที่ส่วนท้ายของการซิงค์ทุกครั้งเสมอ (แม้หลังจากเรียกใช้ registerUpdate() ซึ่งจะคงบันทึก UpdateInfo ต่อคอมโพเนนต์ไว้ แต่จะไม่ อัปเดตการประทับเวลาการตรวจสอบล่าสุดทั่วโลกที่ส่งคืนให้ไคลเอ็นต์) คุณสามารถติดตั้งใช้งานได้ด้วย WorkManager CoroutineWorker ใน Kotlin หรือ Worker ใน 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();
    }
}

ในโมเดลแบบพุช งานในเบื้องหลังจะบันทึกการอัปเดตโดยตรงไปยัง UpdateInfoManager หากต้องการสั่งให้เฟรมเวิร์กแสดงบันทึกจากที่เก็บข้อมูลในดิสก์ภายในเสมอ ให้ลบล้าง shouldFetchUpdates() เพื่อแสดง false โดย ขยาย UpdateInfoService ใน Kotlin หรือ ListenableFutureUpdateInfoService ใน 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());
    }
}

ตัวเลือก ข: โมเดลแบบดึง (ตามต้องการ)

ในสถาปัตยกรรมแบบดึง บริการของคุณจะจัดการคำขอรีเฟรชตามต้องการ ที่แอปไคลเอ็นต์ทริกเกอร์เมื่อแคชในเครื่องล้าสมัย

หากต้องการจัดการการค้นหาการอัปเดตตามคำขอ ให้ขยาย UpdateInfoService ใน Kotlin (การใช้ฟังก์ชัน fetchUpdates() ที่ระงับ) หรือ ListenableFutureUpdateInfoService ใน Java (การใช้ fetchUpdatesAsync() ที่ส่งคืน 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);
    }
}

ขั้นตอนที่ 4: ล้างการอัปเดตที่ใช้หลังจากรีบูตอุปกรณ์

แม้ว่า UpdateInfoManager จะตัดการอัปเดตที่ล้าสมัยออกโดยอัตโนมัติทุกครั้งที่เรียกใช้ registerUpdate() แต่โปรแกรมอัปเดตจะไม่เรียกใช้ registerUpdate() อีกครั้งหลังจากที่การอัปเดต OTA ติดตั้งเสร็จจนกว่าจะถึงรอบการซิงค์เซิร์ฟเวอร์ที่กำหนดเวลาไว้ครั้งถัดไป หากต้องการป้องกันไม่ให้แอปไคลเอ็นต์เห็นการอัปเดตที่ติดตั้งแล้วว่ายังรอดำเนินการอยู่ทันทีหลังจากรีบูต ให้รอรับ ACTION_BOOT_COMPLETED และเรียกใช้ UpdateInfoManager.unregisterUpdate() เมื่อการอัปเดต OTA ติดตั้งเสร็จแล้วเพื่อล้างบันทึกจากแคชในเครื่อง การดำเนินการนี้เฉพาะเมื่อการติดตั้ง อัปเดตเสร็จสิ้นแล้วจะช่วยหลีกเลี่ยงการล้างข้อมูลการอัปเดตที่รอดำเนินการ (ที่ถอนการติดตั้งแล้ว) โดยไม่มีเงื่อนไขในการรีบูตอุปกรณ์ตามปกติทุกครั้ง เนื่องจากคีย์ UpdateInfoManagerจะอัปเดตระเบียนตาม คอมโพเนนต์ คุณจึงต้องระบุคอมโพเนนต์เป้าหมายเมื่อสร้างออบเจ็กต์ UpdateInfoสำหรับการยกเลิกการลงทะเบียนเท่านั้น

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

ขั้นตอนที่ 5: ยืนยันการผสานรวม

เรียกใช้การตรวจสอบต่อไปนี้ในอุปกรณ์ Android หรือโปรแกรมจำลองโดยใช้ Android Debug Bridge (ADB) เพื่อตรวจสอบการผสานรวมตั้งแต่ต้นทางถึงปลายทางและหลีกเลี่ยงข้อผิดพลาดในการติดตั้งใช้งาน OEM ทั่วไป

  1. ยืนยันว่าไคลเอ็นต์เชื่อถือผู้ให้บริการ: แอปไคลเอ็นต์จะไม่สนใจผู้ให้บริการที่ไม่มี READ_PRIVILEGED_PHONE_STATE แม้ว่าจะมีการติดตั้งไว้ล่วงหน้าก็ตาม ยืนยันว่าได้รับสิทธิ์แล้วโดยทำดังนี้

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

    จากนั้นยืนยันว่าผู้ใช้ค้นพบบริการของคุณได้และไม่มีสิทธิ์เข้าถึงบริการ ในเอาต์พุตของบริการ ให้ตรวจสอบ exported=true และ permission=null ดังนี้

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

    หากลูกค้ายังคงไม่เห็นผู้ให้บริการ ให้ตรวจสอบ logcat สำหรับ Ignoring untrusted update provider จากแท็ก SecurityPatchState

  2. ยืนยันการแก้ปัญหา Intent ในทั้งโปรไฟล์ผู้ใช้ 0 และโปรไฟล์งาน: ยืนยันว่า ระบบปฏิบัติการ Android PackageManager แก้ปัญหาตัวกรอง Intent ที่ส่งออก UPDATE_INFO_SERVICE ในทั้งผู้ใช้หลัก (User 0) และ โปรไฟล์งาน Android Enterprise ที่ใช้งานอยู่ (เช่น 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. ยืนยันสถานะบริการและบันทึกที่แคชไว้โดยใช้ dumpsys: UpdateInfoService จะลบล้าง dump() เพื่อรายงาน Global Last Check, Should Throttle (สถานะของเครื่องมือจำกัดอัตรา) และ Cached Updates เนื่องจาก UpdateInfoService เป็นบริการที่เชื่อมโยงและ ไคลเอ็นต์จะยกเลิกการเชื่อมโยงทันทีหลังจากทำการค้นหา dumpsys activity service จะแสดงผล (nothing) เมื่อไม่มีไคลเอ็นต์ที่เชื่อมโยง เริ่มบริการอย่างชัดเจนก่อน เรียกใช้ 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>
    

    ตัวอย่างเอาต์พุตการวินิจฉัย

    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. เรียกใช้การเชื่อมโยงไคลเอ็นต์และยืนยันผลลัพธ์การวัดและส่งข้อมูล: จากแอปทดสอบที่ไม่มีสิทธิ์ (ไม่มีสิทธิ์ลายเซ็นของระบบ) ให้เรียกใช้ SecurityPatchState.queryAllAvailableUpdates() หากคุณใช้ การเรียกกลับของข้อมูลการวัดและส่ง ให้ตรวจสอบสิ่งต่อไปนี้

    • ยืนยันว่าไคลเอ็นต์ที่ไม่มีสิทธิ์เชื่อมโยงโดยไม่มี SecurityException และ ทริกเกอร์ onClientConnected(packageName, callerUid)
    • สำหรับผู้ให้บริการแบบพุช (shouldFetchUpdates() == false): ตรวจสอบว่า onRequestCompleted(telemetry) บันทึก UpdateFetchOutcome.CACHE_HIT (1) ที่มี fetchDurationMillis == 0 ในทุกคำค้นหา
    • สำหรับผู้ให้บริการแบบดึงข้อมูล (shouldFetchUpdates() == true): ตรวจสอบว่า onRequestCompleted(telemetry) บันทึก UpdateFetchOutcome.FETCHED (3) ในการค้นหาแคชที่ล้าสมัยครั้งแรก ตามด้วย CACHE_HIT (1) ใน การค้นหาที่ตามมาทันที (หากต้องการรีเซ็ตตัวจำกัดอัตราแบบต่อเนื่อง 1 ชั่วโมง ระหว่างการทดสอบแบบพุล ให้เรียกใช้ adb shell pm clear <your_package_name>)

การกำหนดค่าขั้นสูงและที่ไม่บังคับ

นโยบายการแคชและการจำกัดอัตราคำขอ

เมื่อไคลเอ็นต์ค้นหาการอัปเดต UpdateInfoService จะเรียกใช้เวิร์กโฟลว์การล็อกที่ตรวจสอบ 2 ครั้งเพื่อปรับสมดุลความใหม่ของข้อมูลกับภาระงานของเซิร์ฟเวอร์แบ็กเอนด์

UpdateInfoService จะดำเนินการเวิร์กโฟลว์การล็อกที่ตรวจสอบ 2 ครั้งเพื่อปรับสมดุลความใหม่ของข้อมูลกับภาระงานของเซิร์ฟเวอร์แบ็กเอนด์

  • Fast Path (shouldFetchUpdates()): โดยค่าเริ่มต้น shouldFetchUpdates() จะแสดงผล true (ระบุแคชที่ล้าสมัย) เฉพาะ เมื่อ lastCheckTimeMillis ทั่วโลกมีอายุมากกว่า 1 ชั่วโมง (TimeUnit.HOURS.toMillis(1)) เมื่อ shouldFetchUpdates() แสดงผล false บริการจะแสดงผลระเบียนที่แคชไว้ทันทีพร้อมผลลัพธ์ UpdateFetchOutcome.CACHE_HIT โดยไม่ต้องรับล็อกหรือดำเนินการ I/O เครือข่าย คุณสามารถลบล้าง shouldFetchUpdates() เพื่อปรับแต่งนโยบายการแคชนี้ได้
  • เส้นทางช้าและการรวมคำขอ: เมื่อ shouldFetchUpdates() แสดงผล true บริการจะรับ Mutex ของโครูทีนภายในและประเมิน shouldFetchUpdates() อีกครั้ง (แสดงผล UpdateFetchOutcome.COALESCED หากคำขอหลายรายการพร้อมกันรีเฟรชแคชแล้วขณะรอล็อก)
  • เครื่องมือจำกัดอัตราแบบถาวร (shouldThrottle()): เพื่อปกป้องโครงสร้างพื้นฐานของแบ็กเอนด์ จากการค้นหาที่เพิ่มขึ้นอย่างรวดเร็วหรือความล้มเหลวซ้ำๆ shouldThrottle() จะบังคับใช้ช่วงเวลาขั้นต่ำแบบถาวร 1 ชั่วโมงในการรีสตาร์ทแอปและอุปกรณ์ UpdateInfoServiceจะบันทึกความพยายามแต่ละครั้งก่อนเรียกใช้ fetchUpdates() ดังนั้นหาก fetchUpdates() แสดงข้อยกเว้น (ส่งคืน UpdateFetchOutcome.FAILED หลังจากเรียกใช้ onFetchFailed(e)) การค้นหาต่อๆ ไปในช่วง 60 นาทีถัดไปจะส่งคืนข้อมูลสำรองที่แคชไว้พร้อมผลลัพธ์ UpdateFetchOutcome.THROTTLED อย่างราบรื่น

ความสามารถในการสังเกต การวัดและส่งข้อมูลทางไกล และการวินิจฉัย

UpdateInfoService มีฮุกการสังเกตการณ์ในตัวเพื่อติดตามการนำไคลเอ็นต์ไปใช้ ตรวจสอบเวลาในการตอบสนองของ IPC และบันทึกข้อผิดพลาดของแบ็กเอนด์โดยไม่ต้องใช้เครื่องมือ Stub AIDL ระดับต่ำ

  • onRequestCompleted(telemetry): เรียกใช้เมื่อการตรวจสอบการอัปเดตแต่ละครั้งเสร็จสมบูรณ์พร้อมสรุป UpdateCheckTelemetry
  • onClientConnected(packageName, callerUid): เรียกใช้เมื่อไคลเอ็นต์ที่ ยืนยันแล้วเปิดเซสชัน
  • onClientDisconnected(packageName, callerUid): เรียกใช้เมื่อไคลเอ็นต์ยกเลิกการเชื่อมโยงหรือกระบวนการของไคลเอ็นต์สิ้นสุดลง
  • onFetchFailed(e): เรียกใช้หากเกิดข้อยกเว้นระหว่าง fetchUpdates() ก่อนที่บริการจะแสดงข้อมูลสำรองที่แคชไว้

แทนที่โค้ดเรียกกลับเหล่านี้ใน UpdateInfoService (Kotlin) หรือ 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);
    }
}

ผลลัพธ์การวัดระยะไกลและเมตริกเวลาในการตอบสนอง

UpdateCheckTelemetry วัดระยะเวลาที่ผ่านไปแบบโมโนโทน (SystemClock.elapsedRealtime()) และรายงานผลลัพธ์ 1 ใน 5 รายการที่กำหนดไว้ ใน UpdateFetchOutcome

ค่าคงที่ของผลลัพธ์ @IntDef รหัส พร็อพเพอร์ตี้ของเมตริกที่บันทึก คำอธิบายและสถานะระบบ
UpdateFetchOutcome.CACHE_HIT 1 totalDurationMillis, processingDurationMillis, callerUid แสดงจากแคชหน่วยความจำ/ดิสก์ในเครื่องทันทีใน Fast Path (shouldFetchUpdates() แสดงผล false) lockWaitDurationMillis และ fetchDurationMillis เป็น 0
UpdateFetchOutcome.COALESCED 2 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, callerUid คิวการค้นหาอยู่หลังการรีเฟรชที่ใช้งานอยู่อื่นๆ เมื่อได้รับล็อก ข้อมูลจะใหม่ล่าสุด หลีกเลี่ยงการดึงข้อมูลเครือข่ายที่ซ้ำกัน (fetchDurationMillis คือ 0)
UpdateFetchOutcome.FETCHED 3 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, fetchDurationMillis, callerUid ซิงค์เครือข่ายแบ็กเอนด์สำเร็จแล้ว (fetchUpdates() เสร็จสมบูรณ์) บันทึกใหม่ที่บันทึกลงในดิสก์
UpdateFetchOutcome.THROTTLED 4 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, callerUid ตัวจำกัดอัตราบล็อกคำขอ (shouldThrottle() แสดงผล true) ระบบจะแสดงข้อมูลที่แคชอย่างปลอดภัยต่อไคลเอ็นต์ (fetchDurationMillis คือ 0)
UpdateFetchOutcome.FAILED 5 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, fetchDurationMillis, callerUid การตรวจสอบการอัปเดตหรือคำขอเครือข่ายทำให้เกิดข้อยกเว้น ไฟร์วอลล์ตรวจพบข้อยกเว้น onFetchFailed(e) ส่งคืนการสำรองข้อมูลที่แคชไว้

Hook โบรกเกอร์บริการขั้นสูง: getCallerUid()

เมื่อไคลเอ็นต์เชื่อมต่อ UpdateInfoService จะประเมิน getCallerUid() โดยอัตโนมัติในเธรด Binder เริ่มต้น (ก่อนเรียกใช้ Binder.clearCallingIdentity() ก่อน fetchUpdates()) ยืนยันการเป็นเจ้าของแพ็กเกจ และส่ง UID ของผู้โทรที่ยืนยันแล้วไปยัง onClientConnected(), onClientDisconnected() และ telemetry.callerUid โดยตรงใน onRequestCompleted(telemetry)

สําหรับ<service>คอมโพเนนต์getCallerUid() Android มาตรฐาน คุณไม่จําเป็นต้องเรียกใช้หรือลบล้าง เมธอด protected open getCallerUid() (ซึ่งมอบสิทธิ์ให้ Binder.getCallingUid() โดยค่าเริ่มต้น) มีไว้เป็นฮุกการลบล้างสำหรับแอปโฮสต์ที่กำหนดเส้นทาง Binder IPC ผ่านโบรกเกอร์บริการภายในหรือสถาปัตยกรรมพร็อกซี ซึ่งช่วยให้คลาสย่อยแสดงผล UID ของไคลเอ็นต์เชิงตรรกะแทน UID ของโบรกเกอร์ได้

แหล่งข้อมูลเพิ่มเติม

ดูข้อมูลเพิ่มเติมเกี่ยวกับการเผยแพร่สถานะความปลอดภัยได้ที่แหล่งข้อมูลต่อไปนี้

เอกสารประกอบ

การอ้างอิง API