פרסום מצב האבטחה של המכשיר

אם אתם יצרני ציוד מקורי (OEM) או מנהלים לקוח עדכונים דרך האוויר (OTA) עם הרשאות מיוחדות, אתם יכולים לתת לאפליקציות רגישות לאבטחה במכשיר גישה לעדכוני אבטחה בהמתנה, כדי שהן יוכלו להעריך בצורה מדויקת את מצב האבטחה של המכשיר. כדי לאכוף מדיניות חזקה של אפס אמון, האפליקציות צריכות להיות מסוגלות לאמת לא רק את רמת התיקון שהותקנה במכשיר (רמת תיקון האבטחה במכשיר, או 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 תומכת בשני מודלים של העברת נתונים:

מודל מסירה טריגר סנכרון תגובת הלקוח תרחישי שימוש מומלצים
מודל דחיפה (סנכרון ברקע) עובדים ברקע שמתוזמנים (WorkManager או JobScheduler) מסתנכרנים עם ה-Backend וכותבים רשומות ל-UpdateInfoManager. השירות שלכם תמיד פועל ממטמון מקומי בדיסק (shouldFetchUpdates() = false). התגובה מוגשת באופן מיידי מהמטמון המקומי. עדכוני OTA של מערכת OEM ועדכונים של רכיבים מודולריים שמסונכרנים ברקע.
מודל משיכה (סנכרון לפי דרישה) שאילתות IPC של לקוחות נכנסות מפעילות אחזור מהרשת כשנתונים במטמון לא עדכניים (shouldFetchUpdates() = true). איחוד של mutex והגבלת קצב (shouldThrottle()) מגנים על הקצה העורפי מפני עליות פתאומיות. ההמתנה לאחזור מהקצה העורפי כשהמטמון לא עדכני. כלי עדכון OTA מונוליטיים של יצרני ציוד מקורי (OEM) ללא עובדי סנכרון ברקע מתוזמנים.

ספקי עדכונים מרובים

במכשירי Android בייצור, כמה ספקי עדכונים עצמאיים פועלים בו-זמנית. לדוגמה, Mainline מפרסם זמינות לרכיבים מודולריים (COMPONENT_SYSTEM_MODULES), בעוד לקוח ה-OTA של יצרן הציוד המקורי מפרסם עדכונים לתמונה הראשית של מערכת ההפעלה (COMPONENT_SYSTEM).

השירות צריך לרשום עדכונים רק לרכיבים הספציפיים שהוא מנהל. אם כמה ספקים במכשיר מפרסמים עדכונים לאותו רכיב, אפליקציות הלקוח מעריכות את רמת התיקון הגבוהה ביותר שזמינה (באמצעות fetchAvailableSecurityPatchLevel()) או בודקות רשומות נפרדות של UpdateInfo (באמצעות queryAllAvailableUpdates()) לצורך ביקורת ארגונית. מוודאים שהשירות שלכם תמיד מפרסם את הפורמט הקנוני של הרכיב (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: מצהירים על שירות העדכון במניפסט

מצהירים על השירות ב-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, צריך גם להצהיר על ההרשאות הבאות במניפסט ולהוסיף אותן לרשימת ההיתרים של הרשאות מיוחדות:

  • ‫READ_PRIVILEGED_PHONE_STATE: נדרש כדי שהלקוחות יסמכו על הספק.
  • ‫INTERACT_ACROSS_USERS: חובה ל-android:singleUser="true".

שלב 3: מטמיעים את UpdateInfoService

כדי לפרסם את סטטוס העדכון, צריך להטמיע את המחלקה UpdateInfoService וליצור רשומות עדכון שתואמות למודל הנתונים הצפוי.

מפרט של מודל הנתונים UpdateInfo

לא משנה אם בוחרים במודל Push או במודל Pull, צריך ליצור רשומות של UpdateInfo באמצעות UpdateInfo.Builder בהתאם למפרט הבא:

שם השדה Getter Method סוג הנתונים דרישות אימות ופורמט מטרה וסמנטיקה של המערכת
component getComponent() String (@Component) קבועים קנוניים ב-SecurityPatchState: ‏ COMPONENT_SYSTEM,‏ COMPONENT_SYSTEM_MODULES או COMPONENT_KERNEL. מזהה את תוכנת המשנה או הקושחה שהעדכון הזה מיועד להן.
securityPatchLevel getSecurityPatchLevel() SecurityPatchLevel הערך צריך להיות מופע של DateBasedSecurityPatchLevel (YYYY-MM-DD) או של VersionedSecurityPatchLevel (major.minor.patch), או להיות מנותח באמצעות SecurityPatchState.getComponentSecurityPatchLevel(). רמת תיקוני האבטחה שאליה יגיע המכשיר אחרי התקנת העדכון הזה.
publishedDateMillis getPublishedDateMillis() long אלפיות השנייה מאז ראשית זמן יוניקס (System.currentTimeMillis()). הערך חייב להיות > 0. מתי העדכון הפך לזמין למשתמשים, למשל שעת ההשקה של OTA. אל תשתמשו בזמן ההורדה או ההתקנה של מטען הייעודי (payload).
lastCheckTimeMillis getLastCheckTimeMillis() long אלפיות השנייה מאז ראשית זמן יוניקס (Unix epoch). חייב להיות > 0. חותמת זמן שבה הספק אימת או גילה את רשומת העדכון הזו במהלך הסנכרון.

בוחרים את מודל ההפצה שמתאים לארכיטקטורה של כלי העדכון מבין האפשרויות הבאות:

אפשרות א': מודל דחיפה (מומלץ)

כשעובד הסנכרון ברקע בודק את שרת ה-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. כדי להנחות את מסגרת ה-SDK להציג תמיד רשומות מאחסון דיסק מקומי, צריך לבטל את השיטה 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() שמחזירה Guava ListenableFuture):

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: אימות השילוב

כדי לוודא שהשילוב מקצה לקצה תקין ולמנוע בעיות נפוצות בהטמעה של יצרני ציוד מקורי (OEM), מריצים את הבדיקות הבאות במכשיר Android או באמולטור באמצעות Android Debug Bridge‏ (ADB):

  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).
    • לספקים של מודל Push (shouldFetchUpdates() == false): מוודאים שיומני onRequestCompleted(telemetry) UpdateFetchOutcome.CACHE_HIT (1) עם fetchDurationMillis == 0 בכל שאילתה.
    • לספקים של מודל Pull (shouldFetchUpdates() == true): מוודאים שיומני onRequestCompleted(telemetry) UpdateFetchOutcome.FETCHED (3) מופיעים בשאילתת המטמון הראשונה שפג תוקפה, ואחריהם יומני CACHE_HIT (1) בשאילתות הבאות שמופיעות מיד. (כדי לאפס את מגביל הקצב המתמשך של שעה אחת בין הרצות בדיקה של מודל משיכה, מריצים את הפקודה adb shell pm clear <your_package_name>.)

