Cross-User Data Access in AOSP System Apps

In the realm of Android Open Source Project (AOSP) development, particularly when engineering system services or OEM-specific features, the single-user assumption is a critical architectural fallacy. While standard application developers operate within a comfortable sandbox tied to a single user profile, system engineers must navigate a fragmented landscape. A background service running as system_server (User 0) often needs to manipulate application data, settings, or notification channels belonging to the current foreground user (e.g., User 10) or a work profile (User 11). Failure to handle this correctly leads to "File Not Found" exceptions, data leakage between profiles, or subtle race conditions during user switching.

1. The Multi-User Isolation Architecture

Android's multi-user implementation relies on Linux UID (User ID) separation at the kernel level, but it adds an abstraction layer known as "Android Users" (or profiles). Standard Linux UIDs isolate apps from each other (App A cannot read App B), but Android Users isolate the *same* app across different profiles.

When an app is installed, it receives a unique App ID (e.g., 10054). At runtime, the effective UID is calculated as UserId * 100000 + AppId. This arithmetic ensures that the file system permissions prevent User 0's instance of Chrome from reading User 10's instance of Chrome, even though they share the same binary.

Architecture Note: The system_server and core system apps typically run as User 0 (the System User). However, the human user interacting with the touchscreen is usually running as User 10 (the first secondary user). Bridging this gap is the core challenge.

Storage Partitioning: DE vs. CE

Understanding storage encryption states is prerequisite to accessing user data. Android splits user storage into two locations based on the encryption state:

Storage Type Path Pattern Accessibility
Device Encrypted (DE) /data/user_de/<user_id>/ Available immediately after boot (before unlock).
Credential Encrypted (CE) /data/user/<user_id>/ Available only after the user enters their PIN/Pattern.

System services must check UserManager.isUserUnlocked(userId) before attempting to access CE storage. Ignoring this check is a common cause of boot-loop crashes or silent failures during OTA updates.

2. Correctly Identifying the Target User

A common mistake in system apps is assuming that UserHandle.myUserId() is the target user. If your code runs in a system service, this returns 0. If you need to act on behalf of the person holding the phone, you must identify the "Current" user.

However, "Current User" is an ambiguous term in Android due to features like Split Screen and Headless System User Mode (automotive). Use the ActivityManager or ActivityTaskManager to resolve the correct handle.

/**
 * Retrieves the user ID of the current foreground user.
 * Note: In older Android versions, ActivityManager.getCurrentUser() was used,
 * but ActivityTaskManager is preferred in modern AOSP stacks.
 */
public int getForegroundUserId() {
    try {
        // Requires INTERACT_ACROSS_USERS permission
        return ActivityTaskManager.getService().getCurrentUserId();
    } catch (RemoteException e) {
        throw e.rethrowFromSystemServer();
    }
}
Deprecation Alert: Avoid using static file paths like /data/system/users/0/. User IDs are dynamic. User 0 is not always the active user, especially in Android Automotive (AAOS) where the driver might be User 10.

3. Context Injection Patterns

The most robust way to access resources, SharedPreferences, or databases of another user is not by constructing file paths manually, but by creating a specific Context for that user. This ensures that the Android framework handles the path resolution, permission checks, and storage redirects (DE vs. CE) correctly.

The method createContextAsUser is the standard factory for this purpose. This creates a new Context instance that is functionally identical to the system context but wired to the target user's storage sandbox.

public SharedPreferences getUserSpecificPrefs(Context systemContext, int targetUserId) {
    try {
        UserHandle targetUser = UserHandle.of(targetUserId);
        
        // 1. Create a Context tied to the specific user
        // 0 flags can be replaced with CONTEXT_IGNORE_SECURITY if needed strictly within system
        Context userContext = systemContext.createContextAsUser(targetUser, 0);
        
        // 2. Access standard APIs normally
        // This will automatically resolve to /data/user/<targetUserId>/...
        return userContext.getSharedPreferences("user_settings", Context.MODE_PRIVATE);
        
    } catch (IllegalStateException e) {
        // Handle case where user is not unlocked yet
        Log.e("SystemApp", "Storage not accessible for user " + targetUserId);
        return null;
    }
}
Anti-Pattern: Never hardcode paths like new File("/data/user/" + userId + "/..."). This breaks whenever Android changes its internal storage layout (which happened in Android N and again in R) and bypasses essential SELinux contexts.

4. Cross-User ContentProvider Access

Often, data is not exposed via SharedPreferences but through a ContentProvider. If a system app needs to query the Settings.Secure or a custom provider for a specific user, standard ContentResolver.query() calls will default to the calling user (User 0). To query another user's provider, explicit authority decoration or specific APIs are required.

For standard settings, Android provides overloaded methods. For custom providers, you may need to construct a content URI that includes the user ID, provided the target ContentProvider supports it.

// Example: querying Settings for a specific user
public int getSecureSettingForUser(Context context, String settingName, int userId) {
    // Settings.Secure provides specific API for multi-user
    return Settings.Secure.getIntForUser(
        context.getContentResolver(), 
        settingName, 
        0, // default value
        userId // Explicit user ID target
    );
}

// Example: URI interaction
public void notifyChangeForUser(Context context, Uri uri, int userId) {
    context.getContentResolver().notifyChange(
        uri, 
        null, 
        false, // syncToNetwork
        userId // Explicitly notify observers in the target user space
    );
}

5. Permission Management and Security

Crossing user boundaries is a privileged operation. A standard app cannot simply peek into another user's data. System apps must declare specific permissions in their AndroidManifest.xml to bypass these checks.

  • INTERACT_ACROSS_USERS: Allows the app to query info about other users and send broadcasts to them. This is a signature-level permission.
  • INTERACT_ACROSS_USERS_FULL: A more powerful version allowing fuller interaction, including starting activities or binding services across users.
Best Practice: Always minimize the scope of your cross-user interactions. If you only need to trigger a sync, send a broadcast to the specific user handle rather than accessing their database directly.

Conclusion

Navigating the multi-user architecture in AOSP requires a shift in mindset from "device-centric" to "profile-centric" programming. By leveraging createContextAsUser, respecting storage encryption states (DE/CE), and utilizing correct IPC patterns, system engineers can build robust services that serve all users seamlessly. These abstractions not only ensure code stability across Android version updates but also maintain the strict security boundaries that define the Android platform.

Post a Comment