Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 31ce3cde authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Add TvWatchdogService (foundation for TvWatchdogHelper), TvWatchdogServiceTest" into main

parents d2088a53 c76eba29
Loading
Loading
Loading
Loading
+237 −0
Original line number Diff line number Diff line
/*
 * Copyright 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.server.tv.watchdogservice;

import android.app.NotificationManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.text.TextUtils;
import android.util.ArraySet;
import android.util.Log;
import android.util.SparseArray;

import com.android.internal.annotations.GuardedBy;
import com.android.server.utils.Slogf;

/**
 * TV Watchdog Service.
 *
 * <p>This service runs in the System Server to monitor system health, focusing on I/O overuse, and
 * interacts with a native watchdog daemon. It replaces CarWatchdogService for the TV platform.
 */
public final class TvWatchdogService implements TvWatchdogHelper.Callback {
    /** Tag for logging. */
    static final String TAG = "TvWatchdogService";

    /** Flag to enable/disable debug logs. */
    static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);

    /** The starting ID for resource overuse notifications. */
    static final int RESOURCE_OVERUSE_NOTIFICATION_BASE_ID = 1100000;

    /** The number of unique notification IDs to cycle through. */
    static final int RESOURCE_OVERUSE_NOTIFICATION_MAX_OFFSET = 1000;

    private final Context mContext;
    private final Object mLock = new Object();
    // The TvWatchdogHelper is initialized here but will be used in subsequent changes.
    @SuppressWarnings("unused")
    private final TvWatchdogHelper mHelper;
    private final NotificationManager mNotificationManager;

    /** Tracks whether the device is currently in idle mode. */
    @GuardedBy("mLock")
    private boolean mIsDeviceIdle = false;

    /**
     * Maps a posted notification's ID to the unique user-package identifier it represents. Used to
     * manage active notifications.
     */
    @GuardedBy("mLock")
    private final SparseArray<String> mActiveUserNotificationsByNotificationId =
            new SparseArray<>();

    /**
     * Set of unique user-package identifiers that currently have an active notification posted.
     * Used to prevent duplicate notifications.
     */
    @GuardedBy("mLock")
    private final ArraySet<String> mActiveUserNotifications = new ArraySet<>();

    /**
     * A cycling counter used to generate unique notification IDs. Incremented after each
     * notification is posted.
     */
    @GuardedBy("mLock")
    private int mCurrentOveruseNotificationIdOffset;

    /** Listens for explicit user dismissal of our notifications. */
    private final BroadcastReceiver mNotificationDismissalReceiver =
            new BroadcastReceiver() {
                @Override
                public void onReceive(Context context, Intent intent) {
                    if (TvWatchdogHelper.ACTION_NOTIFICATION_DISMISSED.equals(intent.getAction())) {
                        int notificationId =
                                intent.getIntExtra(TvWatchdogHelper.EXTRA_NOTIFICATION_ID, -1);
                        if (notificationId != -1) {
                            onNotificationDismissed(notificationId);
                        }
                    }
                }
            };

    /** Listens for package uninstallations to clean up notification state. */
    private final BroadcastReceiver mPackageRemovalReceiver =
            new BroadcastReceiver() {
                @Override
                public void onReceive(Context context, Intent intent) {
                    if (Intent.ACTION_PACKAGE_REMOVED.equals(intent.getAction())) {
                        final String packageName = intent.getData().getSchemeSpecificPart();
                        if (!TextUtils.isEmpty(packageName)) {
                            onPackageRemoved(packageName);
                        }
                    }
                }
            };

    /**
     * Initializes the TvWatchdogService.
     *
     * @param context The system context.
     */
    public TvWatchdogService(Context context) {
        mContext = context;
        mNotificationManager = mContext.getSystemService(NotificationManager.class);
        mHelper = new TvWatchdogHelper(mContext, this, DEBUG);

        IntentFilter dismissFilter =
                new IntentFilter(TvWatchdogHelper.ACTION_NOTIFICATION_DISMISSED);
        mContext.registerReceiver(
                mNotificationDismissalReceiver, dismissFilter, Context.RECEIVER_NOT_EXPORTED);

        IntentFilter packageFilter = new IntentFilter(Intent.ACTION_PACKAGE_REMOVED);
        packageFilter.addDataScheme("package");
        mContext.registerReceiver(
                mPackageRemovalReceiver, packageFilter, Context.RECEIVER_NOT_EXPORTED);
    }

    /** Cleans up resources. This should be called when the service is destroyed. */
    public void shutdown() {
        mContext.unregisterReceiver(mNotificationDismissalReceiver);
        mContext.unregisterReceiver(mPackageRemovalReceiver);
    }

    // --- TvWatchdogHelper.Callback Implementation ---

    @Override
    public boolean isDeviceIdle() {
        synchronized (mLock) {
            return mIsDeviceIdle;
        }
    }

    @Override
    public int reserveNotificationSlot(String userPackageUniqueId) {
        synchronized (mLock) {
            if (mActiveUserNotifications.contains(userPackageUniqueId)) {
                Slogf.w(TAG, "Dropping duplicate notification request for " + userPackageUniqueId);
                return -1; // Slot already reserved for this package.
            }

            final int initialOffset = mCurrentOveruseNotificationIdOffset;
            while (true) {
                final int notificationId =
                        RESOURCE_OVERUSE_NOTIFICATION_BASE_ID + mCurrentOveruseNotificationIdOffset;
                // Check if this notification ID is already in use.
                if (mActiveUserNotificationsByNotificationId.get(notificationId) == null) {
                    // This ID is free. Reserve it.
                    mActiveUserNotifications.add(userPackageUniqueId);
                    mActiveUserNotificationsByNotificationId.put(
                            notificationId, userPackageUniqueId);
                    mCurrentOveruseNotificationIdOffset =
                            (mCurrentOveruseNotificationIdOffset + 1)
                                    % RESOURCE_OVERUSE_NOTIFICATION_MAX_OFFSET;
                    return notificationId; // Success
                }

                // ID is in use, try the next one.
                mCurrentOveruseNotificationIdOffset =
                        (mCurrentOveruseNotificationIdOffset + 1)
                                % RESOURCE_OVERUSE_NOTIFICATION_MAX_OFFSET;
                // If we have checked all 1000 slots and all are full, we cannot proceed.
                if (mCurrentOveruseNotificationIdOffset == initialOffset) {
                    Slogf.e(
                            TAG,
                            "All "
                                    + RESOURCE_OVERUSE_NOTIFICATION_MAX_OFFSET
                                    + " notification slots are in use. Cannot post for "
                                    + userPackageUniqueId);
                    return -1; // All slots are full.
                }
            }
        }
    }

    @Override
    public void cancelNotificationSlot(String userPackageUniqueId, int notificationId) {
        synchronized (mLock) {
            mActiveUserNotifications.remove(userPackageUniqueId);
            mActiveUserNotificationsByNotificationId.remove(notificationId);
        }
    }

    @Override
    public void onNotificationDismissed(int notificationId) {
        synchronized (mLock) {
            String userPackageUniqueId =
                    mActiveUserNotificationsByNotificationId.get(notificationId);
            if (userPackageUniqueId != null) {
                mActiveUserNotificationsByNotificationId.remove(notificationId);
                mActiveUserNotifications.remove(userPackageUniqueId);
                if (DEBUG) {
                    Slogf.d(TAG, "Cleared dismissed notification state for " + userPackageUniqueId);
                }
            }
        }
    }

    /** Cleans up any active notification state for a package that was just uninstalled. */
    private void onPackageRemoved(String packageName) {
        synchronized (mLock) {
            for (int i = mActiveUserNotificationsByNotificationId.size() - 1; i >= 0; i--) {
                String userPackageUniqueId = mActiveUserNotificationsByNotificationId.valueAt(i);
                if (userPackageUniqueId != null
                        && userPackageUniqueId.endsWith(":" + packageName)) {
                    int notificationId = mActiveUserNotificationsByNotificationId.keyAt(i);
                    mNotificationManager.cancel(notificationId);
                    mActiveUserNotificationsByNotificationId.removeAt(i);
                    mActiveUserNotifications.remove(userPackageUniqueId);
                    if (DEBUG) {
                        Slogf.d(
                                TAG,
                                "Cleaned up notification (ID: "
                                        + notificationId
                                        + ") for uninstalled package: "
                                        + userPackageUniqueId);
                    }
                }
            }
        }
    }
}
+192 −0
Original line number Diff line number Diff line
/*
 * Copyright 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.server.tv.watchdogservice;

import static com.android.server.tv.watchdogservice.TvWatchdogService.RESOURCE_OVERUSE_NOTIFICATION_BASE_ID;
import static com.android.server.tv.watchdogservice.TvWatchdogService.RESOURCE_OVERUSE_NOTIFICATION_MAX_OFFSET;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.app.NotificationManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.Uri;

import androidx.test.runner.AndroidJUnit4;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;

import java.util.HashSet;
import java.util.Set;

/** Tests for {@link TvWatchdogService}. */
@RunWith(AndroidJUnit4.class)
public final class TvWatchdogServiceTest {

