Merge "Handle uninstall for selected controls package" into main
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/common/data/repository/PackageChangeRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/common/data/repository/PackageChangeRepositoryTest.kt
new file mode 100644
index 0000000..6380ace
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/common/data/repository/PackageChangeRepositoryTest.kt
@@ -0,0 +1,209 @@
+/*
+ * Copyright (C) 2023 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.Context
+import android.content.pm.PackageManager
+import android.os.Handler
+import android.os.UserHandle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.common.data.shared.model.PackageChangeModel
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.coroutines.collectValues
+import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.log.logcatLogBuffer
+import com.android.systemui.testKosmos
+import com.android.systemui.util.mockito.whenever
+import com.google.common.truth.Truth.assertThat
+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.MockitoAnnotations
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class PackageChangeRepositoryTest : SysuiTestCase() {
+
+ private val kosmos = testKosmos()
+
+ @Mock private lateinit var context: Context
+ @Mock private lateinit var packageManager: PackageManager
+ @Mock private lateinit var handler: Handler
+
+ private lateinit var repository: PackageChangeRepository
+ private lateinit var updateMonitor: PackageUpdateMonitor
+
+ @Before
+ fun setUp() =
+ with(kosmos) {
+ MockitoAnnotations.initMocks(this@PackageChangeRepositoryTest)
+ whenever(context.packageManager).thenReturn(packageManager)
+
+ repository = PackageChangeRepositoryImpl { user ->
+ updateMonitor =
+ PackageUpdateMonitor(
+ user = user,
+ bgDispatcher = testDispatcher,
+ scope = applicationCoroutineScope,
+ context = context,
+ bgHandler = handler,
+ logger = PackageUpdateLogger(logcatLogBuffer())
+ )
+ updateMonitor
+ }
+ }
+
+ @Test
+ fun packageUninstalled() =
+ with(kosmos) {
+ testScope.runTest {
+ val packageChange by collectLastValue(repository.packageChanged(USER_100))
+ assertThat(packageChange).isNull()
+
+ updateMonitor.onPackageRemoved(
+ packageName = TEST_PACKAGE,
+ uid = UserHandle.getUid(/* userId = */ 100, /* appId = */ 10)
+ )
+
+ assertThat(packageChange).isInstanceOf(PackageChangeModel.Uninstalled::class.java)
+ assertThat(packageChange?.packageName).isEqualTo(TEST_PACKAGE)
+ }
+ }
+
+ @Test
+ fun packageUpdateStarted() =
+ with(kosmos) {
+ testScope.runTest {
+ val packageChange by collectLastValue(repository.packageChanged(USER_100))
+ assertThat(packageChange).isNull()
+
+ updateMonitor.onPackageUpdateStarted(
+ packageName = TEST_PACKAGE,
+ uid = UserHandle.getUid(/* userId = */ 100, /* appId = */ 10)
+ )
+
+ assertThat(packageChange).isInstanceOf(PackageChangeModel.UpdateStarted::class.java)
+ assertThat(packageChange?.packageName).isEqualTo(TEST_PACKAGE)
+ }
+ }
+
+ @Test
+ fun packageUpdateFinished() =
+ with(kosmos) {
+ testScope.runTest {
+ val packageChange by collectLastValue(repository.packageChanged(USER_100))
+ assertThat(packageChange).isNull()
+
+ updateMonitor.onPackageUpdateFinished(
+ packageName = TEST_PACKAGE,
+ uid = UserHandle.getUid(/* userId = */ 100, /* appId = */ 10)
+ )
+
+ assertThat(packageChange)
+ .isInstanceOf(PackageChangeModel.UpdateFinished::class.java)
+ assertThat(packageChange?.packageName).isEqualTo(TEST_PACKAGE)
+ }
+ }
+
+ @Test
+ fun packageInstalled() =
+ with(kosmos) {
+ testScope.runTest {
+ val packageChange by collectLastValue(repository.packageChanged(UserHandle.ALL))
+ assertThat(packageChange).isNull()
+
+ updateMonitor.onPackageAdded(
+ packageName = TEST_PACKAGE,
+ uid = UserHandle.getUid(/* userId = */ 100, /* appId = */ 10)
+ )
+
+ assertThat(packageChange).isInstanceOf(PackageChangeModel.Installed::class.java)
+ assertThat(packageChange?.packageName).isEqualTo(TEST_PACKAGE)
+ }
+ }
+
+ @Test
+ fun packageIsChanged() =
+ with(kosmos) {
+ testScope.runTest {
+ val packageChange by collectLastValue(repository.packageChanged(USER_100))
+ assertThat(packageChange).isNull()
+
+ updateMonitor.onPackageChanged(
+ packageName = TEST_PACKAGE,
+ uid = UserHandle.getUid(/* userId = */ 100, /* appId = */ 10),
+ components = emptyArray()
+ )
+
+ assertThat(packageChange).isInstanceOf(PackageChangeModel.Changed::class.java)
+ assertThat(packageChange?.packageName).isEqualTo(TEST_PACKAGE)
+ }
+ }
+
+ @Test
+ fun filtersOutUpdatesFromOtherUsers() =
+ with(kosmos) {
+ testScope.runTest {
+ val packageChange by collectLastValue(repository.packageChanged(USER_100))
+ assertThat(packageChange).isNull()
+
+ updateMonitor.onPackageUpdateFinished(
+ packageName = TEST_PACKAGE,
+ uid = UserHandle.getUid(/* userId = */ 101, /* appId = */ 10)
+ )
+
+ updateMonitor.onPackageAdded(
+ packageName = TEST_PACKAGE,
+ uid = UserHandle.getUid(/* userId = */ 99, /* appId = */ 10)
+ )
+
+ assertThat(packageChange).isNull()
+ }
+ }
+
+ @Test
+ fun listenToUpdatesFromAllUsers() =
+ with(kosmos) {
+ testScope.runTest {
+ val packageChanges by collectValues(repository.packageChanged(UserHandle.ALL))
+ assertThat(packageChanges).isEmpty()
+
+ updateMonitor.onPackageUpdateFinished(
+ packageName = TEST_PACKAGE,
+ uid = UserHandle.getUid(/* userId = */ 101, /* appId = */ 10)
+ )
+
+ updateMonitor.onPackageAdded(
+ packageName = TEST_PACKAGE,
+ uid = UserHandle.getUid(/* userId = */ 99, /* appId = */ 10)
+ )
+
+ assertThat(packageChanges).hasSize(2)
+ }
+ }
+
+ private companion object {
+ val USER_100 = UserHandle.of(100)
+ const val TEST_PACKAGE = "pkg.test"
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/common/data/repository/PackageUpdateMonitorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/common/data/repository/PackageUpdateMonitorTest.kt
new file mode 100644
index 0000000..d610925
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/common/data/repository/PackageUpdateMonitorTest.kt
@@ -0,0 +1,210 @@
+/*
+ * 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.Context
+import android.content.pm.PackageManager
+import android.os.Handler
+import android.os.UserHandle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.common.data.shared.model.PackageChangeModel
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.coroutines.collectValues
+import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.log.logcatLogBuffer
+import com.android.systemui.testKosmos
+import com.android.systemui.util.mockito.whenever
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+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.MockitoAnnotations
+
+@ExperimentalCoroutinesApi
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class PackageUpdateMonitorTest : SysuiTestCase() {
+ private val kosmos = testKosmos()
+
+ @Mock private lateinit var context: Context
+ @Mock private lateinit var packageManager: PackageManager
+ @Mock private lateinit var handler: Handler
+
+ private lateinit var monitor: PackageUpdateMonitor
+
+ @Before
+ fun setUp() =
+ with(kosmos) {
+ MockitoAnnotations.initMocks(this@PackageUpdateMonitorTest)
+ whenever(context.packageManager).thenReturn(packageManager)
+
+ monitor =
+ PackageUpdateMonitor(
+ user = USER_100,
+ bgDispatcher = testDispatcher,
+ bgHandler = handler,
+ context = context,
+ scope = applicationCoroutineScope,
+ logger = PackageUpdateLogger(logcatLogBuffer())
+ )
+ }
+
+ @Test
+ fun becomesActiveWhenFlowCollected() =
+ with(kosmos) {
+ testScope.runTest {
+ assertThat(monitor.isActive).isFalse()
+ val job = monitor.packageChanged.launchIn(this)
+ runCurrent()
+ assertThat(monitor.isActive).isTrue()
+ job.cancel()
+ runCurrent()
+ assertThat(monitor.isActive).isFalse()
+ }
+ }
+
+ @Test
+ fun packageAdded() =
+ with(kosmos) {
+ testScope.runTest {
+ val packageChange by collectLastValue(monitor.packageChanged)
+ assertThat(packageChange).isNull()
+
+ monitor.onPackageAdded(TEST_PACKAGE, 123)
+
+ assertThat(packageChange)
+ .isEqualTo(
+ PackageChangeModel.Installed(packageName = TEST_PACKAGE, packageUid = 123)
+ )
+ }
+ }
+
+ @Test
+ fun packageRemoved() =
+ with(kosmos) {
+ testScope.runTest {
+ val packageChange by collectLastValue(monitor.packageChanged)
+ assertThat(packageChange).isNull()
+
+ monitor.onPackageRemoved(TEST_PACKAGE, 123)
+
+ assertThat(packageChange)
+ .isEqualTo(
+ PackageChangeModel.Uninstalled(packageName = TEST_PACKAGE, packageUid = 123)
+ )
+ }
+ }
+
+ @Test
+ fun packageChanged() =
+ with(kosmos) {
+ testScope.runTest {
+ val packageChange by collectLastValue(monitor.packageChanged)
+ assertThat(packageChange).isNull()
+
+ monitor.onPackageChanged(TEST_PACKAGE, 123, emptyArray())
+
+ assertThat(packageChange)
+ .isEqualTo(
+ PackageChangeModel.Changed(packageName = TEST_PACKAGE, packageUid = 123)
+ )
+ }
+ }
+
+ @Test
+ fun packageUpdateStarted() =
+ with(kosmos) {
+ testScope.runTest {
+ val packageChange by collectLastValue(monitor.packageChanged)
+ assertThat(packageChange).isNull()
+
+ monitor.onPackageUpdateStarted(TEST_PACKAGE, 123)
+
+ assertThat(packageChange)
+ .isEqualTo(
+ PackageChangeModel.UpdateStarted(
+ packageName = TEST_PACKAGE,
+ packageUid = 123
+ )
+ )
+ }
+ }
+
+ @Test
+ fun packageUpdateFinished() =
+ with(kosmos) {
+ testScope.runTest {
+ val packageChange by collectLastValue(monitor.packageChanged)
+ assertThat(packageChange).isNull()
+
+ monitor.onPackageUpdateFinished(TEST_PACKAGE, 123)
+
+ assertThat(packageChange)
+ .isEqualTo(
+ PackageChangeModel.UpdateFinished(
+ packageName = TEST_PACKAGE,
+ packageUid = 123
+ )
+ )
+ }
+ }
+
+ @Test
+ fun handlesBackflow() =
+ with(kosmos) {
+ testScope.runTest {
+ val latch = MutableSharedFlow<Unit>()
+ val packageChanges by collectValues(monitor.packageChanged.onEach { latch.first() })
+ assertThat(packageChanges).isEmpty()
+
+ monitor.onPackageAdded(TEST_PACKAGE, 123)
+ monitor.onPackageUpdateStarted(TEST_PACKAGE, 123)
+ monitor.onPackageUpdateFinished(TEST_PACKAGE, 123)
+
+ assertThat(packageChanges).isEmpty()
+ latch.emit(Unit)
+ assertThat(packageChanges).hasSize(1)
+ latch.emit(Unit)
+ assertThat(packageChanges).hasSize(2)
+ latch.emit(Unit)
+ assertThat(packageChanges)
+ .containsExactly(
+ PackageChangeModel.Installed(TEST_PACKAGE, 123),
+ PackageChangeModel.UpdateStarted(TEST_PACKAGE, 123),
+ PackageChangeModel.UpdateFinished(TEST_PACKAGE, 123),
+ )
+ .inOrder()
+ }
+ }
+
+ companion object {
+ private val USER_100 = UserHandle.of(100)
+ private const val TEST_PACKAGE = "pkg.test"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/common/data/CommonDataLayerModule.kt b/packages/SystemUI/src/com/android/systemui/common/data/CommonDataLayerModule.kt
index 27c9b3f..d1c728c 100644
--- a/packages/SystemUI/src/com/android/systemui/common/data/CommonDataLayerModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/common/data/CommonDataLayerModule.kt
@@ -16,6 +16,8 @@
package com.android.systemui.common.data
+import com.android.systemui.common.data.repository.PackageChangeRepository
+import com.android.systemui.common.data.repository.PackageChangeRepositoryImpl
import com.android.systemui.common.ui.data.repository.ConfigurationRepository
import com.android.systemui.common.ui.data.repository.ConfigurationRepositoryImpl
import dagger.Binds
@@ -23,5 +25,13 @@
@Module
abstract class CommonDataLayerModule {
- @Binds abstract fun bindRepository(impl: ConfigurationRepositoryImpl): ConfigurationRepository
+ @Binds
+ abstract fun bindConfigurationRepository(
+ impl: ConfigurationRepositoryImpl
+ ): ConfigurationRepository
+
+ @Binds
+ abstract fun bindPackageChangeRepository(
+ impl: PackageChangeRepositoryImpl
+ ): PackageChangeRepository
}
diff --git a/packages/SystemUI/src/com/android/systemui/common/data/repository/PackageChangeRepository.kt b/packages/SystemUI/src/com/android/systemui/common/data/repository/PackageChangeRepository.kt
new file mode 100644
index 0000000..7c7b3db
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/common/data/repository/PackageChangeRepository.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2023 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.os.UserHandle
+import com.android.systemui.common.data.shared.model.PackageChangeModel
+import kotlinx.coroutines.flow.Flow
+
+interface PackageChangeRepository {
+ /**
+ * Emits values when packages for the specified user are changed. See supported modifications in
+ * [PackageChangeModel]
+ *
+ * [UserHandle.USER_ALL] may be used to listen to all users.
+ */
+ fun packageChanged(user: UserHandle): Flow<PackageChangeModel>
+}
diff --git a/packages/SystemUI/src/com/android/systemui/common/data/repository/PackageChangeRepositoryImpl.kt b/packages/SystemUI/src/com/android/systemui/common/data/repository/PackageChangeRepositoryImpl.kt
new file mode 100644
index 0000000..b1b348c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/common/data/repository/PackageChangeRepositoryImpl.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2023 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.os.UserHandle
+import com.android.systemui.common.data.shared.model.PackageChangeModel
+import com.android.systemui.dagger.SysUISingleton
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.filter
+
+@SysUISingleton
+class PackageChangeRepositoryImpl
+@Inject
+constructor(
+ private val monitorFactory: PackageUpdateMonitor.Factory,
+) : PackageChangeRepository {
+ /**
+ * A [PackageUpdateMonitor] which monitors package updates for all users. The per-user filtering
+ * is done by [packageChanged].
+ */
+ private val monitor by lazy { monitorFactory.create(UserHandle.ALL) }
+
+ override fun packageChanged(user: UserHandle): Flow<PackageChangeModel> =
+ monitor.packageChanged.filter {
+ user == UserHandle.ALL || user == UserHandle.getUserHandleForUid(it.packageUid)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/common/data/repository/PackageUpdateLogger.kt b/packages/SystemUI/src/com/android/systemui/common/data/repository/PackageUpdateLogger.kt
new file mode 100644
index 0000000..adbb37c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/common/data/repository/PackageUpdateLogger.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2023 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.os.UserHandle
+import com.android.systemui.common.data.shared.model.PackageChangeModel
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.log.LogBuffer
+import com.android.systemui.log.core.LogLevel
+import com.android.systemui.log.dagger.PackageChangeRepoLog
+import javax.inject.Inject
+
+private fun getChangeString(model: PackageChangeModel) =
+ when (model) {
+ is PackageChangeModel.Installed -> "installed"
+ is PackageChangeModel.Uninstalled -> "uninstalled"
+ is PackageChangeModel.UpdateStarted -> "started updating"
+ is PackageChangeModel.UpdateFinished -> "finished updating"
+ is PackageChangeModel.Changed -> "changed"
+ }
+
+/** A debug logger for [PackageChangeRepository]. */
+@SysUISingleton
+class PackageUpdateLogger @Inject constructor(@PackageChangeRepoLog private val buffer: LogBuffer) {
+
+ fun logChange(model: PackageChangeModel) {
+ buffer.log(
+ TAG,
+ LogLevel.DEBUG,
+ {
+ str1 = model.packageName
+ str2 = getChangeString(model)
+ int1 = model.packageUid
+ },
+ {
+ val user = UserHandle.getUserHandleForUid(int1)
+ "Package $str1 ($int1) $str2 on user $user"
+ }
+ )
+ }
+}
+
+private const val TAG = "PackageChangeRepoLog"
diff --git a/packages/SystemUI/src/com/android/systemui/common/data/repository/PackageUpdateMonitor.kt b/packages/SystemUI/src/com/android/systemui/common/data/repository/PackageUpdateMonitor.kt
new file mode 100644
index 0000000..f7cc344
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/common/data/repository/PackageUpdateMonitor.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2023 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.Context
+import android.os.Handler
+import android.os.UserHandle
+import com.android.internal.content.PackageMonitor
+import com.android.systemui.common.data.shared.model.PackageChangeModel
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.dagger.qualifiers.Background
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+
+/**
+ * A wrapper around [PackageMonitor] which exposes package updates as a flow.
+ *
+ * External clients should use [PackageChangeRepository] instead to ensure only a single callback is
+ * registered for all of SystemUI.
+ */
+class PackageUpdateMonitor
+@AssistedInject
+constructor(
+ @Assisted private val user: UserHandle,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ @Background private val bgHandler: Handler,
+ @Application private val context: Context,
+ @Application private val scope: CoroutineScope,
+ private val logger: PackageUpdateLogger,
+) : PackageMonitor() {
+
+ @AssistedFactory
+ fun interface Factory {
+ fun create(user: UserHandle): PackageUpdateMonitor
+ }
+
+ var isActive = false
+ private set
+
+ private val _packageChanged =
+ MutableSharedFlow<PackageChangeModel>(replay = 0, extraBufferCapacity = BUFFER_CAPACITY)
+ .apply {
+ // Automatically register/unregister as needed, depending on whether
+ // there are subscribers to this flow.
+ subscriptionCount
+ .map { it > 0 }
+ .distinctUntilChanged()
+ .onEach { active ->
+ if (active) {
+ register(context, user, bgHandler)
+ } else if (isActive) {
+ // Avoid calling unregister if we were not previously active, as this
+ // will cause an IllegalStateException.
+ unregister()
+ }
+ isActive = active
+ }
+ .flowOn(bgDispatcher)
+ .launchIn(scope)
+ }
+
+ val packageChanged: Flow<PackageChangeModel>
+ get() = _packageChanged.onEach(logger::logChange)
+
+ override fun onPackageAdded(packageName: String, uid: Int) {
+ super.onPackageAdded(packageName, uid)
+ _packageChanged.tryEmit(PackageChangeModel.Installed(packageName, uid))
+ }
+
+ override fun onPackageRemoved(packageName: String, uid: Int) {
+ super.onPackageRemoved(packageName, uid)
+ _packageChanged.tryEmit(PackageChangeModel.Uninstalled(packageName, uid))
+ }
+
+ override fun onPackageChanged(
+ packageName: String,
+ uid: Int,
+ components: Array<out String>
+ ): Boolean {
+ super.onPackageChanged(packageName, uid, components)
+ _packageChanged.tryEmit(PackageChangeModel.Changed(packageName, uid))
+ return false
+ }
+
+ override fun onPackageUpdateStarted(packageName: String, uid: Int) {
+ super.onPackageUpdateStarted(packageName, uid)
+ _packageChanged.tryEmit(PackageChangeModel.UpdateStarted(packageName, uid))
+ }
+
+ override fun onPackageUpdateFinished(packageName: String, uid: Int) {
+ super.onPackageUpdateFinished(packageName, uid)
+ _packageChanged.tryEmit(PackageChangeModel.UpdateFinished(packageName, uid))
+ }
+
+ private companion object {
+ // This capacity is the number of package changes that we will keep buffered in the shared
+ // flow. It is unlikely that at any given time there would be this many changes being
+ // processed by consumers, but this is done just in case that many packages are changed at
+ // the same time and there is backflow due to consumers processing the changes more slowly
+ // than they are being emitted.
+ const val BUFFER_CAPACITY = 100
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/common/data/shared/model/PackageChangeModel.kt b/packages/SystemUI/src/com/android/systemui/common/data/shared/model/PackageChangeModel.kt
new file mode 100644
index 0000000..853eff7
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/common/data/shared/model/PackageChangeModel.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2023 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.shared.model
+
+import android.content.Intent
+
+/** Represents changes to an installed package. */
+sealed interface PackageChangeModel {
+ val packageName: String
+ val packageUid: Int
+
+ /**
+ * An existing application package was uninstalled.
+ *
+ * Equivalent to receiving the [Intent.ACTION_PACKAGE_REMOVED] broadcast with
+ * [Intent.EXTRA_REPLACING] set to false.
+ */
+ data class Uninstalled(override val packageName: String, override val packageUid: Int) :
+ PackageChangeModel
+
+ /**
+ * A new version of an existing application is going to be installed.
+ *
+ * Equivalent to receiving the [Intent.ACTION_PACKAGE_REMOVED] broadcast with
+ * [Intent.EXTRA_REPLACING] set to true.
+ */
+ data class UpdateStarted(override val packageName: String, override val packageUid: Int) :
+ PackageChangeModel
+
+ /**
+ * A new version of an existing application package has been installed, replacing the old
+ * version.
+ *
+ * Equivalent to receiving the [Intent.ACTION_PACKAGE_ADDED] broadcast with
+ * [Intent.EXTRA_REPLACING] set to true.
+ */
+ data class UpdateFinished(override val packageName: String, override val packageUid: Int) :
+ PackageChangeModel
+
+ /**
+ * A new application package has been installed.
+ *
+ * Equivalent to receiving the [Intent.ACTION_PACKAGE_ADDED] broadcast with
+ * [Intent.EXTRA_REPLACING] set to false.
+ */
+ data class Installed(override val packageName: String, override val packageUid: Int) :
+ PackageChangeModel
+
+ /**
+ * An existing application package has been changed (for example, a component has been enabled
+ * or disabled).
+ *
+ * Equivalent to receiving the [Intent.ACTION_PACKAGE_CHANGED] broadcast.
+ */
+ data class Changed(override val packageName: String, override val packageUid: Int) :
+ PackageChangeModel
+}
diff --git a/packages/SystemUI/src/com/android/systemui/controls/start/ControlsStartable.kt b/packages/SystemUI/src/com/android/systemui/controls/start/ControlsStartable.kt
index 0218f45..20bfbc9 100644
--- a/packages/SystemUI/src/com/android/systemui/controls/start/ControlsStartable.kt
+++ b/packages/SystemUI/src/com/android/systemui/controls/start/ControlsStartable.kt
@@ -26,6 +26,8 @@
import androidx.annotation.WorkerThread
import com.android.systemui.CoreStartable
import com.android.systemui.broadcast.BroadcastDispatcher
+import com.android.systemui.common.data.shared.model.PackageChangeModel
+import com.android.systemui.common.data.repository.PackageChangeRepository
import com.android.systemui.controls.controller.ControlsController
import com.android.systemui.controls.dagger.ControlsComponent
import com.android.systemui.controls.management.ControlsListingController
@@ -33,8 +35,16 @@
import com.android.systemui.controls.panels.SelectedComponentRepository
import com.android.systemui.controls.ui.SelectedItem
import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.settings.UserTracker
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
import java.util.concurrent.Executor
import javax.inject.Inject
@@ -53,13 +63,16 @@
class ControlsStartable
@Inject
constructor(
- @Background private val executor: Executor,
- private val controlsComponent: ControlsComponent,
- private val userTracker: UserTracker,
- private val authorizedPanelsRepository: AuthorizedPanelsRepository,
- private val selectedComponentRepository: SelectedComponentRepository,
- private val userManager: UserManager,
- private val broadcastDispatcher: BroadcastDispatcher,
+ @Application private val scope: CoroutineScope,
+ @Background private val bgDispatcher: CoroutineDispatcher,
+ @Background private val executor: Executor,
+ private val controlsComponent: ControlsComponent,
+ private val userTracker: UserTracker,
+ private val authorizedPanelsRepository: AuthorizedPanelsRepository,
+ private val selectedComponentRepository: SelectedComponentRepository,
+ private val packageChangeRepository: PackageChangeRepository,
+ private val userManager: UserManager,
+ private val broadcastDispatcher: BroadcastDispatcher,
) : CoreStartable {
// These two controllers can only be accessed after `start` method once we've checked if the
@@ -78,6 +91,8 @@
}
}
+ private var packageJob: Job? = null
+
override fun start() {}
override fun onBootCompleted() {
@@ -94,6 +109,21 @@
controlsListingController.forceReload()
selectDefaultPanelIfNecessary()
bindToPanel()
+ monitorPackageUninstall()
+ }
+
+ private fun monitorPackageUninstall() {
+ packageJob?.cancel()
+ packageJob = packageChangeRepository.packageChanged(userTracker.userHandle)
+ .filter {
+ val selectedPackage =
+ selectedComponentRepository.getSelectedComponent()?.componentName?.packageName
+ // Selected package was uninstalled
+ (it is PackageChangeModel.Uninstalled) && (it.packageName == selectedPackage)
+ }
+ .onEach { selectedComponentRepository.removeSelectedComponent() }
+ .flowOn(bgDispatcher)
+ .launchIn(scope)
}
private fun selectDefaultPanelIfNecessary() {
diff --git a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java
index 0d5ba64..1e67771 100644
--- a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java
+++ b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java
@@ -18,6 +18,7 @@
import android.os.Build;
+import com.android.systemui.common.data.repository.PackageChangeRepository;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.log.LogBuffer;
import com.android.systemui.log.LogBufferFactory;
@@ -600,4 +601,12 @@
public static LogBuffer provideQBluetoothTileDialogLogBuffer(LogBufferFactory factory) {
return factory.create("BluetoothTileDialogLog", 50);
}
+
+ /** Provides a {@link LogBuffer} for {@link PackageChangeRepository} */
+ @Provides
+ @SysUISingleton
+ @PackageChangeRepoLog
+ public static LogBuffer providePackageChangeRepoLogBuffer(LogBufferFactory factory) {
+ return factory.create("PackageChangeRepo", 50);
+ }
}
diff --git a/packages/SystemUI/src/com/android/systemui/log/dagger/PackageChangeRepoLog.kt b/packages/SystemUI/src/com/android/systemui/log/dagger/PackageChangeRepoLog.kt
new file mode 100644
index 0000000..93b776c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/log/dagger/PackageChangeRepoLog.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2023 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.log.dagger
+
+import com.android.systemui.common.data.repository.PackageChangeRepository
+import com.android.systemui.log.LogBuffer
+import javax.inject.Qualifier
+
+/** A [LogBuffer] for [PackageChangeRepository]. */
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class PackageChangeRepoLog
diff --git a/packages/SystemUI/tests/src/com/android/systemui/controls/start/ControlsStartableTest.kt b/packages/SystemUI/tests/src/com/android/systemui/controls/start/ControlsStartableTest.kt
index 8f65fc8..bcef67e 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/controls/start/ControlsStartableTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/controls/start/ControlsStartableTest.kt
@@ -24,19 +24,28 @@
import android.content.IntentFilter
import android.content.pm.ApplicationInfo
import android.content.pm.ServiceInfo
+import android.os.UserHandle
import android.os.UserManager
import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.broadcast.BroadcastDispatcher
+import com.android.systemui.common.data.repository.fakePackageChangeRepository
+import com.android.systemui.common.data.repository.packageChangeRepository
+import com.android.systemui.common.data.shared.model.PackageChangeModel
import com.android.systemui.controls.ControlsServiceInfo
import com.android.systemui.controls.controller.ControlsController
import com.android.systemui.controls.dagger.ControlsComponent
import com.android.systemui.controls.management.ControlsListingController
import com.android.systemui.controls.panels.AuthorizedPanelsRepository
import com.android.systemui.controls.panels.FakeSelectedComponentRepository
+import com.android.systemui.controls.panels.SelectedComponentRepository
import com.android.systemui.controls.ui.SelectedItem
+import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.testScope
import com.android.systemui.settings.UserTracker
+import com.android.systemui.testKosmos
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.argumentCaptor
@@ -48,6 +57,9 @@
import com.android.systemui.util.time.FakeSystemClock
import com.google.common.truth.Truth.assertThat
import java.util.Optional
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -61,10 +73,13 @@
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
+@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidTestingRunner::class)
class ControlsStartableTest : SysuiTestCase() {
+ private val kosmos = testKosmos()
+
@Mock private lateinit var controlsController: ControlsController
@Mock private lateinit var controlsListingController: ControlsListingController
@Mock private lateinit var userTracker: UserTracker
@@ -72,7 +87,7 @@
@Mock private lateinit var userManager: UserManager
@Mock private lateinit var broadcastDispatcher: BroadcastDispatcher
- private val preferredPanelsRepository = FakeSelectedComponentRepository()
+ private lateinit var preferredPanelsRepository: FakeSelectedComponentRepository
private lateinit var fakeExecutor: FakeExecutor
@@ -81,8 +96,10 @@
MockitoAnnotations.initMocks(this)
whenever(authorizedPanelsRepository.getPreferredPackages()).thenReturn(setOf())
whenever(userManager.isUserUnlocked(anyInt())).thenReturn(true)
+ whenever(userTracker.userHandle).thenReturn(UserHandle.of(1))
fakeExecutor = FakeExecutor(FakeSystemClock())
+ preferredPanelsRepository = FakeSelectedComponentRepository()
}
@Test
@@ -306,6 +323,100 @@
verify(controlsController, never()).setPreferredSelection(any())
}
+ @Test
+ fun testSelectedComponentIsUninstalled() =
+ with(kosmos) {
+ testScope.runTest {
+ val selectedComponent =
+ SelectedComponentRepository.SelectedComponent(
+ "panel",
+ TEST_COMPONENT_PANEL,
+ isPanel = true
+ )
+ preferredPanelsRepository.setSelectedComponent(selectedComponent)
+ val activeUser = UserHandle.of(100)
+ whenever(userTracker.userHandle).thenReturn(activeUser)
+
+ createStartable(enabled = true).onBootCompleted()
+ fakeExecutor.runAllReady()
+ runCurrent()
+
+ assertThat(preferredPanelsRepository.getSelectedComponent())
+ .isEqualTo(selectedComponent)
+ fakePackageChangeRepository.notifyChange(
+ PackageChangeModel.Uninstalled(
+ packageName = TEST_PACKAGE_PANEL,
+ packageUid = UserHandle.getUid(100, 1)
+ )
+ )
+ runCurrent()
+
+ assertThat(preferredPanelsRepository.getSelectedComponent()).isNull()
+ }
+ }
+
+ @Test
+ fun testSelectedComponentIsChanged() =
+ with(kosmos) {
+ testScope.runTest {
+ val selectedComponent =
+ SelectedComponentRepository.SelectedComponent(
+ "panel",
+ TEST_COMPONENT_PANEL,
+ isPanel = true
+ )
+ preferredPanelsRepository.setSelectedComponent(selectedComponent)
+ val activeUser = UserHandle.of(100)
+ whenever(userTracker.userHandle).thenReturn(activeUser)
+
+ createStartable(enabled = true).onBootCompleted()
+ fakeExecutor.runAllReady()
+ runCurrent()
+
+ fakePackageChangeRepository.notifyChange(
+ PackageChangeModel.Changed(
+ packageName = TEST_PACKAGE_PANEL,
+ packageUid = UserHandle.getUid(100, 1)
+ )
+ )
+ runCurrent()
+
+ assertThat(preferredPanelsRepository.getSelectedComponent())
+ .isEqualTo(selectedComponent)
+ }
+ }
+
+ @Test
+ fun testOtherPackageIsUninstalled() =
+ with(kosmos) {
+ testScope.runTest {
+ val selectedComponent =
+ SelectedComponentRepository.SelectedComponent(
+ "panel",
+ TEST_COMPONENT_PANEL,
+ isPanel = true
+ )
+ preferredPanelsRepository.setSelectedComponent(selectedComponent)
+ val activeUser = UserHandle.of(100)
+ whenever(userTracker.userHandle).thenReturn(activeUser)
+
+ createStartable(enabled = true).onBootCompleted()
+ fakeExecutor.runAllReady()
+ runCurrent()
+
+ fakePackageChangeRepository.notifyChange(
+ PackageChangeModel.Uninstalled(
+ packageName = TEST_PACKAGE,
+ packageUid = UserHandle.getUid(100, 1)
+ )
+ )
+ runCurrent()
+
+ assertThat(preferredPanelsRepository.getSelectedComponent())
+ .isEqualTo(selectedComponent)
+ }
+ }
+
private fun setUpControlsListingControls(listings: List<ControlsServiceInfo>) {
doAnswer { doReturn(listings).`when`(controlsListingController).getCurrentServices() }
.`when`(controlsListingController)
@@ -326,11 +437,14 @@
}
}
return ControlsStartable(
+ kosmos.applicationCoroutineScope,
+ kosmos.testDispatcher,
fakeExecutor,
component,
userTracker,
authorizedPanelsRepository,
preferredPanelsRepository,
+ kosmos.packageChangeRepository,
userManager,
broadcastDispatcher,
)
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/common/data/repository/FakePackageChangeRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/common/data/repository/FakePackageChangeRepository.kt
new file mode 100644
index 0000000..60f0448
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/common/data/repository/FakePackageChangeRepository.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2023 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.os.UserHandle
+import com.android.systemui.common.data.shared.model.PackageChangeModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.filter
+
+class FakePackageChangeRepository : PackageChangeRepository {
+
+ private var _packageChanged = MutableSharedFlow<PackageChangeModel>()
+
+ override fun packageChanged(user: UserHandle) =
+ _packageChanged.filter {
+ user == UserHandle.ALL || user == UserHandle.getUserHandleForUid(it.packageUid)
+ }
+
+ suspend fun notifyChange(model: PackageChangeModel) {
+ _packageChanged.emit(model)
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/common/data/repository/PackageChangeRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/common/data/repository/PackageChangeRepositoryKosmos.kt
new file mode 100644
index 0000000..adc05e0
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/common/data/repository/PackageChangeRepositoryKosmos.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2023 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 com.android.systemui.kosmos.Kosmos
+
+var Kosmos.packageChangeRepository: PackageChangeRepository by
+ Kosmos.Fixture { fakePackageChangeRepository }
+val Kosmos.fakePackageChangeRepository by Kosmos.Fixture { FakePackageChangeRepository() }