diff --git a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt index 3a4d3f6529e..1f7cc4dc607 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 @@ private fun AppInfoSettings(packageInfoPresenter: PackageInfoPresenter) { 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 00000000000..e1792a915ba --- /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 7ec5d3edc71..fe8babb56e7 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.RemoteException 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 @@ data class NotificationSentState( 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 @@ class AppNotificationRepository( private val notificationManager: INotificationManager = INotificationManager.Stub.asInterface( ServiceManager.getService(Context.NOTIFICATION_SERVICE) ), -) { +) : IAppNotificationRepository { fun getAggregatedUsageEvents(userIdFlow: Flow): Flow> = userIdFlow.map { userId -> val aggregatedStats = mutableMapOf() @@ -115,6 +122,58 @@ class AppNotificationRepository( } } + 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 00000000000..c54d35f3c62 --- /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 7a5bc9f719a..a1d8d3fe2fc 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.content.pm.ApplicationInfo 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 @@ class AppNotificationRepositoryTest { 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 @@ class AppNotificationRepositoryTest { @Test fun isEnabled() { - whenever(notificationManager.areNotificationsEnabledForPackage(APP.packageName, APP.uid)) - .thenReturn(true) + mockIsEnabled(app = APP, enabled = true) val isEnabled = repository.isEnabled(APP) @@ -211,6 +245,61 @@ class AppNotificationRepositoryTest { .setNotificationsEnabledForPackage(APP.packageName, APP.uid, true) } + @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)