diff --git a/src/com/android/settings/applications/AppInfoBase.java b/src/com/android/settings/applications/AppInfoBase.java index 0f21097f539..155583e0ba6 100644 --- a/src/com/android/settings/applications/AppInfoBase.java +++ b/src/com/android/settings/applications/AppInfoBase.java @@ -26,6 +26,7 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; @@ -235,6 +236,22 @@ public abstract class AppInfoBase extends SettingsPreferenceFragment .launch(); } + /** Starts app info fragment from SPA pages. */ + public static void startAppInfoFragment(Class fragment, String title, ApplicationInfo app, + Context context, int sourceMetricsCategory) { + final Bundle args = new Bundle(); + args.putString(AppInfoBase.ARG_PACKAGE_NAME, app.packageName); + args.putInt(AppInfoBase.ARG_PACKAGE_UID, app.uid); + + new SubSettingLauncher(context) + .setDestination(fragment.getName()) + .setSourceMetricsCategory(sourceMetricsCategory) + .setTitleText(title) + .setArguments(args) + .setUserHandle(UserHandle.getUserHandleForUid(app.uid)) + .launch(); + } + public static class MyAlertDialogFragment extends InstrumentedDialogFragment { private static final String ARG_ID = "id"; diff --git a/src/com/android/settings/spa/SpaEnvironment.kt b/src/com/android/settings/spa/SpaEnvironment.kt index fad7ef23ea8..28de3edd8b9 100644 --- a/src/com/android/settings/spa/SpaEnvironment.kt +++ b/src/com/android/settings/spa/SpaEnvironment.kt @@ -18,6 +18,8 @@ package com.android.settings.spa import com.android.settings.spa.app.InstallUnknownAppsListProvider import com.android.settings.spa.home.HomePageProvider +import com.android.settings.spa.notification.AppListNotificationsPageProvider +import com.android.settings.spa.notification.NotificationMainPageProvider import com.android.settingslib.spa.framework.common.SettingsPageProviderRepository import com.android.settingslib.spaprivileged.template.app.TogglePermissionAppListTemplate @@ -28,6 +30,8 @@ private val togglePermissionAppListTemplate = TogglePermissionAppListTemplate( val settingsPageProviders = SettingsPageProviderRepository( allPagesList = listOf( HomePageProvider, + NotificationMainPageProvider, + AppListNotificationsPageProvider, ) + togglePermissionAppListTemplate.createPageProviders(), rootPages = listOf(HomePageProvider.name), ) diff --git a/src/com/android/settings/spa/home/HomePage.kt b/src/com/android/settings/spa/home/HomePage.kt index 6cd6fe67086..47dffb85ff5 100644 --- a/src/com/android/settings/spa/home/HomePage.kt +++ b/src/com/android/settings/spa/home/HomePage.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.android.settings.R import com.android.settings.spa.app.InstallUnknownAppsListProvider +import com.android.settings.spa.notification.NotificationMainPageProvider import com.android.settingslib.spa.framework.common.SettingsPageProvider import com.android.settingslib.spa.widget.scaffold.HomeScaffold @@ -37,5 +38,6 @@ object HomePageProvider : SettingsPageProvider { private fun HomePage() { HomeScaffold(title = stringResource(R.string.settings_label)) { InstallUnknownAppsListProvider.EntryItem() + NotificationMainPageProvider.EntryItem() } } diff --git a/src/com/android/settings/spa/notification/AppListNotifications.kt b/src/com/android/settings/spa/notification/AppListNotifications.kt new file mode 100644 index 00000000000..da4ebb5dc21 --- /dev/null +++ b/src/com/android/settings/spa/notification/AppListNotifications.kt @@ -0,0 +1,94 @@ +/* + * 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.notification + +import android.app.settings.SettingsEnums +import android.content.Context +import android.content.pm.ApplicationInfo +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.produceState +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.android.settings.R +import com.android.settings.applications.AppInfoBase +import com.android.settings.notification.app.AppNotificationSettings +import com.android.settingslib.spa.framework.common.SettingsPageProvider +import com.android.settingslib.spa.framework.compose.navigator +import com.android.settingslib.spa.framework.compose.rememberContext +import com.android.settingslib.spa.framework.compose.toState +import com.android.settingslib.spa.widget.preference.Preference +import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spaprivileged.template.app.AppListItemModel +import com.android.settingslib.spaprivileged.template.app.AppListPage +import com.android.settingslib.spaprivileged.template.app.AppListSwitchItem + +object AppListNotificationsPageProvider : SettingsPageProvider { + override val name = "AppListNotifications" + + @Composable + override fun Page(arguments: Bundle?) { + AppListPage( + title = stringResource(R.string.app_notifications_title), + listModel = rememberContext(::AppNotificationsListModel), + ) { + AppNotificationsItem(it) + } + } + + @Composable + fun EntryItem() { + Preference(object : PreferenceModel { + override val title = stringResource(R.string.app_notifications_title) + override val summary = stringResource(R.string.app_notification_field_summary).toState() + override val onClick = navigator(name) + }) + } +} + +@Composable +private fun AppNotificationsItem( + itemModel: AppListItemModel, +) { + val appNotificationsRepository = rememberContext(::AppNotificationRepository) + val context = LocalContext.current + AppListSwitchItem( + itemModel = itemModel, + onClick = { + navigateToAppNotificationSettings( + context = context, + app = itemModel.record.app, + ) + }, + checked = itemModel.record.controller.isEnabled.observeAsState(), + changeable = produceState(initialValue = false) { + value = appNotificationsRepository.isChangeable(itemModel.record.app) + }, + onCheckedChange = itemModel.record.controller::setEnabled, + ) +} + +private fun navigateToAppNotificationSettings(context: Context, app: ApplicationInfo) { + AppInfoBase.startAppInfoFragment( + AppNotificationSettings::class.java, + context.getString(R.string.notifications_title), + app, + context, + SettingsEnums.MANAGE_APPLICATIONS_NOTIFICATIONS, + ) +} diff --git a/src/com/android/settings/spa/notification/AppNotificationController.kt b/src/com/android/settings/spa/notification/AppNotificationController.kt new file mode 100644 index 00000000000..1ce72e01f72 --- /dev/null +++ b/src/com/android/settings/spa/notification/AppNotificationController.kt @@ -0,0 +1,50 @@ +/* + * 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.notification + +import android.content.pm.ApplicationInfo +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData + +class AppNotificationController( + private val repository: AppNotificationRepository, + private val app: ApplicationInfo, +) { + val isEnabled: LiveData + get() = _isEnabled + + fun getEnabled() = _isEnabled.get() + + fun setEnabled(enabled: Boolean) { + if (repository.setEnabled(app, enabled)) { + _isEnabled.postValue(enabled) + } + } + + private val _isEnabled = object : MutableLiveData() { + override fun onActive() { + postValue(repository.isEnabled(app)) + } + + override fun onInactive() { + } + + fun get(): Boolean = value ?: repository.isEnabled(app).also { + postValue(it) + } + } +} diff --git a/src/com/android/settings/spa/notification/AppNotificationRepository.kt b/src/com/android/settings/spa/notification/AppNotificationRepository.kt new file mode 100644 index 00000000000..c73aa0047ba --- /dev/null +++ b/src/com/android/settings/spa/notification/AppNotificationRepository.kt @@ -0,0 +1,144 @@ +/* + * 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.notification + +import android.Manifest +import android.annotation.IntRange +import android.app.INotificationManager +import android.app.NotificationChannel +import android.app.NotificationManager.IMPORTANCE_NONE +import android.app.NotificationManager.IMPORTANCE_UNSPECIFIED +import android.app.usage.IUsageStatsManager +import android.app.usage.UsageEvents +import android.content.Context +import android.content.pm.ApplicationInfo +import android.os.Build +import android.os.RemoteException +import android.os.ServiceManager +import android.util.Log +import com.android.settingslib.spaprivileged.model.app.PackageManagers.hasRequestPermission +import java.util.concurrent.TimeUnit +import kotlin.math.max +import kotlin.math.roundToInt +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * This contains how often an app sends notifications and how recently it sent one. + */ +data class NotificationSentState( + @IntRange(from = 0) + var lastSent: Long = 0, + + @IntRange(from = 0) + var sentCount: Int = 0, +) + +class AppNotificationRepository(private val context: Context) { + fun getAggregatedUsageEvents(userIdFlow: Flow): Flow> = + userIdFlow.map { userId -> + val aggregatedStats = mutableMapOf() + queryEventsForUser(userId)?.let { events -> + val event = UsageEvents.Event() + while (events.hasNextEvent()) { + events.getNextEvent(event) + if (event.eventType == UsageEvents.Event.NOTIFICATION_INTERRUPTION) { + aggregatedStats.getOrPut(event.packageName, ::NotificationSentState) + .apply { + lastSent = max(lastSent, event.timeStamp) + sentCount++ + } + } + } + } + aggregatedStats + } + + private fun queryEventsForUser(userId: Int): UsageEvents? { + val now = System.currentTimeMillis() + val startTime = now - TimeUnit.DAYS.toMillis(DAYS_TO_CHECK) + return try { + usageStatsManager.queryEventsForUser(startTime, now, userId, context.packageName) + } catch (e: RemoteException) { + Log.e(TAG, "Failed IUsageStatsManager.queryEventsForUser(): ", e) + null + } + } + + fun isEnabled(app: ApplicationInfo): Boolean = + notificationManager.areNotificationsEnabledForPackage(app.packageName, app.uid) + + fun isChangeable(app: ApplicationInfo): Boolean { + if (notificationManager.isImportanceLocked(app.packageName, app.uid)) { + return false + } + + // If the app targets T but has not requested the permission, we cannot change the + // permission state. + return app.targetSdkVersion < Build.VERSION_CODES.TIRAMISU || + hasRequestPermission(app, Manifest.permission.POST_NOTIFICATIONS) + } + + fun setEnabled(app: ApplicationInfo, enabled: Boolean): Boolean { + if (onlyHasDefaultChannel(app)) { + getChannel(app, NotificationChannel.DEFAULT_CHANNEL_ID)?.let { channel -> + channel.importance = if (enabled) IMPORTANCE_UNSPECIFIED else IMPORTANCE_NONE + updateChannel(app, channel) + } + } + return try { + notificationManager.setNotificationsEnabledForPackage(app.packageName, app.uid, enabled) + true + } catch (e: Exception) { + Log.w(TAG, "Error calling INotificationManager", e) + false + } + } + + private fun updateChannel(app: ApplicationInfo, channel: NotificationChannel) { + notificationManager.updateNotificationChannelForPackage(app.packageName, app.uid, channel) + } + + private fun onlyHasDefaultChannel(app: ApplicationInfo): Boolean = + notificationManager.onlyHasDefaultChannel(app.packageName, app.uid) + + private fun getChannel(app: ApplicationInfo, channelId: String): NotificationChannel? = + notificationManager.getNotificationChannelForPackage( + app.packageName, app.uid, channelId, null, true + ) + + companion object { + private const val TAG = "AppNotificationsRepo" + + const val DAYS_TO_CHECK = 7L + + private val usageStatsManager by lazy { + IUsageStatsManager.Stub.asInterface( + ServiceManager.getService(Context.USAGE_STATS_SERVICE) + ) + } + + private val notificationManager by lazy { + INotificationManager.Stub.asInterface( + ServiceManager.getService(Context.NOTIFICATION_SERVICE) + ) + } + + fun calculateDailyFrequent(sentCount: Int): Int = + (sentCount.toFloat() / DAYS_TO_CHECK).roundToInt() + } +} diff --git a/src/com/android/settings/spa/notification/AppNotificationsListModel.kt b/src/com/android/settings/spa/notification/AppNotificationsListModel.kt new file mode 100644 index 00000000000..ff951fce144 --- /dev/null +++ b/src/com/android/settings/spa/notification/AppNotificationsListModel.kt @@ -0,0 +1,128 @@ +/* + * 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.notification + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.icu.text.RelativeDateTimeFormatter +import androidx.compose.runtime.Composable +import com.android.settings.R +import com.android.settings.spa.notification.SpinnerItem.Companion.toSpinnerItem +import com.android.settingslib.spa.framework.compose.stateOf +import com.android.settingslib.spa.framework.util.asyncFilter +import com.android.settingslib.spa.framework.util.asyncForEach +import com.android.settingslib.spaprivileged.model.app.AppEntry +import com.android.settingslib.spaprivileged.model.app.AppListModel +import com.android.settingslib.spaprivileged.model.app.AppRecord +import com.android.settingslib.utils.StringUtil +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +data class AppNotificationsRecord( + override val app: ApplicationInfo, + val sentState: NotificationSentState?, + val controller: AppNotificationController, +) : AppRecord + +class AppNotificationsListModel( + private val context: Context, +) : AppListModel { + private val repository = AppNotificationRepository(context) + private val now = System.currentTimeMillis() + + override fun transform( + userIdFlow: Flow, appListFlow: Flow>, + ) = repository.getAggregatedUsageEvents(userIdFlow) + .combine(appListFlow) { usageEvents, appList -> + appList.map { app -> + AppNotificationsRecord( + app = app, + sentState = usageEvents[app.packageName], + controller = AppNotificationController(repository, app), + ) + } + } + + override fun filter( + userIdFlow: Flow, option: Int, recordListFlow: Flow>, + ) = recordListFlow.map { recordList -> + recordList.asyncFilter { record -> + when (option.toSpinnerItem()) { + SpinnerItem.MostRecent -> record.sentState != null + SpinnerItem.MostFrequent -> record.sentState != null + SpinnerItem.TurnedOff -> !record.controller.getEnabled() + else -> true + } + } + } + + override suspend fun onFirstLoaded(recordList: List) { + recordList.asyncForEach { it.controller.getEnabled() } + } + + override fun getComparator(option: Int) = when (option.toSpinnerItem()) { + SpinnerItem.MostRecent -> compareByDescending { it.record.sentState?.lastSent } + SpinnerItem.MostFrequent -> compareByDescending { it.record.sentState?.sentCount } + else -> compareBy> { 0 } + }.then(super.getComparator(option)) + + @Composable + override fun getSummary(option: Int, record: AppNotificationsRecord) = record.sentState?.let { + when (option.toSpinnerItem()) { + SpinnerItem.MostRecent -> stateOf(formatLastSent(it.lastSent)) + SpinnerItem.MostFrequent -> stateOf(calculateFrequent(it.sentCount)) + else -> null + } + } + + override fun getSpinnerOptions() = SpinnerItem.values().map { + context.getString(it.stringResId) + } + + private fun formatLastSent(lastSent: Long) = + StringUtil.formatRelativeTime( + context, + (now - lastSent).toDouble(), + true, + RelativeDateTimeFormatter.Style.LONG, + ).toString() + + private fun calculateFrequent(sentCount: Int): String { + val dailyFrequent = AppNotificationRepository.calculateDailyFrequent(sentCount) + return if (dailyFrequent > 0) { + context.resources.getQuantityString( + R.plurals.notifications_sent_daily, dailyFrequent, dailyFrequent + ) + } else { + context.resources.getQuantityString( + R.plurals.notifications_sent_weekly, sentCount, sentCount + ) + } + } +} + +private enum class SpinnerItem(val stringResId: Int) { + MostRecent(R.string.sort_order_recent_notification), + MostFrequent(R.string.sort_order_frequent_notification), + AllApps(R.string.filter_all_apps), + TurnedOff(R.string.filter_notif_blocked_apps); + + companion object { + fun Int.toSpinnerItem(): SpinnerItem = values()[this] + } +} diff --git a/src/com/android/settings/spa/notification/NotificationMain.kt b/src/com/android/settings/spa/notification/NotificationMain.kt new file mode 100644 index 00000000000..46246b5329d --- /dev/null +++ b/src/com/android/settings/spa/notification/NotificationMain.kt @@ -0,0 +1,59 @@ +/* + * 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.notification + +import android.os.Bundle +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.android.settings.R +import com.android.settingslib.spa.framework.common.SettingsPageProvider +import com.android.settingslib.spa.framework.compose.navigator +import com.android.settingslib.spa.framework.compose.toState +import com.android.settingslib.spa.widget.preference.Preference +import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spa.widget.scaffold.RegularScaffold +import com.android.settingslib.spa.widget.ui.SettingsIcon + +object NotificationMainPageProvider : SettingsPageProvider { + override val name = "NotificationMain" + + @Composable + override fun Page(arguments: Bundle?) { + NotificationMain() + } + + @Composable + fun EntryItem() { + Preference(object : PreferenceModel { + override val title = stringResource(R.string.configure_notification_settings) + override val summary = stringResource(R.string.notification_dashboard_summary).toState() + override val onClick = navigator(name) + override val icon = @Composable { + SettingsIcon(imageVector = Icons.Outlined.Notifications) + } + }) + } +} + +@Composable +private fun NotificationMain() { + RegularScaffold(title = stringResource(R.string.configure_notification_settings)) { + AppListNotificationsPageProvider.EntryItem() + } +}