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

Commit 48dd88c4 authored by Jacky Wang's avatar Jacky Wang
Browse files

[Catalyst] Observe app-ops mode change efficiently

This improvement is to reduce the number of OnOpChangedListener
registered to AppOpsManager. By listening on `null` package, only one
single OnOpChangedListener is registered, and leverage KeyedObservable
to dispatch mode change for packages.

Bug: 420743110
Flag: EXEMPT new class
Test: atest&manual
Change-Id: Icc629e56337519156e3d3bc968e942568b7af7b5
parent af2a9e60
Loading
Loading
Loading
Loading
+122 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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.settingslib.utils.appops

import android.app.AppOpsManager
import android.app.AppOpsManager.OnOpChangedListener
import android.content.Context
import androidx.annotation.GuardedBy
import androidx.annotation.VisibleForTesting
import androidx.collection.MutableScatterMap
import com.android.settingslib.datastore.AbstractKeyedDataObservable
import com.android.settingslib.datastore.DataChangeReason
import com.android.settingslib.datastore.KeyedObservable
import com.android.settingslib.datastore.KeyedObserver
import java.util.concurrent.Executor

@GuardedBy("itself")
@VisibleForTesting
internal val appOpsModeObservables = MutableScatterMap<String, KeyedObservable<String>>()

/**
 * Adds an observer to monitor app-ops mode change on given operation. To avoid memory leak,
 * [removeAppOpsModeObserver] must be invoked sometime later.
 *
 * As an improvement, there is only one [OnOpChangedListener] registered to [AppOpsManager] for the
 * same [op].
 *
 * @param op the operation to monitor, one of `AppOpsManager.OPSTR_*`
 * @param observer observer of the mode change (callback key is package name)
 * @param executor executor to run the observer callback
 */
fun Context.addAppOpsModeObserver(
    op: String,
    observer: KeyedObserver<String?>,
    executor: Executor,
) = appOpsModeObservable(op).addObserver(observer, executor)

/**
 * Adds an observer to monitor app-ops mode change on given operation and package. To avoid memory
 * leak, [removeAppOpsModeObserver] must be invoked sometime later.
 *
 * As an improvement, there is only one [OnOpChangedListener] registered to [AppOpsManager] for the
 * same [op] even if several observers are added for different packages.
 *
 * @param op the operation to monitor, one of `AppOpsManager.OPSTR_*`
 * @param packageName package to monitor the mode change
 * @param observer observer of the mode change (callback key is package name)
 * @param executor executor to run the observer callback
 */
fun Context.addAppOpsModeObserver(
    op: String,
    packageName: String,
    observer: KeyedObserver<String>,
    executor: Executor,
) = appOpsModeObservable(op).addObserver(packageName, observer, executor)

/** Removes the observer added by [addAppOpsModeObserver]. */
fun Context.removeAppOpsModeObserver(op: String, observer: KeyedObserver<String?>) =
    appOpsModeObservable(op).removeObserver(observer)

/** Removes the observer added by [addAppOpsModeObserver]. */
fun Context.removeAppOpsModeObserver(
    op: String,
    packageName: String,
    observer: KeyedObserver<String>,
) = appOpsModeObservable(op).removeObserver(packageName, observer)

/**
 * Returns a shared [KeyedObservable] to monitor app-ops mode change on given operation. Only a
 * single [OnOpChangedListener] will be registered to [AppOpsManager] for the same [op].
 *
 * Notes:
 * - The observer key is package name.
 * - It is not recommend to save the returned object (e.g. keep in a field, pass around lambda),
 *   just invoke this method everytime. This is to avoid multiple instances of [KeyedObservable]
 *   created for the same [op]. Hence use [addAppOpsModeObserver] and [removeAppOpsModeObserver]
 *   whenever possible.
 */
internal fun Context.appOpsModeObservable(op: String): KeyedObservable<String> =
    synchronized(appOpsModeObservables) {
        appOpsModeObservables.getOrPut(op) { AppOpsModeObservable(applicationContext, op) }
    }

/**
 * Class to monitor app-ops mode change on given operation.
 *
 * This class helps to reduce the number of [OnOpChangedListener] registered to [AppOpsManager]. No
 * matter how many packages are observed, there is only one [OnOpChangedListener].
 */
private class AppOpsModeObservable(private val appContext: Context, private val op: String) :
    AbstractKeyedDataObservable<String>(), OnOpChangedListener {

    private val appOpsManager
        get() = appContext.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager

    override fun onOpChanged(op: String?, packageName: String) {
        notifyChange(packageName, DataChangeReason.UPDATE)
    }

    override fun onFirstObserverAdded() {
        appOpsManager.startWatchingMode(op, null, this)
    }

    override fun onLastObserverRemoved() {
        appOpsManager.stopWatchingMode(this)
        synchronized(appOpsModeObservables) { appOpsModeObservables.remove(op, this) }
    }
}
+23 −0
Original line number Diff line number Diff line
package {
    default_applicable_licenses: ["frameworks_base_license"],
}

