Merge "Add AppNotificationPreference to App Info Settings"
diff --git a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt
index 3a4d3f6..1f7cc4d 100644
--- a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt
+++ b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt
@@ -95,7 +95,7 @@
AppSettingsPreference(app)
AppAllServicesPreference(app)
- // TODO: notification_settings
+ AppNotificationPreference(app)
AppPermissionPreference(app)
AppStoragePreference(app)
InstantAppDomainsPreference(app)
diff --git a/src/com/android/settings/spa/app/appinfo/AppNotificationPreference.kt b/src/com/android/settings/spa/app/appinfo/AppNotificationPreference.kt
new file mode 100644
index 0000000..e1792a9
--- /dev/null
+++ b/src/com/android/settings/spa/app/appinfo/AppNotificationPreference.kt
@@ -0,0 +1,67 @@
+/*
+* Copyright (C) 2022 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.settings.spa.app.appinfo
+
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.android.settings.R
+import com.android.settings.applications.appinfo.AppInfoDashboardFragment
+import com.android.settings.notification.app.AppNotificationSettings
+import com.android.settings.spa.notification.AppNotificationRepository
+import com.android.settings.spa.notification.IAppNotificationRepository
+import com.android.settingslib.spa.framework.compose.rememberContext
+import com.android.settingslib.spa.widget.preference.Preference
+import com.android.settingslib.spa.widget.preference.PreferenceModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+
+@OptIn(ExperimentalLifecycleComposeApi::class)
+@Composable
+fun AppNotificationPreference(
+ app: ApplicationInfo,
+ repository: IAppNotificationRepository = rememberContext(::AppNotificationRepository),
+) {
+ val context = LocalContext.current
+ val summaryFlow = remember(app) {
+ flow {
+ emit(repository.getNotificationSummary(app))
+ }.flowOn(Dispatchers.IO)
+ }
+ Preference(object : PreferenceModel {
+ override val title = stringResource(R.string.notifications_label)
+ override val summary = summaryFlow.collectAsStateWithLifecycle(
+ initialValue = stringResource(R.string.summary_placeholder)
+ )
+ override val onClick = { navigateToAppNotificationSettings(context, app) }
+ })
+}
+
+private fun navigateToAppNotificationSettings(context: Context, app: ApplicationInfo) {
+ AppInfoDashboardFragment.startAppInfoFragment(
+ AppNotificationSettings::class.java,
+ app,
+ context,
+ AppInfoSettingsProvider.METRICS_CATEGORY,
+ )
+}
\ No newline at end of file
diff --git a/src/com/android/settings/spa/notification/AppNotificationRepository.kt b/src/com/android/settings/spa/notification/AppNotificationRepository.kt
index 7ec5d3e..fe8babb 100644
--- a/src/com/android/settings/spa/notification/AppNotificationRepository.kt
+++ b/src/com/android/settings/spa/notification/AppNotificationRepository.kt
@@ -31,8 +31,10 @@
import android.os.ServiceManager
import android.util.Log
import com.android.settings.R
+import com.android.settingslib.spa.framework.util.formatString
import com.android.settingslib.spaprivileged.model.app.IPackageManagers
import com.android.settingslib.spaprivileged.model.app.PackageManagers
+import com.android.settingslib.spaprivileged.model.app.userId
import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.math.roundToInt
@@ -50,6 +52,11 @@
var sentCount: Int = 0,
)
+interface IAppNotificationRepository {
+ /** Gets the notification summary for the given application. */
+ fun getNotificationSummary(app: ApplicationInfo): String
+}
+
class AppNotificationRepository(
private val context: Context,
private val packageManagers: IPackageManagers = PackageManagers,
@@ -59,7 +66,7 @@
private val notificationManager: INotificationManager = INotificationManager.Stub.asInterface(
ServiceManager.getService(Context.NOTIFICATION_SERVICE)
),
-) {
+) : IAppNotificationRepository {
fun getAggregatedUsageEvents(userIdFlow: Flow<Int>): Flow<Map<String, NotificationSentState>> =
userIdFlow.map { userId ->
val aggregatedStats = mutableMapOf<String, NotificationSentState>()
@@ -115,6 +122,58 @@
}
}
+ override fun getNotificationSummary(app: ApplicationInfo): String {
+ if (!isEnabled(app)) return context.getString(R.string.off)
+ val channelCount = getChannelCount(app)
+ if (channelCount == 0) {
+ return calculateFrequencySummary(getSentCount(app))
+ }
+ val blockedChannelCount = getBlockedChannelCount(app)
+ if (channelCount == blockedChannelCount) return context.getString(R.string.off)
+ val frequencySummary = calculateFrequencySummary(getSentCount(app))
+ if (blockedChannelCount == 0) return frequencySummary
+ return context.getString(
+ R.string.notifications_enabled_with_info,
+ frequencySummary,
+ context.formatString(
+ R.string.notifications_categories_off, "count" to blockedChannelCount
+ )
+ )
+ }
+
+ private fun getSentCount(app: ApplicationInfo): Int {
+ var sentCount = 0
+ queryEventsForPackageForUser(app).forEachNotificationEvent { sentCount++ }
+ return sentCount
+ }
+
+ private fun queryEventsForPackageForUser(app: ApplicationInfo): UsageEvents? {
+ val now = System.currentTimeMillis()
+ val startTime = now - TimeUnit.DAYS.toMillis(DAYS_TO_CHECK)
+ return try {
+ usageStatsManager.queryEventsForPackageForUser(
+ startTime, now, app.userId, app.packageName, context.packageName
+ )
+ } catch (e: RemoteException) {
+ Log.e(TAG, "Failed IUsageStatsManager.queryEventsForPackageForUser(): ", e)
+ null
+ }
+ }
+
+ private fun getChannelCount(app: ApplicationInfo): Int = try {
+ notificationManager.getNumNotificationChannelsForPackage(app.packageName, app.uid, false)
+ } catch (e: Exception) {
+ Log.w(TAG, "Error calling INotificationManager", e)
+ 0
+ }
+
+ private fun getBlockedChannelCount(app: ApplicationInfo): Int = try {
+ notificationManager.getBlockedChannelCount(app.packageName, app.uid)
+ } catch (e: Exception) {
+ Log.w(TAG, "Error calling INotificationManager", e)
+ 0
+ }
+
fun calculateFrequencySummary(sentCount: Int): String {
val dailyFrequency = (sentCount.toFloat() / DAYS_TO_CHECK).roundToInt()
return if (dailyFrequency > 0) {
diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppNotificationPreferenceTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppNotificationPreferenceTest.kt
new file mode 100644
index 0000000..c54d35f
--- /dev/null
+++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppNotificationPreferenceTest.kt
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2022 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.settings.spa.app.appinfo
+
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performClick
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.dx.mockito.inline.extended.ExtendedMockito
+import com.android.settings.R
+import com.android.settings.applications.appinfo.AppInfoDashboardFragment
+import com.android.settings.notification.app.AppNotificationSettings
+import com.android.settings.spa.notification.IAppNotificationRepository
+import com.android.settingslib.spa.testutils.delay
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.MockitoSession
+import org.mockito.Spy
+import org.mockito.quality.Strictness
+
+@RunWith(AndroidJUnit4::class)
+class AppNotificationPreferenceTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private lateinit var mockSession: MockitoSession
+
+ @Spy
+ private val context: Context = ApplicationProvider.getApplicationContext()
+
+ private val repository = object : IAppNotificationRepository {
+ override fun getNotificationSummary(app: ApplicationInfo) = SUMMARY
+ }
+
+ @Before
+ fun setUp() {
+ mockSession = ExtendedMockito.mockitoSession()
+ .initMocks(this)
+ .mockStatic(AppInfoDashboardFragment::class.java)
+ .strictness(Strictness.LENIENT)
+ .startMocking()
+ }
+
+ @After
+ fun tearDown() {
+ mockSession.finishMocking()
+ }
+
+ @Test
+ fun title_displayed() {
+ setContent()
+
+ composeTestRule.onNodeWithText(context.getString(R.string.notifications_label))
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun summary_displayed() {
+ setContent()
+
+ composeTestRule.onNodeWithText(SUMMARY).assertIsDisplayed()
+ }
+
+ @Test
+ fun onClick_startActivity() {
+ setContent()
+
+ composeTestRule.onRoot().performClick()
+ composeTestRule.delay()
+
+ ExtendedMockito.verify {
+ AppInfoDashboardFragment.startAppInfoFragment(
+ AppNotificationSettings::class.java,
+ APP,
+ context,
+ AppInfoSettingsProvider.METRICS_CATEGORY,
+ )
+ }
+ }
+
+ private fun setContent() {
+ composeTestRule.setContent {
+ CompositionLocalProvider(LocalContext provides context) {
+ AppNotificationPreference(app = APP, repository = repository)
+ }
+ }
+ }
+
+ private companion object {
+ const val PACKAGE_NAME = "package.name"
+ const val UID = 123
+ val APP = ApplicationInfo().apply {
+ packageName = PACKAGE_NAME
+ uid = UID
+ }
+ const val SUMMARY = "Summary"
+ }
+}
\ No newline at end of file
diff --git a/tests/spa_unit/src/com/android/settings/spa/notification/AppNotificationRepositoryTest.kt b/tests/spa_unit/src/com/android/settings/spa/notification/AppNotificationRepositoryTest.kt
index 7a5bc9f..a1d8d3f 100644
--- a/tests/spa_unit/src/com/android/settings/spa/notification/AppNotificationRepositoryTest.kt
+++ b/tests/spa_unit/src/com/android/settings/spa/notification/AppNotificationRepositoryTest.kt
@@ -29,8 +29,10 @@
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.settings.R
import com.android.settingslib.spa.testutils.any
import com.android.settingslib.spaprivileged.model.app.IPackageManagers
+import com.android.settingslib.spaprivileged.model.app.userId
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
@@ -89,6 +91,39 @@
return channel
}
+ private fun mockIsEnabled(app: ApplicationInfo, enabled: Boolean) {
+ whenever(notificationManager.areNotificationsEnabledForPackage(app.packageName, app.uid))
+ .thenReturn(enabled)
+ }
+
+ private fun mockChannelCount(app: ApplicationInfo, count: Int) {
+ whenever(
+ notificationManager.getNumNotificationChannelsForPackage(
+ app.packageName,
+ app.uid,
+ false,
+ )
+ ).thenReturn(count)
+ }
+
+ private fun mockBlockedChannelCount(app: ApplicationInfo, count: Int) {
+ whenever(notificationManager.getBlockedChannelCount(app.packageName, app.uid))
+ .thenReturn(count)
+ }
+
+ private fun mockSentCount(app: ApplicationInfo, sentCount: Int) {
+ val events = (1..sentCount).map {
+ UsageEvents.Event().apply {
+ mEventType = UsageEvents.Event.NOTIFICATION_INTERRUPTION
+ }
+ }
+ whenever(
+ usageStatsManager.queryEventsForPackageForUser(
+ any(), any(), eq(app.userId), eq(app.packageName), any()
+ )
+ ).thenReturn(UsageEvents(events, arrayOf()))
+ }
+
@Test
fun getAggregatedUsageEvents() = runTest {
val events = listOf(
@@ -120,8 +155,7 @@
@Test
fun isEnabled() {
- whenever(notificationManager.areNotificationsEnabledForPackage(APP.packageName, APP.uid))
- .thenReturn(true)
+ mockIsEnabled(app = APP, enabled = true)
val isEnabled = repository.isEnabled(APP)
@@ -212,6 +246,61 @@
}
@Test
+ fun getNotificationSummary_notEnabled() {
+ mockIsEnabled(app = APP, enabled = false)
+
+ val summary = repository.getNotificationSummary(APP)
+
+ assertThat(summary).isEqualTo(context.getString(R.string.off))
+ }
+
+ @Test
+ fun getNotificationSummary_noChannel() {
+ mockIsEnabled(app = APP, enabled = true)
+ mockChannelCount(app = APP, count = 0)
+ mockSentCount(app = APP, sentCount = 1)
+
+ val summary = repository.getNotificationSummary(APP)
+
+ assertThat(summary).isEqualTo("About 1 notification per week")
+ }
+
+ @Test
+ fun getNotificationSummary_allChannelsBlocked() {
+ mockIsEnabled(app = APP, enabled = true)
+ mockChannelCount(app = APP, count = 2)
+ mockBlockedChannelCount(app = APP, count = 2)
+
+ val summary = repository.getNotificationSummary(APP)
+
+ assertThat(summary).isEqualTo(context.getString(R.string.off))
+ }
+
+ @Test
+ fun getNotificationSummary_noChannelBlocked() {
+ mockIsEnabled(app = APP, enabled = true)
+ mockChannelCount(app = APP, count = 2)
+ mockSentCount(app = APP, sentCount = 2)
+ mockBlockedChannelCount(app = APP, count = 0)
+
+ val summary = repository.getNotificationSummary(APP)
+
+ assertThat(summary).isEqualTo("About 2 notifications per week")
+ }
+
+ @Test
+ fun getNotificationSummary_someChannelsBlocked() {
+ mockIsEnabled(app = APP, enabled = true)
+ mockChannelCount(app = APP, count = 2)
+ mockSentCount(app = APP, sentCount = 3)
+ mockBlockedChannelCount(app = APP, count = 1)
+
+ val summary = repository.getNotificationSummary(APP)
+
+ assertThat(summary).isEqualTo("About 3 notifications per week / 1 category turned off")
+ }
+
+ @Test
fun calculateFrequencySummary_daily() {
val summary = repository.calculateFrequencySummary(4)