הגדרות אופציונליות ומתקדמות

מדיניות בנושא שמירת נתונים במטמון והגבלת קצב של יצירת בקשות

כשלקוח שולח שאילתה לגבי עדכונים, UpdateInfoService מפעיל תהליך עבודה של נעילה עם בדיקה כפולה כדי לאזן בין עדכניות הנתונים לבין עומס השרת בקצה העורפי:

‫UpdateInfoService מבצע תהליך עבודה של נעילה עם בדיקה כפולה כדי לאזן בין עדכניות הנתונים לבין עומס השרת העורפי

  • נתיב מהיר (shouldFetchUpdates()): כברירת מחדל, הפונקציה shouldFetchUpdates() מחזירה true (שמציין מטמון לא עדכני) רק אם המשתנה הגלובלי lastCheckTimeMillis ישן יותר משעה אחת (TimeUnit.HOURS.toMillis(1)). כשהפונקציה shouldFetchUpdates() מחזירה false, השירות מחזיר באופן מיידי רשומות במטמון עם התוצאה UpdateFetchOutcome.CACHE_HIT בלי לרכוש נעילות או לבצע קלט/פלט ברשת. אפשר לבטל את ההגדרה shouldFetchUpdates() כדי להתאים אישית את מדיניות הקאשינג הזו.
  • נתיב איטי ואיחוד בקשות: כש-shouldFetchUpdates() מחזירה true, השירות מקבל mutex פנימי של שגרת המשך (coroutine) ומעריך מחדש את shouldFetchUpdates() (מחזיר UpdateFetchOutcome.COALESCED אם בקשה מקבילה כבר רעננה את המטמון בזמן ההמתנה לנעילה).
  • הגבלת קצב מתמשכת (shouldThrottle()): כדי להגן על תשתית ה-Backend מפני פרצי שאילתות או כשלים חוזרים, shouldThrottle() אוכפת מרווח מינימלי מתמשך של שעה בין הפעלות מחדש של האפליקציה והמכשיר. ‫UpdateInfoService מתעד כל ניסיון לפני הפעלת fetchUpdates(), כך שאם fetchUpdates() יוצר חריגה (מחזיר UpdateFetchOutcome.FAILED אחרי הפעלת onFetchFailed(e)), שאילתות עוקבות במהלך 60 הדקות הבאות מחזירות בצורה תקינה נתוני גיבוי במטמון עם התוצאה UpdateFetchOutcome.THROTTLED.

