diff options
5 files changed, 685 insertions, 141 deletions
diff --git a/media/java/android/media/flags/projection.aconfig b/media/java/android/media/flags/projection.aconfig index 17d1ff6a86a7..1bb9a8e1d6d3 100644 --- a/media/java/android/media/flags/projection.aconfig +++ b/media/java/android/media/flags/projection.aconfig @@ -18,3 +18,10 @@ flag { bug: "362720120" is_exported: true } + +flag { + namespace: "media_projection" + name: "stop_media_projection_on_call_end" + description: "Stops MediaProjection sessions when a call ends" + bug: "368336349" +}
\ No newline at end of file diff --git a/services/core/java/com/android/server/media/projection/MediaProjectionManagerService.java b/services/core/java/com/android/server/media/projection/MediaProjectionManagerService.java index 436acba6e492..c460465bfb11 100644 --- a/services/core/java/com/android/server/media/projection/MediaProjectionManagerService.java +++ b/services/core/java/com/android/server/media/projection/MediaProjectionManagerService.java @@ -17,7 +17,6 @@ package com.android.server.media.projection; import static android.Manifest.permission.MANAGE_MEDIA_PROJECTION; -import static android.Manifest.permission.RECORD_SENSITIVE_CONTENT; import static android.app.ActivityManagerInternal.MEDIA_PROJECTION_TOKEN_EVENT_CREATED; import static android.app.ActivityManagerInternal.MEDIA_PROJECTION_TOKEN_EVENT_DESTROYED; import static android.content.Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS; @@ -28,7 +27,6 @@ import static android.media.projection.ReviewGrantedConsentResult.RECORD_CANCEL; import static android.media.projection.ReviewGrantedConsentResult.RECORD_CONTENT_DISPLAY; import static android.media.projection.ReviewGrantedConsentResult.RECORD_CONTENT_TASK; import static android.media.projection.ReviewGrantedConsentResult.UNKNOWN; -import static android.provider.Settings.Global.DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS; import static android.view.Display.DEFAULT_DISPLAY; import static android.view.Display.INVALID_DISPLAY; @@ -41,10 +39,7 @@ import android.app.ActivityManagerInternal; import android.app.ActivityOptions.LaunchCookie; import android.app.AppOpsManager; import android.app.IProcessObserver; -import android.app.KeyguardManager; import android.app.compat.CompatChanges; -import android.app.role.RoleManager; -import android.companion.AssociationRequest; import android.compat.annotation.ChangeId; import android.compat.annotation.EnabledSince; import android.content.ComponentName; @@ -74,7 +69,6 @@ import android.os.PermissionEnforcer; import android.os.RemoteException; import android.os.SystemClock; import android.os.UserHandle; -import android.provider.Settings; import android.util.ArrayMap; import android.util.Slog; import android.view.ContentRecordingSession; @@ -85,7 +79,6 @@ import com.android.internal.annotations.VisibleForTesting; import com.android.internal.util.ArrayUtils; import com.android.internal.util.DumpUtils; import com.android.server.LocalServices; -import com.android.server.SystemConfig; import com.android.server.SystemService; import com.android.server.Watchdog; import com.android.server.wm.WindowManagerInternal; @@ -140,12 +133,12 @@ public final class MediaProjectionManagerService extends SystemService private final ActivityManagerInternal mActivityManagerInternal; private final PackageManager mPackageManager; private final WindowManagerInternal mWmInternal; - private final KeyguardManager mKeyguardManager; - private final RoleManager mRoleManager; + private final MediaRouter mMediaRouter; private final MediaRouterCallback mMediaRouterCallback; private final MediaProjectionMetricsLogger mMediaProjectionMetricsLogger; + private final MediaProjectionStopController mMediaProjectionStopController; private MediaRouter.RouteInfo mMediaRouteInfo; @GuardedBy("mLock") @@ -175,72 +168,16 @@ public final class MediaProjectionManagerService extends SystemService mMediaRouter = (MediaRouter) mContext.getSystemService(Context.MEDIA_ROUTER_SERVICE); mMediaRouterCallback = new MediaRouterCallback(); mMediaProjectionMetricsLogger = injector.mediaProjectionMetricsLogger(context); - mKeyguardManager = (KeyguardManager) mContext.getSystemService(Context.KEYGUARD_SERVICE); - mKeyguardManager.addKeyguardLockedStateListener( - mContext.getMainExecutor(), this::onKeyguardLockedStateChanged); - mRoleManager = mContext.getSystemService(RoleManager.class); + mMediaProjectionStopController = new MediaProjectionStopController(context, + this::maybeStopMediaProjection); Watchdog.getInstance().addMonitor(this); } - /** - * In order to record the keyguard, the MediaProjection package must be either: - * - a holder of RECORD_SENSITIVE_CONTENT permission, or - * - be one of the bugreport allowlisted packages, or - * - hold the OP_PROJECT_MEDIA AppOp. - */ - @SuppressWarnings("BooleanMethodIsAlwaysInverted") - private boolean canCaptureKeyguard() { - if (!android.companion.virtualdevice.flags.Flags.mediaProjectionKeyguardRestrictions()) { - return true; - } - synchronized (mLock) { - if (mProjectionGrant == null || mProjectionGrant.packageName == null) { - return false; - } - boolean disableScreenShareProtections = Settings.Global.getInt( - getContext().getContentResolver(), - DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS, 0) != 0; - if (disableScreenShareProtections) { - Slog.v(TAG, - "Allowing keyguard capture as screenshare protections are disabled."); - return true; - } - - if (mPackageManager.checkPermission(RECORD_SENSITIVE_CONTENT, - mProjectionGrant.packageName) - == PackageManager.PERMISSION_GRANTED) { - Slog.v(TAG, - "Allowing keyguard capture for package with RECORD_SENSITIVE_CONTENT " - + "permission"); - return true; - } - if (AppOpsManager.MODE_ALLOWED == mAppOps.noteOpNoThrow(AppOpsManager.OP_PROJECT_MEDIA, - mProjectionGrant.uid, mProjectionGrant.packageName, /* attributionTag= */ null, - "recording lockscreen")) { - // Some tools use media projection by granting the OP_PROJECT_MEDIA app - // op via a shell command. Those tools can be granted keyguard capture - Slog.v(TAG, - "Allowing keyguard capture for package with OP_PROJECT_MEDIA AppOp "); - return true; - } - if (isProjectionAppHoldingAppStreamingRoleLocked()) { - Slog.v(TAG, - "Allowing keyguard capture for package holding app streaming role."); - return true; - } - return SystemConfig.getInstance().getBugreportWhitelistedPackages() - .contains(mProjectionGrant.packageName); - } - } - - @VisibleForTesting - void onKeyguardLockedStateChanged(boolean isKeyguardLocked) { - if (!isKeyguardLocked) return; + private void maybeStopMediaProjection(int reason) { synchronized (mLock) { - if (mProjectionGrant != null && !canCaptureKeyguard() - && mProjectionGrant.mVirtualDisplayId != INVALID_DISPLAY) { - Slog.d(TAG, "Content Recording: Stopped MediaProjection" - + " due to keyguard lock"); + if (!mMediaProjectionStopController.isExemptFromStopping(mProjectionGrant)) { + Slog.d(TAG, "Content Recording: Stopping MediaProjection due to " + + MediaProjectionStopController.stopReasonToString(reason)); mProjectionGrant.stop(); } } @@ -310,6 +247,8 @@ public final class MediaProjectionManagerService extends SystemService } }); } + + mMediaProjectionStopController.startTrackingStopReasons(mContext); } @Override @@ -736,20 +675,6 @@ public final class MediaProjectionManagerService extends SystemService } } - /** - * Application holding the app streaming role - * ({@value AssociationRequest#DEVICE_PROFILE_APP_STREAMING}) are allowed to record the - * lockscreen. - * - * @return true if the is held by the recording application. - */ - @GuardedBy("mLock") - private boolean isProjectionAppHoldingAppStreamingRoleLocked() { - return mRoleManager.getRoleHoldersAsUser(AssociationRequest.DEVICE_PROFILE_APP_STREAMING, - mContext.getUser()) - .contains(mProjectionGrant.packageName); - } - private void dump(final PrintWriter pw) { pw.println("MEDIA PROJECTION MANAGER (dumpsys media_projection)"); synchronized (mLock) { @@ -957,18 +882,19 @@ public final class MediaProjectionManagerService extends SystemService public void requestConsentForInvalidProjection(@NonNull IMediaProjection projection) { requestConsentForInvalidProjection_enforcePermission(); - if (android.companion.virtualdevice.flags.Flags.mediaProjectionKeyguardRestrictions() - && mKeyguardManager.isKeyguardLocked()) { - Slog.v(TAG, "Reusing token: Won't request consent while the keyguard is locked"); - return; - } - synchronized (mLock) { if (!isCurrentProjection(projection)) { Slog.v(TAG, "Reusing token: Won't request consent again for a token that " + "isn't current"); return; } + + if (mMediaProjectionStopController.isStartForbidden(mProjectionGrant)) { + Slog.v(TAG, + "Reusing token: Won't request consent while MediaProjection is " + + "restricted"); + return; + } } // Remove calling app identity before performing any privileged operations. @@ -1076,7 +1002,6 @@ public final class MediaProjectionManagerService extends SystemService } } - @VisibleForTesting final class MediaProjection extends IMediaProjection.Stub { // Host app has 5 minutes to begin using the token before it is invalid. // Some apps show a dialog for the user to interact with (selecting recording resolution) @@ -1381,12 +1306,15 @@ public final class MediaProjectionManagerService extends SystemService @Override public void notifyVirtualDisplayCreated(int displayId) { notifyVirtualDisplayCreated_enforcePermission(); - if (mKeyguardManager.isKeyguardLocked() && !canCaptureKeyguard()) { - Slog.w(TAG, "Content Recording: Keyguard locked, aborting MediaProjection"); - stop(); - return; - } synchronized (mLock) { + if (mMediaProjectionStopController.isStartForbidden(mProjectionGrant)) { + Slog.w(TAG, + "Content Recording: MediaProjection start disallowed, aborting " + + "MediaProjection"); + stop(); + return; + } + mVirtualDisplayId = displayId; // If prior session was does not have a valid display id, then update the display diff --git a/services/core/java/com/android/server/media/projection/MediaProjectionStopController.java b/services/core/java/com/android/server/media/projection/MediaProjectionStopController.java new file mode 100644 index 000000000000..f5b26c41015e --- /dev/null +++ b/services/core/java/com/android/server/media/projection/MediaProjectionStopController.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2024 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.server.media.projection; + +import static android.Manifest.permission.RECORD_SENSITIVE_CONTENT; +import static android.provider.Settings.Global.DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS; + +import android.app.AppOpsManager; +import android.app.KeyguardManager; +import android.app.role.RoleManager; +import android.companion.AssociationRequest; +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Binder; +import android.provider.Settings; +import android.telecom.TelecomManager; +import android.telephony.TelephonyCallback; +import android.telephony.TelephonyManager; +import android.util.Slog; +import android.view.Display; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.SystemConfig; + +import java.util.function.Consumer; + +/** + * Tracks events that should cause MediaProjection to stop + */ +public class MediaProjectionStopController { + + private static final String TAG = "MediaProjectionStopController"; + @VisibleForTesting + static final int STOP_REASON_KEYGUARD = 1; + @VisibleForTesting + static final int STOP_REASON_CALL_END = 2; + + private final TelephonyCallback mTelephonyCallback = new ProjectionTelephonyCallback(); + private final Consumer<Integer> mStopReasonConsumer; + private final KeyguardManager mKeyguardManager; + private final TelecomManager mTelecomManager; + private final TelephonyManager mTelephonyManager; + private final AppOpsManager mAppOpsManager; + private final PackageManager mPackageManager; + private final RoleManager mRoleManager; + private final ContentResolver mContentResolver; + + private boolean mIsInCall; + + public MediaProjectionStopController(Context context, Consumer<Integer> stopReasonConsumer) { + mStopReasonConsumer = stopReasonConsumer; + mKeyguardManager = context.getSystemService(KeyguardManager.class); + mTelecomManager = context.getSystemService(TelecomManager.class); + mTelephonyManager = context.getSystemService(TelephonyManager.class); + mAppOpsManager = context.getSystemService(AppOpsManager.class); + mPackageManager = context.getPackageManager(); + mRoleManager = context.getSystemService(RoleManager.class); + mContentResolver = context.getContentResolver(); + } + + /** + * Start tracking stop reasons that may interrupt a MediaProjection session. + */ + public void startTrackingStopReasons(Context context) { + final long token = Binder.clearCallingIdentity(); + try { + mKeyguardManager.addKeyguardLockedStateListener(context.getMainExecutor(), + this::onKeyguardLockedStateChanged); + if (com.android.media.projection.flags.Flags.stopMediaProjectionOnCallEnd()) { + callStateChanged(); + mTelephonyManager.registerTelephonyCallback(context.getMainExecutor(), + mTelephonyCallback); + } + } finally { + Binder.restoreCallingIdentity(token); + } + } + + /** + * Checks whether the given projection grant is exempt from stopping restrictions. + */ + public boolean isExemptFromStopping( + MediaProjectionManagerService.MediaProjection projectionGrant) { + return isExempt(projectionGrant, false); + } + + /** + * Apps may disregard recording restrictions via MediaProjection for any stop reason if: + * - the "Disable Screenshare protections" developer option is enabled + * - the app is a holder of RECORD_SENSITIVE_CONTENT permission + * - the app holds the OP_PROJECT_MEDIA AppOp + * - the app holds the COMPANION_DEVICE_APP_STREAMING role + * - the app is one of the bugreport allowlisted packages + * - the current projection does not have an active VirtualDisplay associated with the + * MediaProjection session + */ + private boolean isExempt( + MediaProjectionManagerService.MediaProjection projectionGrant, boolean forStart) { + if (projectionGrant == null || projectionGrant.packageName == null) { + return true; + } + boolean disableScreenShareProtections = Settings.Global.getInt(mContentResolver, + DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS, 0) != 0; + if (disableScreenShareProtections) { + Slog.v(TAG, "Continuing MediaProjection as screenshare protections are disabled."); + return true; + } + + if (mPackageManager.checkPermission(RECORD_SENSITIVE_CONTENT, projectionGrant.packageName) + == PackageManager.PERMISSION_GRANTED) { + Slog.v(TAG, + "Continuing MediaProjection for package with RECORD_SENSITIVE_CONTENT " + + "permission"); + return true; + } + if (AppOpsManager.MODE_ALLOWED == mAppOpsManager.noteOpNoThrow( + AppOpsManager.OP_PROJECT_MEDIA, projectionGrant.uid, + projectionGrant.packageName, /* attributionTag= */ null, "recording lockscreen")) { + // Some tools use media projection by granting the OP_PROJECT_MEDIA app + // op via a shell command. + Slog.v(TAG, "Continuing MediaProjection for package with OP_PROJECT_MEDIA AppOp "); + return true; + } + if (mRoleManager.getRoleHoldersAsUser(AssociationRequest.DEVICE_PROFILE_APP_STREAMING, + projectionGrant.userHandle).contains(projectionGrant.packageName)) { + Slog.v(TAG, "Continuing MediaProjection for package holding app streaming role."); + return true; + } + if (SystemConfig.getInstance().getBugreportWhitelistedPackages().contains( + projectionGrant.packageName)) { + Slog.v(TAG, "Continuing MediaProjection for package allowlisted for bugreporting."); + return true; + } + if (!forStart && projectionGrant.getVirtualDisplayId() == Display.INVALID_DISPLAY) { + Slog.v(TAG, "Continuing MediaProjection as current projection has no VirtualDisplay."); + return true; + } + + return false; + } + + /** + * @return {@code true} if a MediaProjection session is currently in a restricted state. + */ + public boolean isStartForbidden( + MediaProjectionManagerService.MediaProjection projectionGrant) { + if (!android.companion.virtualdevice.flags.Flags.mediaProjectionKeyguardRestrictions()) { + return false; + } + + if (!mKeyguardManager.isKeyguardLocked()) { + return false; + } + + if (isExempt(projectionGrant, true)) { + return false; + } + return true; + } + + @VisibleForTesting + void onKeyguardLockedStateChanged(boolean isKeyguardLocked) { + if (!isKeyguardLocked) return; + if (!android.companion.virtualdevice.flags.Flags.mediaProjectionKeyguardRestrictions()) { + return; + } + mStopReasonConsumer.accept(STOP_REASON_KEYGUARD); + } + + @VisibleForTesting + void callStateChanged() { + if (!com.android.media.projection.flags.Flags.stopMediaProjectionOnCallEnd()) { + return; + } + boolean isInCall = mTelecomManager.isInCall(); + if (isInCall == mIsInCall) { + return; + } + if (mIsInCall && !isInCall) { + mStopReasonConsumer.accept(STOP_REASON_CALL_END); + } + mIsInCall = isInCall; + } + + /** + * @return a String representation of the stop reason interrupting MediaProjection. + */ + public static String stopReasonToString(int stopReason) { + switch (stopReason) { + case STOP_REASON_KEYGUARD -> { + return "STOP_REASON_KEYGUARD"; + } + case STOP_REASON_CALL_END -> { + return "STOP_REASON_CALL_END"; + } + } + return ""; + } + + private final class ProjectionTelephonyCallback extends TelephonyCallback implements + TelephonyCallback.CallStateListener { + @Override + public void onCallStateChanged(int state) { + callStateChanged(); + } + } +} diff --git a/services/tests/servicestests/src/com/android/server/media/projection/MediaProjectionManagerServiceTest.java b/services/tests/servicestests/src/com/android/server/media/projection/MediaProjectionManagerServiceTest.java index 73aec6375a03..510c2bcabad0 100644 --- a/services/tests/servicestests/src/com/android/server/media/projection/MediaProjectionManagerServiceTest.java +++ b/services/tests/servicestests/src/com/android/server/media/projection/MediaProjectionManagerServiceTest.java @@ -532,6 +532,8 @@ public class MediaProjectionManagerServiceTest { MediaProjectionManagerService.MediaProjection projection = startProjectionPreconditions(); projection.start(mIMediaProjectionCallback); + doReturn(PackageManager.PERMISSION_DENIED).when(mPackageManager).checkPermission( + RECORD_SENSITIVE_CONTENT, projection.packageName); doReturn(true).when(mKeyguardManager).isKeyguardLocked(); MediaProjectionManagerService.BinderService mediaProjectionBinderService = mService.new BinderService(mContext); @@ -540,50 +542,6 @@ public class MediaProjectionManagerServiceTest { verify(mContext, never()).startActivityAsUser(any(), any()); } - @EnableFlags(android.companion.virtualdevice.flags - .Flags.FLAG_MEDIA_PROJECTION_KEYGUARD_RESTRICTIONS) - @Test - public void testKeyguardLocked_stopsActiveProjection() throws Exception { - MediaProjectionManagerService service = - new MediaProjectionManagerService(mContext, mMediaProjectionMetricsLoggerInjector); - MediaProjectionManagerService.MediaProjection projection = - startProjectionPreconditions(service); - projection.start(mIMediaProjectionCallback); - projection.notifyVirtualDisplayCreated(10); - - assertThat(service.getActiveProjectionInfo()).isNotNull(); - - doReturn(PackageManager.PERMISSION_DENIED).when(mPackageManager) - .checkPermission(RECORD_SENSITIVE_CONTENT, projection.packageName); - service.onKeyguardLockedStateChanged(true); - - verify(mMediaProjectionMetricsLogger).logStopped(UID, TARGET_UID_UNKNOWN); - assertThat(service.getActiveProjectionInfo()).isNull(); - assertThat(mIMediaProjectionCallback.mLatch.await(5, TimeUnit.SECONDS)).isTrue(); - } - - @EnableFlags(android.companion.virtualdevice.flags - .Flags.FLAG_MEDIA_PROJECTION_KEYGUARD_RESTRICTIONS) - @Test - public void testKeyguardLocked_packageAllowlisted_doesNotStopActiveProjection() - throws NameNotFoundException { - MediaProjectionManagerService service = - new MediaProjectionManagerService(mContext, mMediaProjectionMetricsLoggerInjector); - MediaProjectionManagerService.MediaProjection projection = - startProjectionPreconditions(service); - projection.start(mIMediaProjectionCallback); - projection.notifyVirtualDisplayCreated(10); - - assertThat(service.getActiveProjectionInfo()).isNotNull(); - - doReturn(PackageManager.PERMISSION_GRANTED).when(mPackageManager).checkPermission( - RECORD_SENSITIVE_CONTENT, projection.packageName); - service.onKeyguardLockedStateChanged(true); - - verifyZeroInteractions(mMediaProjectionMetricsLogger); - assertThat(service.getActiveProjectionInfo()).isNotNull(); - } - @Test public void stop_noActiveProjections_doesNotLog() throws Exception { MediaProjectionManagerService service = diff --git a/services/tests/servicestests/src/com/android/server/media/projection/MediaProjectionStopControllerTest.java b/services/tests/servicestests/src/com/android/server/media/projection/MediaProjectionStopControllerTest.java new file mode 100644 index 000000000000..89d2d2847007 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/media/projection/MediaProjectionStopControllerTest.java @@ -0,0 +1,429 @@ +/* + * Copyright (C) 2024 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.server.media.projection; + + +import static android.Manifest.permission.RECORD_SENSITIVE_CONTENT; +import static android.provider.Settings.Global.DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS; +import static android.view.Display.INVALID_DISPLAY; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.ActivityManagerInternal; +import android.app.AppOpsManager; +import android.app.Instrumentation; +import android.app.KeyguardManager; +import android.app.role.RoleManager; +import android.companion.AssociationRequest; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.ApplicationInfoFlags; +import android.content.pm.PackageManager.NameNotFoundException; +import android.media.projection.MediaProjectionManager; +import android.os.UserHandle; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.annotations.Presubmit; +import android.platform.test.flag.junit.SetFlagsRule; +import android.provider.Settings; +import android.telecom.TelecomManager; +import android.testing.TestableContext; +import android.util.ArraySet; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.server.LocalServices; +import com.android.server.SystemConfig; +import com.android.server.wm.WindowManagerInternal; + +import org.junit.After; +import org.junit.Assert; +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; +import org.mockito.MockitoAnnotations; + +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Tests for the {@link MediaProjectionStopController} class. + * <p> + * Build/Install/Run: + * atest FrameworksServicesTests:MediaProjectionStopControllerTest + */ +@SmallTest +@Presubmit +@RunWith(AndroidJUnit4.class) +@SuppressLint({"UseCheckPermission", "VisibleForTests", "MissingPermission"}) +public class MediaProjectionStopControllerTest { + private static final int UID = 10; + private static final String PACKAGE_NAME = "test.package"; + private final ApplicationInfo mAppInfo = new ApplicationInfo(); + @Rule + public final TestableContext mContext = spy( + new TestableContext(InstrumentationRegistry.getInstrumentation().getContext())); + + private final MediaProjectionManagerService.Injector mMediaProjectionMetricsLoggerInjector = + new MediaProjectionManagerService.Injector() { + @Override + MediaProjectionMetricsLogger mediaProjectionMetricsLogger(Context context) { + return mMediaProjectionMetricsLogger; + } + }; + + private MediaProjectionManagerService mService; + + @Rule + public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + + @Mock + private ActivityManagerInternal mAmInternal; + @Mock + private PackageManager mPackageManager; + @Mock + private KeyguardManager mKeyguardManager; + @Mock + private TelecomManager mTelecomManager; + + private AppOpsManager mAppOpsManager; + @Mock + private MediaProjectionMetricsLogger mMediaProjectionMetricsLogger; + @Mock + private Consumer<Integer> mStopConsumer; + + private MediaProjectionStopController mStopController; + + @Before + public void setup() throws Exception { + MockitoAnnotations.initMocks(this); + + LocalServices.removeServiceForTest(ActivityManagerInternal.class); + LocalServices.addService(ActivityManagerInternal.class, mAmInternal); + + mAppOpsManager = mockAppOpsManager(); + mContext.addMockSystemService(AppOpsManager.class, mAppOpsManager); + mContext.addMockSystemService(KeyguardManager.class, mKeyguardManager); + mContext.addMockSystemService(TelecomManager.class, mTelecomManager); + mContext.setMockPackageManager(mPackageManager); + + mStopController = new MediaProjectionStopController(mContext, mStopConsumer); + mService = new MediaProjectionManagerService(mContext, + mMediaProjectionMetricsLoggerInjector); + + mAppInfo.targetSdkVersion = 35; + } + + private static AppOpsManager mockAppOpsManager() { + return mock(AppOpsManager.class, invocationOnMock -> { + if (invocationOnMock.getMethod().getName().startsWith("noteOp")) { + // Mockito will return 0 for non-stubbed method which corresponds to MODE_ALLOWED + // and is not what we want. + return AppOpsManager.MODE_IGNORED; + } + return Answers.RETURNS_DEFAULTS.answer(invocationOnMock); + }); + } + + @After + public void tearDown() { + LocalServices.removeServiceForTest(ActivityManagerInternal.class); + LocalServices.removeServiceForTest(WindowManagerInternal.class); + } + + @Test + @EnableFlags( + android.companion.virtualdevice.flags.Flags.FLAG_MEDIA_PROJECTION_KEYGUARD_RESTRICTIONS) + public void testMediaProjectionNotRestricted() throws Exception { + when(mKeyguardManager.isKeyguardLocked()).thenReturn(false); + + assertThat(mStopController.isStartForbidden( + createMediaProjection(PACKAGE_NAME))).isFalse(); + } + + @Test + @EnableFlags( + android.companion.virtualdevice.flags.Flags.FLAG_MEDIA_PROJECTION_KEYGUARD_RESTRICTIONS) + public void testMediaProjectionRestricted() throws Exception { + MediaProjectionManagerService.MediaProjection mediaProjection = createMediaProjection(); + mediaProjection.notifyVirtualDisplayCreated(1); + doReturn(PackageManager.PERMISSION_DENIED).when(mPackageManager).checkPermission( + RECORD_SENSITIVE_CONTENT, mediaProjection.packageName); + when(mKeyguardManager.isKeyguardLocked()).thenReturn(true); + + assertThat(mStopController.isStartForbidden(mediaProjection)).isTrue(); + } + + @Test + public void testExemptFromStoppingNullProjection() throws Exception { + assertThat(mStopController.isExemptFromStopping(null)).isTrue(); + } + + @Test + public void testExemptFromStoppingInvalidProjection() throws Exception { + assertThat(mStopController.isExemptFromStopping(createMediaProjection(null))).isTrue(); + } + + @Test + public void testExemptFromStoppingDisableScreenshareProtections() throws Exception { + MediaProjectionManagerService.MediaProjection mediaProjection = createMediaProjection(); + doReturn(PackageManager.PERMISSION_DENIED).when(mPackageManager).checkPermission( + RECORD_SENSITIVE_CONTENT, mediaProjection.packageName); + int value = Settings.Global.getInt(mContext.getContentResolver(), + DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS, 0); + try { + Settings.Global.putInt(mContext.getContentResolver(), + DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS, 1); + + assertThat(mStopController.isExemptFromStopping(mediaProjection)).isTrue(); + } finally { + Settings.Global.putInt(mContext.getContentResolver(), + DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS, value); + } + } + + @Test + public void testExemptFromStoppingHasOpProjectMedia() throws Exception { + MediaProjectionManagerService.MediaProjection mediaProjection = createMediaProjection(); + doReturn(PackageManager.PERMISSION_DENIED).when(mPackageManager).checkPermission( + RECORD_SENSITIVE_CONTENT, mediaProjection.packageName); + doReturn(AppOpsManager.MODE_ALLOWED).when(mAppOpsManager) + .noteOpNoThrow(eq(AppOpsManager.OP_PROJECT_MEDIA), + eq(mediaProjection.uid), eq(mediaProjection.packageName), + nullable(String.class), + nullable(String.class)); + assertThat(mStopController.isExemptFromStopping(mediaProjection)).isTrue(); + } + + @Test + public void testExemptFromStoppingHasAppStreamingRole() throws Exception { + runWithRole( + AssociationRequest.DEVICE_PROFILE_APP_STREAMING, + () -> { + try { + MediaProjectionManagerService.MediaProjection mediaProjection = + createMediaProjection(); + doReturn(PackageManager.PERMISSION_DENIED).when( + mPackageManager).checkPermission( + RECORD_SENSITIVE_CONTENT, mediaProjection.packageName); + assertThat(mStopController.isExemptFromStopping(mediaProjection)).isTrue(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + @Test + public void testExemptFromStoppingIsBugreportAllowlisted() throws Exception { + ArraySet<String> packages = SystemConfig.getInstance().getBugreportWhitelistedPackages(); + if (packages.isEmpty()) { + return; + } + MediaProjectionManagerService.MediaProjection mediaProjection = createMediaProjection( + packages.valueAt(0)); + doReturn(PackageManager.PERMISSION_DENIED).when(mPackageManager).checkPermission( + RECORD_SENSITIVE_CONTENT, mediaProjection.packageName); + assertThat(mStopController.isExemptFromStopping(mediaProjection)).isTrue(); + } + + @Test + public void testExemptFromStoppingHasNoDisplay() throws Exception { + MediaProjectionManagerService.MediaProjection mediaProjection = createMediaProjection( + PACKAGE_NAME); + doReturn(PackageManager.PERMISSION_DENIED).when(mPackageManager).checkPermission( + RECORD_SENSITIVE_CONTENT, mediaProjection.packageName); + assertThat(mStopController.isExemptFromStopping(mediaProjection)).isTrue(); + } + + @Test + public void testExemptFromStoppingHasRecordSensitiveContentPermission() throws Exception { + MediaProjectionManagerService.MediaProjection mediaProjection = createMediaProjection(); + doReturn(PackageManager.PERMISSION_GRANTED).when(mPackageManager).checkPermission( + RECORD_SENSITIVE_CONTENT, mediaProjection.packageName); + assertThat(mStopController.isExemptFromStopping(mediaProjection)).isTrue(); + } + + @Test + public void testExemptFromStoppingIsFalse() throws Exception { + MediaProjectionManagerService.MediaProjection mediaProjection = createMediaProjection(); + mediaProjection.notifyVirtualDisplayCreated(1); + doReturn(PackageManager.PERMISSION_DENIED).when(mPackageManager).checkPermission( + RECORD_SENSITIVE_CONTENT, mediaProjection.packageName); + assertThat(mStopController.isExemptFromStopping(mediaProjection)).isFalse(); + } + + @Test + @EnableFlags( + android.companion.virtualdevice.flags.Flags.FLAG_MEDIA_PROJECTION_KEYGUARD_RESTRICTIONS) + public void testKeyguardLockedStateChanged_unlocked() { + mStopController.onKeyguardLockedStateChanged(false); + + verify(mStopConsumer, never()).accept(anyInt()); + } + + @Test + @EnableFlags( + android.companion.virtualdevice.flags.Flags.FLAG_MEDIA_PROJECTION_KEYGUARD_RESTRICTIONS) + public void testKeyguardLockedStateChanged_locked() { + mStopController.onKeyguardLockedStateChanged(true); + + verify(mStopConsumer).accept(MediaProjectionStopController.STOP_REASON_KEYGUARD); + } + + @Test + @EnableFlags(com.android.media.projection.flags.Flags.FLAG_STOP_MEDIA_PROJECTION_ON_CALL_END) + public void testCallStateChanged_callStarts() { + // Setup call state to false + when(mTelecomManager.isInCall()).thenReturn(false); + mStopController.callStateChanged(); + + clearInvocations(mStopConsumer); + + when(mTelecomManager.isInCall()).thenReturn(true); + mStopController.callStateChanged(); + + verify(mStopConsumer, never()).accept(anyInt()); + } + + @Test + @EnableFlags(com.android.media.projection.flags.Flags.FLAG_STOP_MEDIA_PROJECTION_ON_CALL_END) + public void testCallStateChanged_remainsInCall() { + // Setup call state to false + when(mTelecomManager.isInCall()).thenReturn(true); + mStopController.callStateChanged(); + + clearInvocations(mStopConsumer); + + when(mTelecomManager.isInCall()).thenReturn(true); + mStopController.callStateChanged(); + + verify(mStopConsumer, never()).accept(anyInt()); + } + + @Test + @EnableFlags(com.android.media.projection.flags.Flags.FLAG_STOP_MEDIA_PROJECTION_ON_CALL_END) + public void testCallStateChanged_remainsNoCall() { + // Setup call state to false + when(mTelecomManager.isInCall()).thenReturn(false); + mStopController.callStateChanged(); + + clearInvocations(mStopConsumer); + + when(mTelecomManager.isInCall()).thenReturn(false); + mStopController.callStateChanged(); + + verify(mStopConsumer, never()).accept(anyInt()); + } + + @Test + @EnableFlags(com.android.media.projection.flags.Flags.FLAG_STOP_MEDIA_PROJECTION_ON_CALL_END) + public void testCallStateChanged_callEnds() { + // Setup call state to false + when(mTelecomManager.isInCall()).thenReturn(true); + mStopController.callStateChanged(); + + clearInvocations(mStopConsumer); + + when(mTelecomManager.isInCall()).thenReturn(false); + mStopController.callStateChanged(); + + verify(mStopConsumer).accept(MediaProjectionStopController.STOP_REASON_CALL_END); + } + + private MediaProjectionManagerService.MediaProjection createMediaProjection() + throws NameNotFoundException { + return createMediaProjection(PACKAGE_NAME); + } + + private MediaProjectionManagerService.MediaProjection createMediaProjection(String packageName) + throws NameNotFoundException { + doReturn(mAppInfo).when(mPackageManager).getApplicationInfoAsUser(anyString(), + any(ApplicationInfoFlags.class), any(UserHandle.class)); + doReturn(mAppInfo).when(mPackageManager).getApplicationInfoAsUser(Mockito.isNull(), + any(ApplicationInfoFlags.class), any(UserHandle.class)); + return mService.createProjectionInternal(UID, packageName, + MediaProjectionManager.TYPE_SCREEN_CAPTURE, false, mContext.getUser(), + INVALID_DISPLAY); + } + + /** + * Run the provided block giving the current context's package the provided role. + */ + @SuppressWarnings("SameParameterValue") + private void runWithRole(String role, Runnable block) { + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + String packageName = mContext.getPackageName(); + UserHandle user = instrumentation.getTargetContext().getUser(); + RoleManager roleManager = Objects.requireNonNull( + mContext.getSystemService(RoleManager.class)); + try { + CountDownLatch latch = new CountDownLatch(1); + instrumentation.getUiAutomation().adoptShellPermissionIdentity( + Manifest.permission.MANAGE_ROLE_HOLDERS, + Manifest.permission.BYPASS_ROLE_QUALIFICATION); + + roleManager.setBypassingRoleQualification(true); + roleManager.addRoleHolderAsUser(role, packageName, + /* flags= */ RoleManager.MANAGE_HOLDERS_FLAG_DONT_KILL_APP, user, + mContext.getMainExecutor(), success -> { + if (success) { + latch.countDown(); + } else { + Assert.fail("Couldn't set role for test (failure) " + role); + } + }); + assertWithMessage("Couldn't set role for test (timeout) : " + role) + .that(latch.await(1, TimeUnit.SECONDS)).isTrue(); + block.run(); + + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + roleManager.removeRoleHolderAsUser(role, packageName, + /* flags= */ RoleManager.MANAGE_HOLDERS_FLAG_DONT_KILL_APP, user, + mContext.getMainExecutor(), (aBool) -> { + }); + roleManager.setBypassingRoleQualification(false); + instrumentation.getUiAutomation() + .dropShellPermissionIdentity(); + } + } +} |