diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 66988147104..a68b2a77c0f 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -2564,6 +2564,20 @@ android:value="com.android.settings.applications.VrListenerSettings" /> + + + + + + + + + + + Reduce flicker + + Picture-in-picture + + + No installed apps support Picture-in-picture + + + pip picture in + + + Picture-in-picture + + + Permit entering picture-in-picture when leaving app + Do Not Disturb access diff --git a/res/xml/special_access.xml b/res/xml/special_access.xml index 7d85195796e..8bf5c5657d2 100644 --- a/res/xml/special_access.xml +++ b/res/xml/special_access.xml @@ -74,6 +74,11 @@ android:title="@string/manage_notification_access_title" android:fragment="com.android.settings.notification.NotificationAccessSettings" /> + IGNORE_PACKAGE_LIST = new ArrayList<>(); + static { + IGNORE_PACKAGE_LIST.add("com.android.systemui"); + } + + private Context mContext; + private PackageManager mPackageManager; + + /** + * @return true if the package has any activities that declare that they support + * picture-in-picture. + */ + static boolean checkPackageHasPictureInPictureActivities(String packageName, + ActivityInfo[] activities) { + ActivityInfoWrapper[] wrappedActivities = null; + if (activities != null) { + wrappedActivities = new ActivityInfoWrapper[activities.length]; + for (int i = 0; i < activities.length; i++) { + wrappedActivities[i] = new ActivityInfoWrapperImpl(activities[i]); + } + } + return checkPackageHasPictureInPictureActivities(packageName, wrappedActivities); + } + + /** + * @return true if the package has any activities that declare that they support + * picture-in-picture. + */ + @VisibleForTesting + static boolean checkPackageHasPictureInPictureActivities(String packageName, + ActivityInfoWrapper[] activities) { + // Skip if it's in the ignored list + if (IGNORE_PACKAGE_LIST.contains(packageName)) { + return false; + } + + // Iterate through all the activities and check if it is resizeable and supports + // picture-in-picture + if (activities != null) { + for (int i = activities.length - 1; i >= 0; i--) { + if (activities[i].getResizeMode() == RESIZE_MODE_RESIZEABLE_AND_PIPABLE) { + return true; + } + } + } + return false; + } + + /** + * Sets whether the app associated with the given {@param packageName} is allowed to enter + * picture-in-picture when it is hidden. + */ + static void setEnterPipOnHideStateForPackage(Context context, int uid, String packageName, + boolean value) { + final AppOpsManager appOps = context.getSystemService(AppOpsManager.class); + final int newMode = value ? MODE_ALLOWED : MODE_ERRORED; + appOps.setMode(OP_ENTER_PICTURE_IN_PICTURE_ON_HIDE, + uid, packageName, newMode); + } + + /** + * @return whether the app associated with the given {@param packageName} is allowed to enter + * picture-in-picture when it is hidden. + */ + static boolean getEnterPipOnHideStateForPackage(Context context, int uid, String packageName) { + final AppOpsManager appOps = context.getSystemService(AppOpsManager.class); + return appOps.checkOpNoThrow(OP_ENTER_PICTURE_IN_PICTURE_ON_HIDE, + uid, packageName) == MODE_ALLOWED; + } + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + mContext = getActivity(); + mPackageManager = mContext.getPackageManager(); + setPreferenceScreen(getPreferenceManager().createPreferenceScreen(mContext)); + } + + @Override + public void onResume() { + super.onResume(); + + // Clear the prefs + final PreferenceScreen screen = getPreferenceScreen(); + screen.removeAll(); + + // Fetch the set of applications which have at least one activity that declare that they + // support picture-in-picture + final ArrayMap packageToState = new ArrayMap<>(); + final ArrayList pipApps = new ArrayList<>(); + final List installedPackages = mPackageManager.getInstalledPackagesAsUser( + GET_ACTIVITIES, UserHandle.myUserId()); + for (PackageInfo packageInfo : installedPackages) { + if (checkPackageHasPictureInPictureActivities(packageInfo.packageName, + packageInfo.activities)) { + final String packageName = packageInfo.applicationInfo.packageName; + final boolean state = getEnterPipOnHideStateForPackage(mContext, + packageInfo.applicationInfo.uid, packageName); + pipApps.add(packageInfo.applicationInfo); + packageToState.put(packageName, state); + } + } + Collections.sort(pipApps, new PackageItemInfo.DisplayNameComparator(mPackageManager)); + + // Rebuild the list of prefs + final Context prefContext = getPrefContext(); + for (final ApplicationInfo appInfo : pipApps) { + final String packageName = appInfo.packageName; + final CharSequence label = appInfo.loadLabel(mPackageManager); + final SwitchPreference pref = new SwitchPreference(prefContext); + pref.setPersistent(false); + pref.setIcon(appInfo.loadIcon(mPackageManager)); + pref.setTitle(label); + pref.setChecked(packageToState.get(packageName)); + pref.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + logSpecialPermissionChange((Boolean) newValue, packageName); + setEnterPipOnHideStateForPackage(mContext, appInfo.uid, packageName, + (Boolean) newValue); + return true; + } + }); + screen.addPreference(pref); + } + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + setEmptyText(R.string.picture_in_picture_empty_text); + } + + @Override + public int getMetricsCategory() { + return MetricsEvent.SETTINGS_MANAGE_PICTURE_IN_PICTURE; + } + + @VisibleForTesting + void logSpecialPermissionChange(boolean newState, String packageName) { + int logCategory = newState + ? MetricsEvent.APP_PICTURE_IN_PICTURE_ON_HIDE_ALLOW + : MetricsEvent.APP_PICTURE_IN_PICTURE_ON_HIDE_DENY; + FeatureFactory.getFactory(getContext()) + .getMetricsFeatureProvider().action(getContext(), logCategory, packageName); + } +} diff --git a/src/com/android/settings/core/gateway/SettingsGateway.java b/src/com/android/settings/core/gateway/SettingsGateway.java index 6b08af8cf8f..76132efa601 100644 --- a/src/com/android/settings/core/gateway/SettingsGateway.java +++ b/src/com/android/settings/core/gateway/SettingsGateway.java @@ -57,6 +57,7 @@ import com.android.settings.applications.ManageApplications; import com.android.settings.applications.ManageAssist; import com.android.settings.applications.ManageDomainUrls; import com.android.settings.applications.NotificationApps; +import com.android.settings.applications.PictureInPictureSettings; import com.android.settings.applications.ProcessStatsSummary; import com.android.settings.applications.ProcessStatsUi; import com.android.settings.applications.UsageAccessDetails; @@ -225,6 +226,7 @@ public class SettingsGateway { AdvancedAppSettings.class.getName(), WallpaperTypeSettings.class.getName(), VrListenerSettings.class.getName(), + PictureInPictureSettings.class.getName(), ManagedProfileSettings.class.getName(), ChooseAccountActivity.class.getName(), IccLockSettings.class.getName(), diff --git a/tests/robotests/assets/grandfather_not_implementing_indexable b/tests/robotests/assets/grandfather_not_implementing_indexable index a17859655be..d7747792ba8 100644 --- a/tests/robotests/assets/grandfather_not_implementing_indexable +++ b/tests/robotests/assets/grandfather_not_implementing_indexable @@ -91,3 +91,4 @@ com.android.settings.applications.ConvertToFbe com.android.settings.localepicker.LocaleListEditor com.android.settings.qstile.DevelopmentTileConfigActivity$DevelopmentTileConfigFragment com.android.settings.applications.ExternalSourcesDetails +com.android.settings.applications.PictureInPictureSettings diff --git a/tests/robotests/src/com/android/settings/applications/PictureInPictureSettingsTest.java b/tests/robotests/src/com/android/settings/applications/PictureInPictureSettingsTest.java new file mode 100644 index 00000000000..daed00d2b59 --- /dev/null +++ b/tests/robotests/src/com/android/settings/applications/PictureInPictureSettingsTest.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2017 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.applications; + +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageInfo; + +import com.android.internal.logging.nano.MetricsProto; +import com.android.settings.SettingsRobolectricTestRunner; +import com.android.settings.TestConfig; +import com.android.settings.testutils.FakeFeatureFactory; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; + +import java.lang.reflect.Field; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.argThat; +import static org.mockito.Matchers.anyObject; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@RunWith(SettingsRobolectricTestRunner.class) +@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION) +public class PictureInPictureSettingsTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private Context mContext; + + private FakeFeatureFactory mFeatureFactory; + private PictureInPictureSettings mFragment; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + FakeFeatureFactory.setupForTest(mContext); + mFeatureFactory = (FakeFeatureFactory) FakeFeatureFactory.getFactory(mContext); + mFragment = new PictureInPictureSettings(); + } + + @Test + public void testIgnoredApp() { + for (String ignoredPackage : mFragment.IGNORE_PACKAGE_LIST) { + assertThat(checkPackageHasPictureInPictureActivities(ignoredPackage, true)) + .isFalse(); + } + } + + @Test + public void testNonPippableApp() { + assertThat(checkPackageHasPictureInPictureActivities("com.android.dummypackage")).isFalse(); + assertThat(checkPackageHasPictureInPictureActivities("com.android.dummypackage", + false, false, false)).isFalse(); + } + + @Test + public void testPippableApp() { + assertThat(checkPackageHasPictureInPictureActivities("com.android.dummypackage", + true)).isTrue(); + assertThat(checkPackageHasPictureInPictureActivities("com.android.dummypackage", + false, true)).isTrue(); + assertThat(checkPackageHasPictureInPictureActivities("com.android.dummypackage", + true, false)).isTrue(); + } + + @Test + public void logSpecialPermissionChange() { + mFragment.logSpecialPermissionChange(true, "app"); + verify(mFeatureFactory.metricsFeatureProvider).action(any(Context.class), + eq(MetricsProto.MetricsEvent.APP_PICTURE_IN_PICTURE_ON_HIDE_ALLOW), eq("app")); + + mFragment.logSpecialPermissionChange(false, "app"); + verify(mFeatureFactory.metricsFeatureProvider).action(any(Context.class), + eq(MetricsProto.MetricsEvent.APP_PICTURE_IN_PICTURE_ON_HIDE_DENY), eq("app")); + } + + private boolean checkPackageHasPictureInPictureActivities(String packageName, + boolean... resizeableActivityState) { + ActivityInfoWrapper[] activities = null; + if (resizeableActivityState.length > 0) { + activities = new ActivityInfoWrapper[resizeableActivityState.length]; + for (int i = 0; i < activities.length; i++) { + activities[i] = new MockActivityInfo(resizeableActivityState[i] + ? ActivityInfo.RESIZE_MODE_RESIZEABLE_AND_PIPABLE + : ActivityInfo.RESIZE_MODE_UNRESIZEABLE); + } + } + return PictureInPictureSettings.checkPackageHasPictureInPictureActivities(packageName, + activities); + } + + private class MockActivityInfo implements ActivityInfoWrapper { + + private int mResizeMode; + + public MockActivityInfo(int resizeMode) { + mResizeMode = resizeMode; + } + + @Override + public int getResizeMode() { + return mResizeMode; + } + } +}