기기 보안 상태 게시

원래 장비 제조업체 (OEM)이거나 권한이 있는 무선 (OTA) 업데이트 클라이언트를 유지관리하는 경우 기기의 보안에 민감한 앱에 대기 중인 보안 업데이트를 표시하여 기기의 보안 상황을 정확하게 평가할 수 있습니다. 강력한 제로 트러스트 정책을 시행하려면 앱이 기기에 설치된 패치 수준(기기 보안 패치 수준, DSPL)뿐만 아니라 사용 가능하고 설치 준비가 된 보안 업데이트 (사용 가능한 보안 패치 수준, ASPL)도 확인할 수 있어야 합니다.

권한이 없는 클라이언트 앱은 펌웨어 속성을 직접 읽거나 비공개 업데이터 데이터베이스를 검사하거나 내부 OEM 백엔드 엔드포인트를 쿼리할 수 없으므로 AndroidX 보안 상태 제공자 라이브러리는 업데이트 클라이언트가 사용 가능한 업데이트에 관한 정보를 공유하는 데 사용하는 표준화된 보안 프로세스 간 통신 (IPC) 아키텍처를 제공합니다. OTA 업데이트 클라이언트에 UpdateInfoService을 구현하면 독점 백엔드 통합을 노출하지 않고 시스템의 ASPL 메타데이터를 게시할 수 있습니다. Google은 GMS 기기용 모듈식 시스템 구성요소 (Mainline) 업데이터 구현을 제공하지만 비 GMS 기기도 이러한 모듈식 시스템 구성요소의 ASPL 메타데이터를 게시할 수 있습니다.

아키텍처 개요

다음 다이어그램은 AndroidX 보안 상태 제공자 라이브러리가 권한이 없는 클라이언트 앱과 온디바이스 업데이트 서비스 간에 표준화된 보안 IPC 프레임워크를 설정하는 방법을 보여줍니다.

AndroidX 보안 상태 제공자 라이브러리는 권한이 없는 클라이언트 앱과 온디바이스 업데이트 서비스 간에 표준화된 보안 IPC 프레임워크를 설정합니다.

데이터 전송 모델

클라이언트 앱은 queryAllAvailableUpdates 또는 fetchAvailableSecurityPatchLevel를 호출하여 업데이트 사용 가능 여부를 쿼리합니다. 내부적으로 클라이언트 라이브러리는 READ_PRIVILEGED_PHONE_STATE 권한을 보유한 시스템 앱에서 기기의 UpdateInfoService 클래스를 확장하는 등록된 모든 서비스를 자동으로 검색하고 바인딩합니다.

앞의 다이어그램에 표시된 것처럼 security-state-provider 라이브러리는 다음 두 가지 데이터 전송 모델을 지원합니다.

전송 모델 동기화 트리거 클라이언트 응답 권장 사용 사례
푸시 모델 (백그라운드 동기화) 예약된 백그라운드 작업자 (WorkManager 또는 JobScheduler)는 백엔드와 동기화되고 UpdateInfoManager에 레코드를 작성합니다. 서비스는 항상 로컬 디스크 캐시 (shouldFetchUpdates() = false)에서 제공됩니다. 로컬 캐시에서 즉시 제공됩니다. OEM 시스템 OTA 업데이터 및 백그라운드 동기화된 모듈식 구성요소 업데이터
풀 모델 (주문형 동기화) 캐시된 레코드가 오래되면 (shouldFetchUpdates() = true) 수신 클라이언트 IPC 쿼리가 네트워크 가져오기를 트리거합니다. 뮤텍스 병합 및 비율 제한 (shouldThrottle())은 백엔드를 급증으로부터 보호합니다. 캐시가 오래된 경우 백엔드 가져오기를 기다립니다. 예약된 백그라운드 동기화 작업자가 없는 모놀리식 OEM OTA 업데이터

여러 업데이트 제공자

프로덕션 Android 기기에서는 여러 독립 업데이트 제공자가 동시에 공존합니다. 예를 들어 Mainline은 모듈식 구성요소 (COMPONENT_SYSTEM_MODULES)의 가용성을 게시하고 OEM OTA 클라이언트는 기본 OS 이미지 (COMPONENT_SYSTEM)의 업데이트를 게시합니다.

서비스는 관리하는 특정 구성요소의 업데이트만 등록하면 됩니다. 기기의 여러 제공자가 동일한 구성요소의 업데이트를 게시하는 경우 클라이언트 앱은 사용 가능한 가장 높은 패치 수준을 평가하거나 (fetchAvailableSecurityPatchLevel() 사용) 엔터프라이즈 감사를 위해 개별 UpdateInfo 레코드를 검사합니다 (queryAllAvailableUpdates() 사용). 서비스가 항상 구성요소의 표준 형식 (COMPONENT_SYSTEM의 경우 DateBasedSecurityPatchLevel)을 게시하는지 확인합니다.

업데이트 클라이언트 온보딩 단계별 가이드

다음 단계에 따라 AndroidX 보안 상태 제공자 라이브러리를 업데이트 클라이언트에 통합하고 기기의 보안 업데이트 사용 가능 여부 게시를 시작하세요.