    private static final String FAKE_PACKAGE_NAME_1 = "com.example.app1";
    private static final String FAKE_PACKAGE_NAME_2 = "com.example.app2";
    private static final int USER_ID_0 = 0;
    private static final int USER_ID_10 = 10;

    @Rule public MockitoRule rule = MockitoJUnit.rule();

    @Mock private Context mMockContext;
    @Mock private NotificationManager mMockNotificationManager;

    @Captor private ArgumentCaptor<BroadcastReceiver> mReceiverCaptor;
    @Captor private ArgumentCaptor<IntentFilter> mFilterCaptor;

    private TvWatchdogService mService;
    private BroadcastReceiver mPackageRemovalReceiver;

    @Before
    public void setUp() {
        when(mMockContext.getSystemServiceName(NotificationManager.class))
                .thenReturn(Context.NOTIFICATION_SERVICE);
        when(mMockContext.getSystemService(Context.NOTIFICATION_SERVICE))
                .thenReturn(mMockNotificationManager);

        // This is the corrected line with anyInt()
        when(mMockContext.registerReceiver(
                mReceiverCaptor.capture(), mFilterCaptor.capture(), anyInt()))
                .thenReturn(null);

        mService = new TvWatchdogService(mMockContext);

        for (IntentFilter filter : mFilterCaptor.getAllValues()) {
            if (filter.hasAction(Intent.ACTION_PACKAGE_REMOVED)) {
                int index = mFilterCaptor.getAllValues().indexOf(filter);
                mPackageRemovalReceiver = mReceiverCaptor.getAllValues().get(index);
                break;
            }
        }
        assertNotNull("Package removal receiver should be registered", mPackageRemovalReceiver);
    }