android_app {
    name: "SettingsLibUtilsShell",
    platform_apis: true,
}

android_robolectric_test {
    name: "SettingsLibUtilsTest",
    srcs: ["src/**/*.kt"],
    static_libs: [
        "SettingsLibUtils",
        "androidx.core_core-ktx",
        "androidx.test.ext.junit",
        "mockito-robolectric-prebuilt", // mockito deps order matters!
        "mockito-kotlin2",
    ],
    associates: ["SettingsLibUtils"],
    coverage_libs: ["SettingsLibUtils"],
    instrumentation_for: "SettingsLibUtilsShell",
}
+2 −0
Original line number Diff line number Diff line
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.android.settingslib.utils.test" />
+104 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 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.settingslib.utils.appops

import android.app.AppOpsManager
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.settingslib.datastore.DataChangeReason
import com.android.settingslib.datastore.HandlerExecutor
import com.android.settingslib.datastore.KeyedObservable
import com.android.settingslib.datastore.KeyedObserver
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import org.mockito.kotlin.verify

@RunWith(AndroidJUnit4::class)
class AppOpsModeObservableTest {
    private val context: Context = ApplicationProvider.getApplicationContext()
    private val appOpsManager = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
    private val executor = HandlerExecutor.main
    private val op = AppOpsManager.OPSTR_MONITOR_LOCATION
    private val opCode = AppOpsManager.OP_MONITOR_LOCATION

    @Test
    fun observerNotified() {
        val pkg1 = "pkg1"
        val pkg2 = "pkg2"
        val observer1: KeyedObserver<String> = mock()
        val observer2: KeyedObserver<String> = mock()
        val anyPkgObserver: KeyedObserver<String?> = mock()

        context.addAppOpsModeObserver(op, anyPkgObserver, executor)
        val observable1 = context.appOpsModeObservable(op)
        context.addAppOpsModeObserver(op, pkg1, observer1, executor)
        context.addAppOpsModeObserver(op, pkg2, observer2, executor)
        val observable2 = context.appOpsModeObservable(op)
        try {
            assertThat(observable1).isSameInstanceAs(observable2)

            appOpsManager.setMode(opCode, 0, pkg1, AppOpsManager.MODE_ERRORED)
            appOpsManager.setMode(opCode, 0, pkg1, AppOpsManager.MODE_ERRORED) // mode unchanged
            appOpsManager.setMode(opCode, 0, pkg2, AppOpsManager.MODE_ERRORED)
            appOpsManager.setMode(opCode, 0, pkg2, AppOpsManager.MODE_ALLOWED)

            verify(observer1).onKeyChanged(pkg1, DataChangeReason.UPDATE)
            verify(anyPkgObserver).onKeyChanged(pkg1, DataChangeReason.UPDATE)
            verify(observer2, times(2)).onKeyChanged(pkg2, DataChangeReason.UPDATE)
            verify(anyPkgObserver, times(2)).onKeyChanged(pkg2, DataChangeReason.UPDATE)
        } finally {
            context.removeAppOpsModeObserver(op, anyPkgObserver)
            context.removeAppOpsModeObserver(op, pkg1, observer1)
            context.removeAppOpsModeObserver(op, pkg2, observer2)
        }
        assertThat(appOpsModeObservables.size).isEqualTo(0)
    }

    @Test
    fun appOpsModeObservable_multipleInstances() {
        val observable1 = context.appOpsModeObservable(op)
        observable1.verifyObserverNotified(0) // observable is released due to no observer

        // a new observable is created
        val observable2 = context.appOpsModeObservable(op)
        assertThat(observable1).isNotSameInstanceAs(observable2)

        // the old observable should still work
        observable1.verifyObserverNotified(1, AppOpsManager.MODE_ALLOWED) // use a different mode
        // the new observable also works
        observable2.verifyObserverNotified(0)
    }

    private fun KeyedObservable<String>.verifyObserverNotified(
        size: Int,
        mode: Int = AppOpsManager.MODE_ERRORED,
    ) {
        val observer: KeyedObserver<String> = mock()
        addObserver("pkg", observer, executor)
        try {
            appOpsManager.setMode(opCode, 0, "pkg", mode)
            verify(observer).onKeyChanged("pkg", DataChangeReason.UPDATE)
        } finally {
            removeObserver("pkg", observer)
        }
        assertThat(appOpsModeObservables.size).isEqualTo(size)
    }
}