diff --git a/res/values/strings.xml b/res/values/strings.xml index 405960bcca8..484c9c4aa8a 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -8164,6 +8164,12 @@ Never show notifications in the shade or on peripheral devices + + Allow full screen notifications + + + Allow notifications to take up the full screen when the device is locked + Allow notification dot diff --git a/res/xml/app_notification_settings.xml b/res/xml/app_notification_settings.xml index 9857a4d1db7..f96a375224e 100644 --- a/res/xml/app_notification_settings.xml +++ b/res/xml/app_notification_settings.xml @@ -59,38 +59,57 @@ + android:summary="@string/allow_interruption_summary" + settings:allowDividerAbove="true" + settings:allowDividerBelow="false" /> + android:title="@string/app_notification_visibility_override_title" + settings:allowDividerAbove="true" + settings:allowDividerBelow="false" /> + android:summary="@string/app_notification_override_dnd_summary" + settings:allowDividerAbove="true" + settings:allowDividerBelow="false" /> + + + + settings:allowDividerBelow="false" /> + android:title="@string/app_settings_link" + android:order="1003" + settings:allowDividerAbove="true" + settings:allowDividerBelow="false" /> + android:order="8000" + settings:allowDividerAbove="true" + settings:allowDividerBelow="false" /> diff --git a/src/com/android/settings/notification/app/AppNotificationSettings.java b/src/com/android/settings/notification/app/AppNotificationSettings.java index 4ebac0f497d..ee9ec4565d2 100644 --- a/src/com/android/settings/notification/app/AppNotificationSettings.java +++ b/src/com/android/settings/notification/app/AppNotificationSettings.java @@ -74,6 +74,7 @@ public class AppNotificationSettings extends NotificationSettings { mControllers = new ArrayList<>(); mControllers.add(new HeaderPreferenceController(context, this)); mControllers.add(new BlockPreferenceController(context, mDependentFieldListener, mBackend)); + mControllers.add(new FullScreenIntentPermissionPreferenceController(context, mBackend)); mControllers.add(new BadgePreferenceController(context, mBackend)); mControllers.add(new AllowSoundPreferenceController( context, mDependentFieldListener, mBackend)); diff --git a/src/com/android/settings/notification/app/FullScreenIntentPermissionPreferenceController.kt b/src/com/android/settings/notification/app/FullScreenIntentPermissionPreferenceController.kt new file mode 100644 index 00000000000..ec997498bfe --- /dev/null +++ b/src/com/android/settings/notification/app/FullScreenIntentPermissionPreferenceController.kt @@ -0,0 +1,118 @@ +/* + * 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.settings.notification.app + +import android.Manifest.permission.USE_FULL_SCREEN_INTENT +import android.app.AppOpsManager +import android.app.AppOpsManager.OP_USE_FULL_SCREEN_INTENT +import android.content.AttributionSource +import android.content.Context +import android.content.pm.PackageManager.FLAG_PERMISSION_USER_SET +import android.content.pm.PackageManager.GET_PERMISSIONS +import android.os.UserHandle +import android.permission.PermissionManager +import android.util.Log +import androidx.preference.Preference +import androidx.preference.Preference.OnPreferenceChangeListener +import com.android.settings.notification.NotificationBackend +import com.android.settingslib.RestrictedSwitchPreference + +class FullScreenIntentPermissionPreferenceController( + context: Context, + backend: NotificationBackend +) : NotificationPreferenceController(context, backend), OnPreferenceChangeListener { + private val packageManager = mPm!! + private val permissionManager = context.getSystemService(PermissionManager::class.java)!! + private val appOpsManager = context.getSystemService(AppOpsManager::class.java)!! + + private val packageName get() = mAppRow.pkg + private val uid get() = mAppRow.uid + private val userHandle get() = UserHandle.getUserHandleForUid(uid) + + override fun getPreferenceKey() = KEY_FSI_PERMISSION + + override fun isAvailable(): Boolean { + val inAppWidePreferences = mChannelGroup == null && mChannel == null + + if (!inAppWidePreferences) { + Log.wtf(TAG, "Belongs only in app-wide notification preferences!") + } + + return super.isAvailable() && inAppWidePreferences && isPermissionRequested() + } + + override fun isIncludedInFilter() = false + + override fun updateState(preference: Preference) { + check(KEY_FSI_PERMISSION.equals(preference.key)) + check(preference is RestrictedSwitchPreference) + + preference.setDisabledByAdmin(mAdmin) + preference.isEnabled = !preference.isDisabledByAdmin + preference.isChecked = isPermissionGranted() + } + + override fun onPreferenceChange(preference: Preference, value: Any): Boolean { + check(KEY_FSI_PERMISSION.equals(preference.key)) + check(preference is RestrictedSwitchPreference) + check(value is Boolean) + + if (isPermissionGranted() != value) { + setPermissionGranted(value) + } + + return true + } + + private fun isPermissionRequested(): Boolean { + val packageInfo = packageManager.getPackageInfo(packageName, GET_PERMISSIONS) + + for (requestedPermission in packageInfo.requestedPermissions) { + if (USE_FULL_SCREEN_INTENT.equals(requestedPermission)) { + return true + } + } + + return false + } + + private fun isPermissionGranted(): Boolean { + val attributionSource = AttributionSource.Builder(uid).setPackageName(packageName).build() + + val permissionResult = + permissionManager.checkPermissionForPreflight(USE_FULL_SCREEN_INTENT, attributionSource) + + return (permissionResult == PermissionManager.PERMISSION_GRANTED) + } + + private fun setPermissionGranted(allowed: Boolean) { + val mode = if (allowed) AppOpsManager.MODE_ALLOWED else AppOpsManager.MODE_ERRORED + appOpsManager.setUidMode(OP_USE_FULL_SCREEN_INTENT, uid, mode) + packageManager.updatePermissionFlags( + USE_FULL_SCREEN_INTENT, + packageName, + FLAG_PERMISSION_USER_SET, + FLAG_PERMISSION_USER_SET, + userHandle + ) + } + + companion object { + const val KEY_FSI_PERMISSION = "fsi_permission" + const val TAG = "FsiPermPrefController" + } +} \ No newline at end of file diff --git a/tests/robotests/src/com/android/settings/notification/app/FullScreenIntentPermissionPreferenceControllerTest.kt b/tests/robotests/src/com/android/settings/notification/app/FullScreenIntentPermissionPreferenceControllerTest.kt new file mode 100644 index 00000000000..e148f71d1c0 --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/app/FullScreenIntentPermissionPreferenceControllerTest.kt @@ -0,0 +1,299 @@ +/* + * 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.settings.notification.app + +import android.Manifest.permission.USE_FULL_SCREEN_INTENT +import android.app.AppOpsManager +import android.app.AppOpsManager.MODE_ALLOWED +import android.app.AppOpsManager.MODE_ERRORED +import android.app.AppOpsManager.OP_USE_FULL_SCREEN_INTENT +import android.app.NotificationChannel +import android.app.NotificationChannelGroup +import android.app.NotificationManager.IMPORTANCE_UNSPECIFIED +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.FLAG_PERMISSION_USER_SET +import android.content.pm.PackageManager.GET_PERMISSIONS +import android.os.UserHandle +import android.permission.PermissionManager.PERMISSION_GRANTED +import android.permission.PermissionManager.PERMISSION_HARD_DENIED +import android.permission.PermissionManager.PERMISSION_SOFT_DENIED +import android.permission.PermissionManager.PermissionResult +import androidx.preference.Preference +import androidx.preference.PreferenceScreen +import androidx.test.core.app.ApplicationProvider +import com.android.settings.notification.NotificationBackend +import com.android.settings.notification.NotificationBackend.AppRow +import com.android.settings.notification.app.FullScreenIntentPermissionPreferenceController.Companion.KEY_FSI_PERMISSION +import com.android.settingslib.RestrictedLockUtils.EnforcedAdmin +import com.android.settingslib.RestrictedSwitchPreference +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Answers +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowApplicationPackageManager +import org.robolectric.shadows.ShadowPermissionChecker +import org.mockito.Mockito.`when` as whenever + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowApplicationPackageManager::class]) +class FullScreenIntentPermissionPreferenceControllerTest { + @JvmField + @Rule + val mockitoRule = MockitoJUnit.rule()!! + + @Mock + private lateinit var packageManager: PackageManager + + @Mock + private lateinit var appOpsManager: AppOpsManager + + private lateinit var preference: RestrictedSwitchPreference + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private lateinit var screen: PreferenceScreen + + private lateinit var controller: FullScreenIntentPermissionPreferenceController + + @Before + fun setUp() { + val context = spy(ApplicationProvider.getApplicationContext()) + + whenever(context.packageManager).thenReturn(packageManager) + whenever(context.getSystemService(AppOpsManager::class.java)).thenReturn(appOpsManager) + + preference = RestrictedSwitchPreference(context).apply { key = KEY_FSI_PERMISSION } + + whenever(screen.findPreference(KEY_FSI_PERMISSION)).thenReturn(preference) + + controller = FullScreenIntentPermissionPreferenceController( + context, + mock(NotificationBackend::class.java) + ) + } + + @Test + fun testIsAvailable_notWhenPermissionNotRequested() { + setPermissionRequestedInManifest(requested = false) + initController() + + assertFalse(controller.isAvailable) + } + + @Test + fun testIsAvailable_notWhenOnChannelScreen() { + setPermissionRequestedInManifest() + initController(channel = makeTestChannel()) + + assertFalse(controller.isAvailable) + } + + @Test + fun testIsAvailable_notWhenOnGroupScreen() { + setPermissionRequestedInManifest() + initController(group = makeTestGroup()) + + assertFalse(controller.isAvailable) + } + + @Test + fun testIsAvailable_onAppScreenWhenRequested() { + setPermissionRequestedInManifest() + initController() + + assertTrue(controller.isAvailable) + } + + @Test + fun testIsEnabled_notWhenDisabledByAdmin() { + setPermissionRequestedInManifest() + initController(admin = makeTestAdmin()) + + controller.updateState(preference) + + assertFalse(preference.isEnabled) + } + + @Test + fun testIsEnabled_whenNotDisabledByAdmin() { + setPermissionRequestedInManifest() + initController() + + controller.updateState(preference) + + assertTrue(preference.isEnabled) + } + + @Test + fun testIsChecked_notWhenHardDenied() { + setPermissionRequestedInManifest() + setPermissionResult(PERMISSION_HARD_DENIED) + initController() + + controller.updateState(preference) + + assertFalse(preference.isChecked) + } + + @Test + fun testIsChecked_notWhenSoftDenied() { + setPermissionRequestedInManifest() + setPermissionResult(PERMISSION_SOFT_DENIED) + initController() + + controller.updateState(preference) + + assertFalse(preference.isChecked) + } + + @Test + fun testIsChecked_whenGranted() { + setPermissionRequestedInManifest() + setPermissionResult(PERMISSION_GRANTED) + initController() + + controller.updateState(preference) + + assertTrue(preference.isChecked) + } + + @Test + fun testOnPreferenceChange_whenHardDenied() { + setPermissionRequestedInManifest() + setPermissionResult(PERMISSION_HARD_DENIED) + initController() + controller.displayPreference(screen) + controller.updateState(preference) + assertFalse(preference.isChecked) + + setPreferenceChecked(true) + + verifySetAppOpMode(MODE_ALLOWED) + verifySetPermissionUserSetFlag() + } + + @Test + fun testOnPreferenceChange_whenSoftDenied() { + setPermissionRequestedInManifest() + setPermissionResult(PERMISSION_SOFT_DENIED) + initController() + controller.displayPreference(screen) + controller.updateState(preference) + assertFalse(preference.isChecked) + + setPreferenceChecked(true) + + verifySetAppOpMode(MODE_ALLOWED) + verifySetPermissionUserSetFlag() + } + + @Test + fun testOnPreferenceChange_whenGranted() { + setPermissionRequestedInManifest() + setPermissionResult(PERMISSION_GRANTED) + initController() + controller.displayPreference(screen) + controller.updateState(preference) + assertTrue(preference.isChecked) + + setPreferenceChecked(false) + + verifySetAppOpMode(MODE_ERRORED) + verifySetPermissionUserSetFlag() + } + + private fun setPermissionRequestedInManifest( + requested: Boolean = true + ) { + whenever(packageManager.getPackageInfo(TEST_PACKAGE, GET_PERMISSIONS)).thenReturn( + PackageInfo().apply { + packageName = TEST_PACKAGE + applicationInfo = ApplicationInfo().apply { packageName = TEST_PACKAGE } + requestedPermissions = if (requested) arrayOf(USE_FULL_SCREEN_INTENT) else arrayOf() + }) + } + + private fun setPermissionResult(@PermissionResult result: Int) { + ShadowPermissionChecker.setResult(TEST_PACKAGE, USE_FULL_SCREEN_INTENT, result) + } + + private fun setPreferenceChecked(checked: Boolean) { + preference.isChecked = checked + + /* This shouldn't be necessary, but for some reason it's not called automatically when + isChecked is changed. */ + controller.onPreferenceChange(preference, checked) + } + + private fun verifySetAppOpMode(@AppOpsManager.Mode expectedMode: Int) { + verify(appOpsManager).setUidMode(OP_USE_FULL_SCREEN_INTENT, TEST_UID, expectedMode) + } + + private fun verifySetPermissionUserSetFlag() { + verify(packageManager).updatePermissionFlags( + USE_FULL_SCREEN_INTENT, + TEST_PACKAGE, + FLAG_PERMISSION_USER_SET, + FLAG_PERMISSION_USER_SET, + makeTestUserHandle() + ) + } + + private fun initController( + channel: NotificationChannel? = null, + group: NotificationChannelGroup? = null, + admin: EnforcedAdmin? = null + ) { + controller.onResume( + makeTestAppRow(), + channel, + group, + /* conversationDrawable = */null, + /* conversationInfo = */null, + admin, + /* preferenceFilter = */null + ) + } + + private fun makeTestChannel() = + NotificationChannel("test_channel_id", "Test Channel Name", IMPORTANCE_UNSPECIFIED) + + private fun makeTestGroup() = NotificationChannelGroup("test_group_id", "Test Group Name") + + private fun makeTestAppRow() = AppRow().apply { pkg = TEST_PACKAGE; uid = TEST_UID } + + private fun makeTestUserHandle() = UserHandle.getUserHandleForUid(TEST_UID) + + private fun makeTestAdmin() = mock(EnforcedAdmin::class.java) + + private companion object { + const val TEST_PACKAGE = "test.package.name" + const val TEST_UID = 12345 + } +} \ No newline at end of file