    @Test
    public void testReserveNotificationSlot_firstReservation_succeeds() {
        String userPackageId =
                IoOveruseHandler.getUserPackageUniqueId(USER_ID_0, FAKE_PACKAGE_NAME_1);
        int notificationId = mService.reserveNotificationSlot(userPackageId);
        assertEquals(RESOURCE_OVERUSE_NOTIFICATION_BASE_ID, notificationId);
    }

    @Test
    public void testReserveNotificationSlot_duplicateRequest_returnsNegativeOne() {
        String userPackageId =
                IoOveruseHandler.getUserPackageUniqueId(USER_ID_0, FAKE_PACKAGE_NAME_1);

        int firstId = mService.reserveNotificationSlot(userPackageId);
        assertNotEquals(-1, firstId);

        int secondId = mService.reserveNotificationSlot(userPackageId);
        assertEquals(-1, secondId);
    }

    @Test
    public void testReserveNotificationSlot_allSlotsFull_returnsNegativeOne() {
        Set<String> reservedPackages = new HashSet<>();
        for (int i = 0; i < RESOURCE_OVERUSE_NOTIFICATION_MAX_OFFSET; i++) {
            String userPackageId =
                    IoOveruseHandler.getUserPackageUniqueId(USER_ID_0, "com.example.app" + i);

            int notificationId = mService.reserveNotificationSlot(userPackageId);

            assertNotEquals(-1, notificationId);
            assertTrue(reservedPackages.add(userPackageId));
        }

        String overflowPackageId =
                IoOveruseHandler.getUserPackageUniqueId(USER_ID_0, "com.example.overflow");
        int overflowId = mService.reserveNotificationSlot(overflowPackageId);
        assertEquals(-1, overflowId);
    }