1단계: 종속 항목 추가

업데이트 제공자를 구현하려면 프로젝트에 Google Maven 저장소가 포함되어 있는지 확인한 다음 모듈의 build.gradle.kts (Kotlin DSL) 또는 build.gradle (Groovy DSL) 파일에 security-state-provider 라이브러리를 추가합니다.

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

2단계: 매니페스트에서 업데이트 서비스 선언

androidx.security.state.provider.UPDATE_INFO_SERVICE와 일치하는 <intent-filter>를 사용하여 앱의 AndroidManifest.xml에서 서비스를 선언합니다. 클라이언트 라이브러리가 프로세스 및 사용자 경계(특히 직장 프로필의 경우)에서 서비스에 바인딩될 수 있도록 서비스가 내보내기(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 데이터 모델 사양

푸시 모델을 선택하든 풀 모델을 선택하든 다음 사양에 따라 UpdateInfo.Builder를 사용하여 UpdateInfo 레코드를 구성합니다.

필드 이름 Getter 메서드 데이터 유형 유효성 검사 및 형식 요구사항 목적 및 시스템 시맨틱
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 Unix epoch 이후의 밀리초 (System.currentTimeMillis())입니다. > 0이어야 합니다. 업데이트가 사용자에게 제공된 시간입니다(예: OTA 출시 시간). 페이로드 다운로드 또는 설치 시간을 사용하지 마세요.
lastCheckTimeMillis getLastCheckTimeMillis() long Unix epoch 이후 경과 시간(밀리초)입니다. > 0이어야 합니다. 동기화 중에 제공업체가 이 업데이트 기록을 확인하거나 발견한 타임스탬프입니다.

다음 옵션 중에서 업데이터의 아키텍처에 맞는 전송 모델을 선택합니다.

옵션 A: 푸시 모델 (권장)

백그라운드 동기화 작업자가 OTA 서버를 확인할 때 발견된 업데이트가 기기의 현재 패치 수준을 높이는지 확인하고 UpdateInfoManager.registerUpdate()를 사용하여 유지합니다. 보안 업데이트가 대기 중이 아닌 경우 UpdateInfoManager.unregisterUpdate()를 호출합니다. registerUpdate()를 호출한 후에도 모든 동기화가 끝날 때마다 항상 UpdateInfoManager.setLastCheckTimeMillis()를 호출하세요. registerUpdate()는 구성요소별 UpdateInfo 레코드를 유지하지만 클라이언트에 반환되는 전역 마지막 확인 타임스탬프는 업데이트하지 않습니다. Kotlin에서는 WorkManager CoroutineWorker로, Java에서는 Worker로 이를 구현할 수 있습니다.

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

자바

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에 직접 유지합니다. 프레임워크가 항상 로컬 디스크 저장소에서 레코드를 제공하도록 지시하려면 Kotlin에서는 UpdateInfoService을 확장하고 Java에서는 ListenableFutureUpdateInfoService을 확장하여 shouldFetchUpdates()를 재정의하여 false를 반환하세요.

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

자바

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

옵션 B: 풀 모델 (주문형)

풀 기반 아키텍처에서 서비스는 로컬 캐시가 오래되었을 때 클라이언트 앱에 의해 트리거되는 온디맨드 새로고침 요청을 처리합니다.

주문형 업데이트 쿼리를 처리하려면 Kotlin에서 UpdateInfoService를 확장하거나(정지 fetchUpdates() 함수 구현) Java에서 ListenableFutureUpdateInfoService를 확장합니다 (Guava ListenableFuture를 반환하는 fetchUpdatesAsync() 구현).

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

자바

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()가 호출될 때마다 오래된 업데이트를 자동으로 삭제하지만 업데이트 프로그램은 OTA 업데이트 설치가 완료된 후 다음 예약된 서버 동기화 주기까지 registerUpdate()를 다시 호출하지 않습니다. 클라이언트 앱이 재부팅 직후 이미 설치된 업데이트를 아직 대기 중인 것으로 인식하지 않도록 하려면 ACTION_BOOT_COMPLETED를 리슨하고 OTA 업데이트 설치가 완료되면 UpdateInfoManager.unregisterUpdate()를 호출하여 로컬 캐시에서 레코드를 삭제하세요. 업데이트 설치가 완료된 경우에만 이렇게 하면 모든 일반 기기 재부팅 시 대기 중인(제거된) 업데이트를 무조건 삭제하지 않아도 됩니다. 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())

자바