ניראות, טלמטריה ואבחון

‫UpdateInfoService מספקת ווים מובנים של יכולת צפייה כדי לעקוב אחרי אימוץ הלקוח, לעקוב אחרי השהיה של IPC ולרשום ביומן שגיאות של קצה העורפי בלי להשתמש ב-stubs של AIDL ברמה נמוכה:

צריך לבטל את ברירת המחדל של הקריאות החוזרות האלה ב-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()) ומדווח על אחת מחמש התוצאות שמוגדרות ב-UpdateFetchOutcome:

Outcome Constant @IntDef קוד מאפייני המדד שנרשמו ביומן תיאור ומצב המערכת
UpdateFetchOutcome.CACHE_HIT 1 totalDurationMillis, processingDurationMillis, callerUid הדף נטען באופן מיידי מהמטמון המקומי בדיסק או ממטמון הזיכרון בנתיב המהיר (shouldFetchUpdates() הוחזר false). הערכים של lockWaitDurationMillis ו-fetchDurationMillis הם 0.
UpdateFetchOutcome.COALESCED 2 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, callerUid השאילתה הוכנסה לתור מאחורי רענון פעיל אחר. אחרי שהושגה נעילה, הנתונים היו עדכניים. הימנעות מאחזור כפול של הרשת (fetchDurationMillis הוא 0).
UpdateFetchOutcome.FETCHED 3 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, fetchDurationMillis, callerUid הסנכרון של רשת ה-Backend בוצע בהצלחה (fetchUpdates() הושלם). רשומות חדשות נשמרו בדיסק.
UpdateFetchOutcome.THROTTLED 4 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, callerUid הבקשה נחסמה על ידי מגביל הקצב (shouldThrottle() הוחזר true). נתונים במטמון הוחזרו בבטחה ללקוח (fetchDurationMillis הוא 0).
UpdateFetchOutcome.FAILED 5 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, fetchDurationMillis, callerUid בדיקת העדכון או בקשה לאחזור מהרשת גרמו לחריגה. נתפס על ידי חומת אש של חריגים, הופעל onFetchFailed(e), הוחזר גיבוי במטמון.

Advanced service broker hook: getCallerUid()

כשלקוח מתחבר, UpdateInfoService מעריך אוטומטית את getCallerUid() בשרשור Binder הראשוני (לפני הקריאה ל-Binder.clearCallingIdentity() לפני fetchUpdates()), מאמת את הבעלות על החבילה ומעביר את ה-UID של המתקשר המאומת ישירות אל onClientConnected(), onClientDisconnected() ו-telemetry.callerUid ב-onRequestCompleted(telemetry).

ברכיבי Android <service> רגילים, אין צורך להפעיל או לשנות את הפונקציה getCallerUid(). השיטה protected open getCallerUid() (שמועברת כברירת מחדל אל Binder.getCallingUid()) מסופקת כ-override hook לאפליקציות מארחות שמנתבות את Binder IPC דרך מתווך שירות פנימי או ארכיטקטורת proxy, ומאפשרת למחלקת המשנה להחזיר את ה-UID הלוגי של הלקוח במקום ה-UID של המתווך.

מקורות מידע נוספים

מידע נוסף על פרסום מצב האבטחה זמין במקורות המידע הבאים:

מאמרי עזרה

הפניית API