Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 1398b3d0 authored by Naomi Musgrave's avatar Naomi Musgrave
Browse files

(2/N)[MediaProjection] Show dialog when token is reused

Validate if the IMediaProjection token (representing
the user's consent) is used to get more than one
MediaProjection instance, or if client app is trying to
invoke MediaProjection#createVirtualDisplay more than
once.

Re-show the permission dialog for target SDK below U.

Follow-on CLs will:
* Black out recording when waiting for consent

Bug: 274790702
Test: atest FrameworksServicesTests:MediaProjectionManagerServiceTest
Change-Id: I30c96d9a9afd69fba29314caeeb867f665bb83ad
parent 3d3fc71b
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -1495,6 +1495,12 @@
      "group": "WM_DEBUG_CONFIGURATION",
      "at": "com\/android\/server\/wm\/ActivityRecord.java"
    },
    "-741766551": {
      "message": "Content Recording: Ignoring session on invalid virtual display",
      "level": "VERBOSE",
      "group": "WM_DEBUG_CONTENT_RECORDING",
      "at": "com\/android\/server\/wm\/ContentRecordingController.java"
    },
    "-732715767": {
      "message": "Unable to retrieve window container to start recording for display %d",
      "level": "VERBOSE",
+51 −1
Original line number Diff line number Diff line
@@ -20,11 +20,23 @@ import android.media.projection.IMediaProjection;
import android.media.projection.IMediaProjectionCallback;
import android.media.projection.IMediaProjectionWatcherCallback;
import android.media.projection.MediaProjectionInfo;
import android.media.projection.ReviewGrantedConsentResult;
import android.os.IBinder;
import android.view.ContentRecordingSession;

/** {@hide} */
interface IMediaProjectionManager {
    /**
     * Intent extra indicating if user must review access to the consent token already granted.
     */
    const String EXTRA_USER_REVIEW_GRANTED_CONSENT = "extra_media_projection_user_consent_required";

    /**
     * Intent extra indicating the package attempting to re-use granted consent.
     */
    const String EXTRA_PACKAGE_REUSING_GRANTED_CONSENT =
            "extra_media_projection_package_reusing_consent";

    @UnsupportedAppUsage
    boolean hasProjectionPermission(int uid, String packageName);

@@ -36,6 +48,21 @@ interface IMediaProjectionManager {
    IMediaProjection createProjection(int uid, String packageName, int type,
            boolean permanentGrant);

    /**
     * Returns the current {@link IMediaProjection} instance associated with the given
     * package, or {@code null} if it is not possible to re-use the current projection.
     *
     * <p>Should only be invoked when the user has reviewed consent for a re-used projection token.
     * Requires that there is a prior session waiting for the user to review consent, and the given
     * package details match those on the current projection.
     *
     * @see {@link #isCurrentProjection}
     */
    @EnforcePermission("android.Manifest.permission.MANAGE_MEDIA_PROJECTION")
    @JavaPassthrough(annotation = "@android.annotation.RequiresPermission(android.Manifest"
            + ".permission.MANAGE_MEDIA_PROJECTION)")
    IMediaProjection getProjection(int uid, String packageName);

    /**
     * Returns {@code true} if the given {@link IMediaProjection} corresponds to the current
     * projection, or {@code false} otherwise.
@@ -58,7 +85,7 @@ interface IMediaProjectionManager {
     */
    @JavaPassthrough(annotation = "@android.annotation.RequiresPermission(android.Manifest"
            + ".permission.MANAGE_MEDIA_PROJECTION)")
    void requestConsentForInvalidProjection(IMediaProjection projection);
    void requestConsentForInvalidProjection(in IMediaProjection projection);

    @JavaPassthrough(annotation = "@android.annotation.RequiresPermission(android.Manifest"
            + ".permission.MANAGE_MEDIA_PROJECTION)")
@@ -94,9 +121,32 @@ interface IMediaProjectionManager {
     *
     * @param incomingSession the nullable incoming content recording session
     * @param projection      the non-null projection the session describes
     * @throws SecurityException If the provided projection is not current.
     */
  @JavaPassthrough(annotation = "@android.annotation.RequiresPermission(android.Manifest"
            + ".permission.MANAGE_MEDIA_PROJECTION)")
    boolean setContentRecordingSession(in ContentRecordingSession incomingSession,
            in IMediaProjection projection);

    /**
     * Sets the result of the user reviewing the recording permission, when the host app is re-using
     * the consent token.
     *
     * <p>Ignores the provided result if the given projection is not the current projection.
     *
     * <p>Based on the given result:
     * <ul>
     *   <li>If UNKNOWN or RECORD_CANCEL, then tear down the recording.</li>
     *   <li>If RECORD_CONTENT_DISPLAY, then record the default display.</li>
     *   <li>If RECORD_CONTENT_TASK, record the task indicated by
     *     {@link IMediaProjection#getLaunchCookie}.</li>
     * </ul>
     * @param projection The projection associated with the consent result. Must be the current
     * projection instance, unless the given result is RECORD_CANCEL.
     */
    @EnforcePermission("android.Manifest.permission.MANAGE_MEDIA_PROJECTION")
    @JavaPassthrough(annotation = "@android.annotation.RequiresPermission(android.Manifest"
            + ".permission.MANAGE_MEDIA_PROJECTION)")
    void setUserReviewGrantedConsentResult(ReviewGrantedConsentResult consentResult,
            in @nullable IMediaProjection projection);
}
+31 −0
Original line number Diff line number Diff line
/*
 * 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 android.media.projection;

/**
 * Indicates result of user interacting with consent dialog, when their review is required due to
 * app re-using the token.

 * @hide
 */
@Backing(type="int")
enum ReviewGrantedConsentResult {
    UNKNOWN = -1,
    RECORD_CANCEL = 0,
    RECORD_CONTENT_DISPLAY = 1,
    RECORD_CONTENT_TASK = 2,
}
+201 −12
Original line number Diff line number Diff line
@@ -19,9 +19,19 @@ package com.android.server.media.projection;
import static android.Manifest.permission.MANAGE_MEDIA_PROJECTION;
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;
import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
import static android.media.projection.IMediaProjectionManager.EXTRA_PACKAGE_REUSING_GRANTED_CONSENT;
import static android.media.projection.IMediaProjectionManager.EXTRA_USER_REVIEW_GRANTED_CONSENT;
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.view.Display.DEFAULT_DISPLAY;
import static android.view.Display.INVALID_DISPLAY;

import android.Manifest;
import android.annotation.EnforcePermission;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.ActivityManagerInternal;
@@ -30,7 +40,9 @@ import android.app.IProcessObserver;
import android.app.compat.CompatChanges;
import android.compat.annotation.ChangeId;
import android.compat.annotation.EnabledSince;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
@@ -45,11 +57,13 @@ import android.media.projection.IMediaProjectionManager;
import android.media.projection.IMediaProjectionWatcherCallback;
import android.media.projection.MediaProjectionInfo;
import android.media.projection.MediaProjectionManager;
import android.media.projection.ReviewGrantedConsentResult;
import android.os.Binder;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.PermissionEnforcer;
import android.os.RemoteException;
import android.os.SystemClock;
import android.os.UserHandle;
@@ -57,6 +71,7 @@ import android.util.ArrayMap;
import android.util.Slog;
import android.view.ContentRecordingSession;

import com.android.internal.R;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.ArrayUtils;
import com.android.internal.util.DumpUtils;
@@ -69,6 +84,7 @@ import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;

/**
 * Manages MediaProjection sessions.
@@ -161,10 +177,9 @@ public final class MediaProjectionManagerService extends SystemService
        }
    }


    @Override
    public void onStart() {
        publishBinderService(Context.MEDIA_PROJECTION_SERVICE, new BinderService(),
        publishBinderService(Context.MEDIA_PROJECTION_SERVICE, new BinderService(mContext),
                false /*allowIsolated*/);
        mMediaRouter.addCallback(MediaRouter.ROUTE_TYPE_REMOTE_DISPLAY, mMediaRouterCallback,
                MediaRouter.CALLBACK_FLAG_PASSIVE_DISCOVERY);
@@ -305,6 +320,10 @@ public final class MediaProjectionManagerService extends SystemService
                }
                return false;
            }
            if (mProjectionGrant != null) {
                // Cache the session details.
                mProjectionGrant.mSession = incomingSession;
            }
            return true;
        }
    }
