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)