// 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 디버그 브리지 (ADB)를 사용하여 Android 기기 또는 에뮬레이터에서 다음 검사를 실행하여 엔드 투 엔드 통합을 검증하고 일반적인 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
    

    클라이언트에 제공업체가 표시되지 않으면 SecurityPatchState 태그에서 Ignoring untrusted update provider를 logcat에서 확인합니다.

  2. 사용자 0과 직장 프로필 모두에서 인텐트 확인: Android OS PackageManager이 기본 사용자 (User 0)와 활성 Android Enterprise 직장 프로필 (예: User 10) 모두에서 내보낸 UPDATE_INFO_SERVICE 인텐트 필터를 확인한다고 어설션합니다.

    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)이 fetchDurationMillis == 0을 사용하여 UpdateFetchOutcome.CACHE_HIT(1)을 로깅하는지 확인합니다.
    • 풀 모델 제공자 (shouldFetchUpdates() == true): 초기 오래된 캐시 쿼리에서 onRequestCompleted(telemetry)이 UpdateFetchOutcome.FETCHED(3)을 로깅한 후 즉시 후속 쿼리에서 CACHE_HIT (1)을 로깅하는지 확인합니다. (풀 모델 테스트 실행 간에 1시간 영구 비율 제한기를 재설정하려면 adb shell pm clear <your_package_name>를 실행하세요.)

선택적 및 고급 구성

캐싱 정책 및 비율 제한

클라이언트가 업데이트를 쿼리하면 UpdateInfoService는 데이터 최신성과 백엔드 서버 부하의 균형을 맞추기 위해 이중 확인 잠금 워크플로를 실행합니다.

UpdateInfoService는 데이터 업데이트 빈도와 백엔드 서버 부하의 균형을 맞추기 위해 이중 확인 잠금 워크플로를 실행합니다.

  • 빠른 경로 (shouldFetchUpdates()): 기본적으로 shouldFetchUpdates()는 전역 lastCheckTimeMillis이 1시간 (TimeUnit.HOURS.toMillis(1))보다 오래된 경우에만 true(오래된 캐시를 나타냄)를 반환합니다. shouldFetchUpdates()이 false을 반환하면 서비스는 잠금을 획득하거나 네트워크 I/O를 실행하지 않고 결과가 UpdateFetchOutcome.CACHE_HIT인 캐시된 레코드를 즉시 반환합니다. shouldFetchUpdates()를 재정의하여 이 캐싱 정책을 맞춤설정할 수 있습니다.
  • 느린 경로 및 요청 병합: shouldFetchUpdates()가 true를 반환하면 서비스가 내부 코루틴 뮤텍스를 획득하고 shouldFetchUpdates()를 다시 평가합니다 (동시 요청이 잠금을 기다리는 동안 이미 캐시를 새로고침한 경우 UpdateFetchOutcome.COALESCED 반환).
  • 영구 비율 제한기 (shouldThrottle()): 백엔드 인프라를 쿼리 버스트 또는 반복된 실패로부터 보호하기 위해 shouldThrottle()는 앱 및 기기 재시작 전반에 걸쳐 영구적인 최소 1시간 간격을 적용합니다. UpdateInfoService는 fetchUpdates()를 호출하기 전에 각 시도를 기록하므로 fetchUpdates()가 예외를 발생시키는 경우(onFetchFailed(e) 호출 후 UpdateFetchOutcome.FAILED 반환) 다음 60분 동안의 후속 쿼리는 결과가 UpdateFetchOutcome.THROTTLED인 캐시된 대체 데이터를 정상적으로 반환합니다.

관측 가능성, 원격 분석, 진단

UpdateInfoService는 클라이언트 채택을 추적하고, IPC 지연 시간을 모니터링하고, 하위 수준 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)
    }
}

자바

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에 정의된 5가지 결과 중 하나를 보고합니다.

결과 상수 @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 백엔드 네트워크 동기화가 성공적으로 실행되었습니다 (fetchUpdates() 완료). 디스크에 유지된 새 레코드입니다.
UpdateFetchOutcome.THROTTLED 4 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, callerUid 속도 제한기에 의해 요청이 차단되었습니다 (shouldThrottle()이 true을 반환함). 캐시된 데이터가 클라이언트에 안전하게 반환되었습니다 (fetchDurationMillis이 0임).
UpdateFetchOutcome.FAILED 5 totalDurationMillis, lockWaitDurationMillis, processingDurationMillis, fetchDurationMillis, callerUid 업데이트 확인 또는 네트워크 요청에서 예외가 발생했습니다. 예외 방화벽에 의해 포착되고 onFetchFailed(e)이 실행되고 캐시된 대체가 반환되었습니다.

고급 서비스 브로커 후크: getCallerUid()

클라이언트가 연결되면 UpdateInfoService는 초기 바인더 스레드에서 getCallerUid()를 자동으로 평가하고 (fetchUpdates() 전에 Binder.clearCallingIdentity() 호출 전) 패키지 소유권을 확인하고 확인된 호출자 UID를 onRequestCompleted(telemetry)의 onClientConnected(), onClientDisconnected(), telemetry.callerUid에 직접 전달합니다.

표준 Android <service> 구성요소의 경우 getCallerUid()를 호출하거나 재정의할 필요가 없습니다. protected open getCallerUid() 메서드 (기본적으로 Binder.getCallingUid()에 위임)는 내부 서비스 브로커나 프록시 아키텍처를 통해 바인더 IPC를 라우팅하는 호스트 앱의 재정의 후크로 제공되어 하위 클래스가 브로커의 UID가 아닌 논리적 클라이언트 UID를 반환할 수 있습니다.

추가 리소스

보안 상태 게시에 관한 자세한 내용은 다음 리소스를 참고하세요.

문서

API 참조