@@ -323,9 +342,8 @@ public final class MediaProjectionManagerService extends SystemService
        }
    }


    /**
     * Reshows the permisison dialog for the user to review consent they've already granted in
     * Re-shows the permission dialog for the user to review consent they've already granted in
     * the given projection instance.
     *
     * <p>Preconditions:
@@ -337,18 +355,111 @@ public final class MediaProjectionManagerService extends SystemService
     * <p>Returns immediately but waits to start recording until user has reviewed their consent.
     */
    @VisibleForTesting
    void requestConsentForInvalidProjection(IMediaProjection projection) {
    void requestConsentForInvalidProjection() {
        synchronized (mLock) {
            Slog.v(TAG, "Reusing token: Reshow dialog for due to invalid projection.");
            // TODO(b/274790702): Trigger the permission dialog again in SysUI.
            // Trigger the permission dialog again in SysUI
            // Do not handle the result; SysUI will update us when the user has consented.
            mContext.startActivityAsUser(buildReviewGrantedConsentIntent(),
                    UserHandle.getUserHandleForUid(mProjectionGrant.uid));
        }
    }

    /**
     * Returns an intent to re-show the consent dialog in SysUI. Should only be used for the
     * scenario where the host app has re-used the consent token.
     *
     * <p>Consent dialog result handled in
     * {@link BinderService#setUserReviewGrantedConsentResult(int)}.
     */
    private Intent buildReviewGrantedConsentIntent() {
        final String permissionDialogString = mContext.getResources().getString(
                R.string.config_mediaProjectionPermissionDialogComponent);
        final ComponentName mediaProjectionPermissionDialogComponent =
                ComponentName.unflattenFromString(permissionDialogString);
        // We can use mProjectionGrant since we already checked that it matches the given token.
        return new Intent().setComponent(mediaProjectionPermissionDialogComponent)
                .putExtra(EXTRA_USER_REVIEW_GRANTED_CONSENT, true)
                .putExtra(EXTRA_PACKAGE_REUSING_GRANTED_CONSENT, mProjectionGrant.packageName)
                .setFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
    }

    /**
     * Handles result of dialog shown from {@link BinderService#buildReviewGrantedConsentIntent()}.
     *
     * <p>Tears down session if user did not consent, or starts mirroring if user did consent.
     */
    @VisibleForTesting
    void setUserReviewGrantedConsentResult(@ReviewGrantedConsentResult int consentResult,
            @Nullable IMediaProjection projection) {
        synchronized (mLock) {
            final boolean consentGranted =
                    consentResult == RECORD_CONTENT_DISPLAY || consentResult == RECORD_CONTENT_TASK;
            if (consentGranted && projection == null || !isCurrentProjection(
                    projection.asBinder())) {
                Slog.v(TAG, "Reusing token: Ignore consent result of " + consentResult + " for a "
                        + "token that isn't current");
                return;
            }
            if (mProjectionGrant == null) {
                Slog.w(TAG, "Reusing token: Can't review consent with no ongoing projection.");
                return;
            }
            if (mProjectionGrant.mSession == null
                    || !mProjectionGrant.mSession.isWaitingToRecord()) {
                Slog.w(TAG, "Reusing token: Ignore consent result " + consentResult
                        + " if not waiting for the result.");
                return;
            }
            Slog.v(TAG, "Reusing token: Handling user consent result " + consentResult);
            switch (consentResult) {
                case UNKNOWN:
                case RECORD_CANCEL:
                    // Pass in null to stop mirroring.
                    setReviewedConsentSessionLocked(/* session= */ null);
                    // The grant may now be null if setting the session failed.
                    if (mProjectionGrant != null) {
                        // Always stop the projection.
                        mProjectionGrant.stop();
                    }
                    break;
                case RECORD_CONTENT_DISPLAY:
                    // TODO(270118861) The app may have specified a particular id in the virtual
                    //  display config. However - below will always return INVALID since it checks
                    //  that window manager mirroring is not enabled (it is always enabled for MP).
                    setReviewedConsentSessionLocked(ContentRecordingSession.createDisplaySession(
                            DEFAULT_DISPLAY));
                    break;
                case RECORD_CONTENT_TASK:
                    setReviewedConsentSessionLocked(ContentRecordingSession.createTaskSession(
                            mProjectionGrant.getLaunchCookie()));
                    break;
            }
        }
    }

    /**
     * Updates the session after the user has reviewed consent. There must be a current session.
     *
     * @param session The new session details, or {@code null} to stop recording.
     */
    private void setReviewedConsentSessionLocked(@Nullable ContentRecordingSession session) {
        if (session != null) {
            session.setWaitingToRecord(false);
            session.setVirtualDisplayId(mProjectionGrant.mVirtualDisplayId);
        }

        Slog.v(TAG, "Reusing token: Processed consent so set the session " + session);
        if (!setContentRecordingSession(session)) {
            Slog.e(TAG, "Reusing token: Failed to set session for reused consent, so stop");
            // Do not need to invoke stop; updating the session does it for us.
        }
    }

    // TODO(b/261563516): Remove internal method and test aidl directly, here and elsewhere.
    @VisibleForTesting
    MediaProjection createProjectionInternal(int uid, String packageName, int type,
            boolean isPermanentGrant, UserHandle callingUser,
            boolean packageAttemptedReusingGrantedConsent) {
            boolean isPermanentGrant, UserHandle callingUser) {
        MediaProjection projection;
        ApplicationInfo ai;
        try {
@@ -371,6 +482,34 @@ public final class MediaProjectionManagerService extends SystemService
        return projection;
    }

    // TODO(b/261563516): Remove internal method and test aidl directly, here and elsewhere.
    @VisibleForTesting
    MediaProjection getProjectionInternal(int uid, String packageName) {
        final long callingToken = Binder.clearCallingIdentity();
        try {
            // Supposedly the package has re-used the user's consent; confirm the provided details
            // against the current projection token before re-using the current projection.
            if (mProjectionGrant == null || mProjectionGrant.mSession == null
                    || !mProjectionGrant.mSession.isWaitingToRecord()) {
                Slog.e(TAG, "Reusing token: Not possible to reuse the current projection "
                        + "instance");
                return null;
            }
                // The package matches, go ahead and re-use the token for this request.
            if (mProjectionGrant.uid == uid
                    && Objects.equals(mProjectionGrant.packageName, packageName)) {
                Slog.v(TAG, "Reusing token: getProjection can reuse the current projection");
                return mProjectionGrant;
            } else {
                Slog.e(TAG, "Reusing token: Not possible to reuse the current projection "
                        + "instance due to package details mismatching");
                return null;
            }
        } finally {
            Binder.restoreCallingIdentity(callingToken);
        }
    }

    @VisibleForTesting
    MediaProjectionInfo getActiveProjectionInfo() {
        synchronized (mLock) {
@@ -395,6 +534,10 @@ public final class MediaProjectionManagerService extends SystemService

    private final class BinderService extends IMediaProjectionManager.Stub {

        BinderService(Context context) {
            super(PermissionEnforcer.fromContext(context));
        }

        @Override // Binder call
        public boolean hasProjectionPermission(int uid, String packageName) {
            final long token = Binder.clearCallingIdentity();
@@ -424,7 +567,25 @@ public final class MediaProjectionManagerService extends SystemService
            }
            final UserHandle callingUser = Binder.getCallingUserHandle();
            return createProjectionInternal(uid, packageName, type, isPermanentGrant,
                    callingUser, false);
                    callingUser);
        }

        @Override // Binder call
        @EnforcePermission(MANAGE_MEDIA_PROJECTION)
        public IMediaProjection getProjection(int uid, String packageName) {
            getProjection_enforcePermission();
            if (packageName == null || packageName.isEmpty()) {
                throw new IllegalArgumentException("package name must not be empty");
            }

            MediaProjection projection;
            final long callingToken = Binder.clearCallingIdentity();
            try {
                projection = getProjectionInternal(uid, packageName);
            } finally {
                Binder.restoreCallingIdentity(callingToken);
            }
            return projection;
        }

        @Override // Binder call
@@ -562,7 +723,7 @@ public final class MediaProjectionManagerService extends SystemService
        }

        @Override
        public void requestConsentForInvalidProjection(IMediaProjection projection) {
        public void requestConsentForInvalidProjection(@NonNull IMediaProjection projection) {
            if (mContext.checkCallingOrSelfPermission(Manifest.permission.MANAGE_MEDIA_PROJECTION)
                    != PackageManager.PERMISSION_GRANTED) {
                throw new SecurityException("Requires MANAGE_MEDIA_PROJECTION to check if the given"
@@ -577,7 +738,22 @@ public final class MediaProjectionManagerService extends SystemService
            // Remove calling app identity before performing any privileged operations.
            final long token = Binder.clearCallingIdentity();
            try {
                MediaProjectionManagerService.this.requestConsentForInvalidProjection(projection);
                MediaProjectionManagerService.this.requestConsentForInvalidProjection();
            } finally {
                Binder.restoreCallingIdentity(token);
            }
        }

        @Override // Binder call
        @EnforcePermission(MANAGE_MEDIA_PROJECTION)
        public void setUserReviewGrantedConsentResult(@ReviewGrantedConsentResult int consentResult,
                @Nullable IMediaProjection projection) {
            setUserReviewGrantedConsentResult_enforcePermission();
            // Remove calling app identity before performing any privileged operations.
            final long token = Binder.clearCallingIdentity();
            try {
                MediaProjectionManagerService.this.setUserReviewGrantedConsentResult(consentResult,
                        projection);
            } finally {
                Binder.restoreCallingIdentity(token);
            }
@@ -594,7 +770,6 @@ public final class MediaProjectionManagerService extends SystemService
            }
        }


        private boolean checkPermission(String packageName, String permission) {
            return mContext.getPackageManager().checkPermission(permission, packageName)
                    == PackageManager.PERMISSION_GRANTED;
@@ -630,6 +805,8 @@ public final class MediaProjectionManagerService extends SystemService
        // Set if MediaProjection#createVirtualDisplay has been invoked previously (it
        // should only be called once).
        private int mVirtualDisplayId = INVALID_DISPLAY;
        // The associated session details already sent to WindowManager.
        private ContentRecordingSession mSession;

        MediaProjection(int type, int uid, String packageName, int targetSdkVersion,
                boolean isPrivileged) {
@@ -883,6 +1060,18 @@ public final class MediaProjectionManagerService extends SystemService
            }
            synchronized (mLock) {
                mVirtualDisplayId = displayId;

                // If prior session was does not have a valid display id, then update the display
                // so recording can start.
                if (mSession != null && mSession.getVirtualDisplayId() == INVALID_DISPLAY) {
                    Slog.v(TAG, "Virtual display now created, so update session with the virtual "
                            + "display id");
                    mSession.setVirtualDisplayId(mVirtualDisplayId);
                    if (!setContentRecordingSession(mSession)) {
                        Slog.e(TAG, "Failed to set session for virtual display id");
                        // Do not need to invoke stop; updating the session does it for us.
                    }
                }
            }
        }

+2 −2
Original line number Diff line number Diff line
@@ -80,7 +80,7 @@ final class ContentRecordingController {
        }
        // Invalid scenario: ignore identical incoming session.
        if (ContentRecordingSession.isProjectionOnSameDisplay(mSession, incomingSession)) {
            // TODO(242833866) if incoming session is no longer waiting to record, allow
            // TODO(242833866): if incoming session is no longer waiting to record, allow
            //  the update through.

            ProtoLog.v(WM_DEBUG_CONTENT_RECORDING,
@@ -99,7 +99,7 @@ final class ContentRecordingController {
            incomingDisplayContent = wmService.mRoot.getDisplayContentOrCreate(
                    incomingSession.getVirtualDisplayId());
            incomingDisplayContent.setContentRecordingSession(incomingSession);
            // TODO(b/270118861) When user grants consent to re-use, explicitly ask ContentRecorder
            // TODO(b/270118861): When user grants consent to re-use, explicitly ask ContentRecorder
            //  to update, since no config/display change arrives. Mark recording as black.
        }
        // Takeover and stopping scenario: stop recording on the pre-existing session.
Loading