    @Test
    public void onNotificationDismissed_clearsStateAndAllowsNewReservation() {
        String userPackageId =
                IoOveruseHandler.getUserPackageUniqueId(USER_ID_0, FAKE_PACKAGE_NAME_1);

        int notificationId = mService.reserveNotificationSlot(userPackageId);
        assertNotEquals(-1, notificationId);

        assertEquals(-1, mService.reserveNotificationSlot(userPackageId));

        mService.onNotificationDismissed(notificationId);

        int newNotificationId = mService.reserveNotificationSlot(userPackageId);
        assertNotEquals(-1, newNotificationId);
        assertNotEquals(notificationId, newNotificationId);
    }

    @Test
    public void onPackageRemoved_clearsStateAndCancelsNotification() {
        String userPackageId1 =
                IoOveruseHandler.getUserPackageUniqueId(USER_ID_0, FAKE_PACKAGE_NAME_1);
        int notificationId1 = mService.reserveNotificationSlot(userPackageId1);
        assertNotEquals(-1, notificationId1);

        String userPackageId2 =
                IoOveruseHandler.getUserPackageUniqueId(USER_ID_0, FAKE_PACKAGE_NAME_2);
        int notificationId2 = mService.reserveNotificationSlot(userPackageId2);
        assertNotEquals(-1, notificationId2);

        Intent removalIntent = new Intent(Intent.ACTION_PACKAGE_REMOVED);
        removalIntent.setData(Uri.parse("package:" + FAKE_PACKAGE_NAME_1));
        mPackageRemovalReceiver.onReceive(mMockContext, removalIntent);

        verify(mMockNotificationManager).cancel(notificationId1);
        verify(mMockNotificationManager, never()).cancel(notificationId2);

        int newNotificationId = mService.reserveNotificationSlot(userPackageId1);
        assertNotEquals(-1, newNotificationId);
    }

    @Test
    public void onPackageRemoved_multipleUsers_cleansUpAllNotificationsForPackage() {
        String userPackageIdUser0 =
                IoOveruseHandler.getUserPackageUniqueId(USER_ID_0, FAKE_PACKAGE_NAME_1);
        int notificationIdUser0 = mService.reserveNotificationSlot(userPackageIdUser0);
        String userPackageIdUser10 =
                IoOveruseHandler.getUserPackageUniqueId(USER_ID_10, FAKE_PACKAGE_NAME_1);
        int notificationIdUser10 = mService.reserveNotificationSlot(userPackageIdUser10);

        Intent removalIntent = new Intent(Intent.ACTION_PACKAGE_REMOVED);
        removalIntent.setData(Uri.parse("package:" + FAKE_PACKAGE_NAME_1));
        mPackageRemovalReceiver.onReceive(mMockContext, removalIntent);

        verify(mMockNotificationManager).cancel(notificationIdUser0);
        verify(mMockNotificationManager).cancel(notificationIdUser10);
    }
}