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

Commit 1f323b51 authored by Darrell Shi's avatar Darrell Shi
Browse files

Introduce PackageInstallerMonitor

This monitor exposes a flow of package install sessions.

Test: PackageInstallerMonitorTest
Bug: 330945453
Flag: ACONFIG com.android.systemui.communal_hub TEAMFOOD
Change-Id: I9da688532ee9f1aea998bedd35c624d7bf6f9246
parent 59975e3f
Loading
Loading
Loading
Loading
+15 −13
Original line number Diff line number Diff line
@@ -50,6 +50,7 @@ class PackageChangeRepositoryTest : SysuiTestCase() {
    @Mock private lateinit var context: Context
    @Mock private lateinit var packageManager: PackageManager
    @Mock private lateinit var handler: Handler
    @Mock private lateinit var packageInstallerMonitor: PackageInstallerMonitor

    private lateinit var repository: PackageChangeRepository
    private lateinit var updateMonitor: PackageUpdateMonitor
@@ -60,7 +61,8 @@ class PackageChangeRepositoryTest : SysuiTestCase() {
            MockitoAnnotations.initMocks(this@PackageChangeRepositoryTest)
            whenever(context.packageManager).thenReturn(packageManager)

            repository = PackageChangeRepositoryImpl { user ->
            repository =
                PackageChangeRepositoryImpl(packageInstallerMonitor) { user ->
                    updateMonitor =
                        PackageUpdateMonitor(
                            user = user,
+228 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.systemui.common.data.repository

import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.SessionInfo
import android.graphics.Bitmap
import android.os.fakeExecutorHandler
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.common.shared.model.PackageInstallSession
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.applicationCoroutineScope
import com.android.systemui.kosmos.testScope
import com.android.systemui.log.logcatLogBuffer
import com.android.systemui.testKosmos
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
import com.android.systemui.util.mockito.withArgCaptor
import com.google.common.truth.Correspondence
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
class PackageInstallerMonitorTest : SysuiTestCase() {
    @Mock private lateinit var packageInstaller: PackageInstaller
    @Mock private lateinit var icon1: Bitmap
    @Mock private lateinit var icon2: Bitmap
    @Mock private lateinit var icon3: Bitmap

    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope

    private val handler = kosmos.fakeExecutorHandler

    private lateinit var session1: SessionInfo
    private lateinit var session2: SessionInfo
    private lateinit var session3: SessionInfo

    private lateinit var defaultSessions: List<SessionInfo>

    private lateinit var underTest: PackageInstallerMonitor

    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)

        session1 =
            SessionInfo().apply {
                sessionId = 1
                appPackageName = "pkg_name_1"
                appIcon = icon1
            }
        session2 =
            SessionInfo().apply {
                sessionId = 2
                appPackageName = "pkg_name_2"
                appIcon = icon2
            }
        session3 =
            SessionInfo().apply {
                sessionId = 3
                appPackageName = "pkg_name_3"
                appIcon = icon3
            }
        defaultSessions = listOf(session1, session2)

        whenever(packageInstaller.allSessions).thenReturn(defaultSessions)
        whenever(packageInstaller.getSessionInfo(1)).thenReturn(session1)
        whenever(packageInstaller.getSessionInfo(2)).thenReturn(session2)

        underTest =
            PackageInstallerMonitor(
                handler,
                kosmos.applicationCoroutineScope,
                logcatLogBuffer("PackageInstallerRepositoryImplTest"),
                packageInstaller,
            )
    }

    @Test
    fun installSessions_callbacksRegisteredOnlyWhenFlowIsCollected() =
        testScope.runTest {
            // Verify callback not added before flow is collected
            verify(packageInstaller, never()).registerSessionCallback(any(), eq(handler))

            // Start collecting the flow
            val job =
                backgroundScope.launch {
                    underTest.installSessionsForPrimaryUser.collect {
                        // Do nothing with the value
                    }
                }
            runCurrent()

            // Verify callback added only after flow is collected
            val callback =
                withArgCaptor<PackageInstaller.SessionCallback> {
                    verify(packageInstaller).registerSessionCallback(capture(), eq(handler))
                }

            // Verify callback not removed
            verify(packageInstaller, never()).unregisterSessionCallback(any())

            // Stop collecting the flow
            job.cancel()
            runCurrent()

            // Verify callback removed once flow stops being collected
            verify(packageInstaller).unregisterSessionCallback(eq(callback))
        }

    @Test
    fun installSessions_newSessionsAreAdded() =
        testScope.runTest {
            val installSessions by collectLastValue(underTest.installSessionsForPrimaryUser)
            assertThat(installSessions)
                .comparingElementsUsing(represents)
                .containsExactlyElementsIn(defaultSessions)

            val callback =
                withArgCaptor<PackageInstaller.SessionCallback> {
                    verify(packageInstaller).registerSessionCallback(capture(), eq(handler))
                }

            // New session added
            whenever(packageInstaller.getSessionInfo(3)).thenReturn(session3)
            callback.onCreated(3)
            runCurrent()

            // Verify flow updated with the new session
            assertThat(installSessions)
                .comparingElementsUsing(represents)
                .containsExactlyElementsIn(defaultSessions + session3)
        }

    @Test
    fun installSessions_finishedSessionsAreRemoved() =
        testScope.runTest {
            val installSessions by collectLastValue(underTest.installSessionsForPrimaryUser)
            assertThat(installSessions)
                .comparingElementsUsing(represents)
                .containsExactlyElementsIn(defaultSessions)

            val callback =
                withArgCaptor<PackageInstaller.SessionCallback> {
                    verify(packageInstaller).registerSessionCallback(capture(), eq(handler))
                }

            // Session 1 finished successfully
            callback.onFinished(1, /* success = */ true)
            runCurrent()

            // Verify flow updated with session 1 removed
            assertThat(installSessions)
                .comparingElementsUsing(represents)
                .containsExactlyElementsIn(defaultSessions - session1)
        }

    @Test
    fun installSessions_sessionsUpdatedOnBadgingChange() =
        testScope.runTest {
            val installSessions by collectLastValue(underTest.installSessionsForPrimaryUser)
            assertThat(installSessions)
                .comparingElementsUsing(represents)
                .containsExactlyElementsIn(defaultSessions)

            val callback =
                withArgCaptor<PackageInstaller.SessionCallback> {
                    verify(packageInstaller).registerSessionCallback(capture(), eq(handler))
                }

            // App icon for session 1 updated
            val newSession =
                SessionInfo().apply {
                    sessionId = 1
                    appPackageName = "pkg_name_1"
                    appIcon = mock()
                }
            whenever(packageInstaller.getSessionInfo(1)).thenReturn(newSession)
            callback.onBadgingChanged(1)
            runCurrent()

            // Verify flow updated with the new session 1
            assertThat(installSessions)
                .comparingElementsUsing(represents)
                .containsExactlyElementsIn(defaultSessions - session1 + newSession)
        }

    private val represents =
        Correspondence.from<PackageInstallSession, SessionInfo>(
            { actual, expected ->
                actual?.sessionId == expected?.sessionId &&
                    actual?.packageName == expected?.appPackageName &&
                    actual?.icon == expected?.getAppIcon()
            },
            "represents",
        )
}
+4 −0
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ package com.android.systemui.common.data.repository

import android.os.UserHandle
import com.android.systemui.common.shared.model.PackageChangeModel
import com.android.systemui.common.shared.model.PackageInstallSession
import kotlinx.coroutines.flow.Flow

interface PackageChangeRepository {
@@ -28,4 +29,7 @@ interface PackageChangeRepository {
     * [UserHandle.USER_ALL] may be used to listen to all users.
     */
    fun packageChanged(user: UserHandle): Flow<PackageChangeModel>

    /** Emits a list of all known install sessions associated with the primary user. */
    val packageInstallSessionsForPrimaryUser: Flow<List<PackageInstallSession>>
}
+5 −0
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ package com.android.systemui.common.data.repository

import android.os.UserHandle
import com.android.systemui.common.shared.model.PackageChangeModel
import com.android.systemui.common.shared.model.PackageInstallSession
import com.android.systemui.dagger.SysUISingleton
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
@@ -27,6 +28,7 @@ import kotlinx.coroutines.flow.filter
class PackageChangeRepositoryImpl
@Inject
constructor(
    packageInstallerMonitor: PackageInstallerMonitor,
    private val monitorFactory: PackageUpdateMonitor.Factory,
) : PackageChangeRepository {
    /**
@@ -37,4 +39,7 @@ constructor(

    override fun packageChanged(user: UserHandle): Flow<PackageChangeModel> =
        monitor.packageChanged.filter { user == UserHandle.ALL || user == it.user }

    override val packageInstallSessionsForPrimaryUser: Flow<List<PackageInstallSession>> =
        packageInstallerMonitor.installSessionsForPrimaryUser
}
+154 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.systemui.common.data.repository

import android.content.pm.PackageInstaller
import android.os.Handler
import com.android.internal.annotations.GuardedBy
import com.android.systemui.common.shared.model.PackageInstallSession
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.log.LogBuffer
import com.android.systemui.log.core.Logger
import com.android.systemui.log.dagger.PackageChangeRepoLog
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach

/** Monitors package install sessions for all users. */
@SysUISingleton
class PackageInstallerMonitor
@Inject
constructor(
    @Background private val bgHandler: Handler,
    @Background private val bgScope: CoroutineScope,
    @PackageChangeRepoLog logBuffer: LogBuffer,
    private val packageInstaller: PackageInstaller,
) : PackageInstaller.SessionCallback() {

    private val logger = Logger(logBuffer, TAG)

    @GuardedBy("sessions") private val sessions = mutableMapOf<Int, PackageInstallSession>()

    private val _installSessions =
        MutableStateFlow<List<PackageInstallSession>>(emptyList()).apply {
            subscriptionCount
                .map { count -> count > 0 }
                .distinctUntilChanged()
                // Drop initial false value
                .dropWhile { !it }
                .onEach { isActive ->
                    if (isActive) {
                        synchronized(sessions) {
                            sessions.putAll(
                                packageInstaller.allSessions
                                    .map { session -> session.toModel() }
                                    .associateBy { it.sessionId }
                            )
                            updateInstallerSessionsFlow()
                        }
                        packageInstaller.registerSessionCallback(
                            this@PackageInstallerMonitor,
                            bgHandler
                        )
                    } else {
                        synchronized(sessions) {
                            sessions.clear()
                            updateInstallerSessionsFlow()
                        }
                        packageInstaller.unregisterSessionCallback(this@PackageInstallerMonitor)
                    }
                }
                .launchIn(bgScope)
        }

    val installSessionsForPrimaryUser: Flow<List<PackageInstallSession>> =
        _installSessions.asStateFlow()

    /** Called when a new installer session is created. */
    override fun onCreated(sessionId: Int) {
        logger.i({ "session created $int1" }) { int1 = sessionId }
        updateSession(sessionId)
    }

    /** Called when new installer session has finished. */
    override fun onFinished(sessionId: Int, success: Boolean) {
        logger.i({ "session finished $int1" }) { int1 = sessionId }
        synchronized(sessions) {
            sessions.remove(sessionId)
            updateInstallerSessionsFlow()
        }
    }

    /**
     * Badging details for the session changed. For example, the app icon or label has been updated.
     */
    override fun onBadgingChanged(sessionId: Int) {
        logger.i({ "session badging changed $int1" }) { int1 = sessionId }
        updateSession(sessionId)
    }

    /**
     * A session is considered active when there is ongoing forward progress being made. For
     * example, a package started downloading.
     */
    override fun onActiveChanged(sessionId: Int, active: Boolean) {
        // Active status updates are not tracked for now
    }

    override fun onProgressChanged(sessionId: Int, progress: Float) {
        // Progress updates are not tracked for now
    }

    private fun updateSession(sessionId: Int) {
        val session = packageInstaller.getSessionInfo(sessionId)

        synchronized(sessions) {
            if (session == null) {
                sessions.remove(sessionId)
            } else {
                sessions[sessionId] = session.toModel()
            }
            updateInstallerSessionsFlow()
        }
    }

    @GuardedBy("sessions")
    private fun updateInstallerSessionsFlow() {
        _installSessions.value = sessions.values.toList()
    }

    companion object {
        const val TAG = "PackageInstallerMonitor"

        private fun PackageInstaller.SessionInfo.toModel(): PackageInstallSession {
            return PackageInstallSession(
                sessionId = this.sessionId,
                packageName = this.appPackageName,
                icon = this.getAppIcon(),
                user = this.user,
            )
        }
    }
}
Loading