blob: 7eb73883937a45fcb6d66514d8011154fb36df90 [file] [log] [blame]
/*
* Copyright (C) 2020 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 org.robolectric.shadows;
import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_AWARE;
import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_UNAWARE;
import static android.os.Build.VERSION_CODES.P;
import static android.os.Build.VERSION_CODES.Q;
import static android.os.Build.VERSION_CODES.R;
import static com.google.common.base.Preconditions.checkNotNull;
import android.Manifest;
import android.Manifest.permission;
import android.annotation.NonNull;
import android.annotation.RequiresPermission;
import android.annotation.SystemApi;
import android.app.AppOpsManager;
import android.app.AppOpsManager.Mode;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.CrossProfileApps;
import android.content.pm.ICrossProfileApps;
import android.content.pm.PackageManager;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Process;
import android.os.UserHandle;
import android.text.TextUtils;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.annotation.Resetter;
/** Robolectric implementation of {@link CrossProfileApps}. */
@Implements(value = CrossProfileApps.class, minSdk = P)
public class ShadowCrossProfileApps {
// BEGIN-INTERNAL
private final static String INTERACT_ACROSS_PROFILES_APPOP = AppOpsManager.permissionToOp(
Manifest.permission.INTERACT_ACROSS_PROFILES);
private static final Set<String> configurableInteractAcrossProfilePackages = new HashSet<>();
// END-INTERNAL
private final Set<UserHandle> targetUserProfiles = new LinkedHashSet<>();
private final List<StartedMainActivity> startedMainActivities = new ArrayList<>();
private final List<StartedActivity> startedActivities =
Collections.synchronizedList(new ArrayList<>());
private final Map<String, Integer> packageNameAppOpModes = new HashMap<>();
private Context context;
private PackageManager packageManager;
@Implementation
protected void __constructor__(Context context, ICrossProfileApps service) {
this.context = context;
this.packageManager = context.getPackageManager();
}
/**
* Returns a list of {@link UserHandle}s currently accessible. This list is populated from calls
* to {@link #addTargetUserProfile(UserHandle)}.
*/
@Implementation
protected List<UserHandle> getTargetUserProfiles() {
return ImmutableList.copyOf(targetUserProfiles);
}
/**
* Returns a {@link Drawable} that can be shown for profile switching, which is guaranteed to
* always be the same for a particular user and to be distinct between users.
*/
@Implementation
protected Drawable getProfileSwitchingIconDrawable(UserHandle userHandle) {
verifyCanAccessUser(userHandle);
return new ColorDrawable(userHandle.getIdentifier());
}
/**
* Returns a {@link CharSequence} that can be shown as a label for profile switching, which is
* guaranteed to always be the same for a particular user and to be distinct between users.
*/
@Implementation
protected CharSequence getProfileSwitchingLabel(UserHandle userHandle) {
verifyCanAccessUser(userHandle);
return "Switch to " + userHandle;
}
/**
* Simulates starting the main activity specified in the specified profile, performing the same
* security checks done by the real {@link CrossProfileApps}.
*
* <p>The most recent main activity started can be queried by {@link #peekNextStartedActivity()}
* ()}.
*/
@Implementation
protected void startMainActivity(ComponentName componentName, UserHandle targetUser) {
verifyCanAccessUser(targetUser);
verifyActivityInManifest(componentName, /* requireMainActivity= */ true);
startedMainActivities.add(new StartedMainActivity(componentName, targetUser));
startedActivities.add(new StartedActivity(componentName, targetUser));
}
/**
* Simulates starting the activity specified in the specified profile, performing the same
* security checks done by the real {@link CrossProfileApps}.
*
* <p>The most recent main activity started can be queried by {@link #peekNextStartedActivity()}
* ()}.
*/
@Implementation(minSdk = Q)
@SystemApi
@RequiresPermission(permission.INTERACT_ACROSS_PROFILES)
protected void startActivity(ComponentName componentName, UserHandle targetUser) {
verifyCanAccessUser(targetUser);
verifyActivityInManifest(componentName, /* requireMainActivity= */ false);
verifyHasInteractAcrossProfilesPermission();
startedActivities.add(new StartedActivity(componentName, targetUser));
}
/** Adds {@code userHandle} to the list of accessible handles. */
public void addTargetUserProfile(UserHandle userHandle) {
if (userHandle.equals(Process.myUserHandle())) {
throw new IllegalArgumentException("Cannot target current user");
}
targetUserProfiles.add(userHandle);
}
/** Removes {@code userHandle} from the list of accessible handles, if present. */
public void removeTargetUserProfile(UserHandle userHandle) {
if (userHandle.equals(Process.myUserHandle())) {
throw new IllegalArgumentException("Cannot target current user");
}
targetUserProfiles.remove(userHandle);
}
/** Clears the list of accessible handles. */
public void clearTargetUserProfiles() {
targetUserProfiles.clear();
}
/**
* Returns the most recent {@link ComponentName}, {@link UserHandle} pair started by {@link
* CrossProfileApps#startMainActivity(ComponentName, UserHandle)}, wrapped in {@link
* StartedMainActivity}.
*
* @deprecated Use {@link #peekNextStartedActivity()} instead.
*/
@Nullable
@Deprecated
public StartedMainActivity peekNextStartedMainActivity() {
if (startedMainActivities.isEmpty()) {
return null;
} else {
return Iterables.getLast(startedMainActivities);
}
}
/**
* Returns the most recent {@link ComponentName}, {@link UserHandle} pair started by {@link
* CrossProfileApps#startMainActivity(ComponentName, UserHandle)} or {@link
* CrossProfileApps#startActivity(ComponentName, UserHandle)}, wrapped in {@link StartedActivity}.
*/
@Nullable
public StartedActivity peekNextStartedActivity() {
if (startedActivities.isEmpty()) {
return null;
} else {
return Iterables.getLast(startedActivities);
}
}
/**
* Consumes the most recent {@link ComponentName}, {@link UserHandle} pair started by {@link
* CrossProfileApps#startMainActivity(ComponentName, UserHandle)} or {@link
* CrossProfileApps#startActivity(ComponentName, UserHandle)}, and returns it wrapped in {@link
* StartedActivity}.
*/
@Nullable
public StartedActivity getNextStartedActivity() {
if (startedActivities.isEmpty()) {
return null;
} else {
return startedActivities.remove(startedActivities.size() - 1);
}
}
/**
* Clears all records of {@link StartedActivity}s from calls to {@link
* CrossProfileApps#startActivity(ComponentName, UserHandle)} or {@link
* CrossProfileApps#startMainActivity(ComponentName, UserHandle)}.
*/
public void clearNextStartedActivities() {
startedActivities.clear();
}
private void verifyCanAccessUser(UserHandle userHandle) {
if (!targetUserProfiles.contains(userHandle)) {
throw new SecurityException(
"Not allowed to access "
+ userHandle
+ " (did you forget to call addTargetUserProfile?)");
}
}
private void verifyHasInteractAcrossProfilesPermission() {
if (context.checkSelfPermission(permission.INTERACT_ACROSS_PROFILES)
!= PackageManager.PERMISSION_GRANTED) {
throw new SecurityException(
"Attempt to launch activity without required "
+ permission.INTERACT_ACROSS_PROFILES
+ " permission");
}
}
/**
* Ensures that {@code component} is present in the manifest as an exported and enabled activity.
* This check and the error thrown are the same as the check done by the real {@link
* CrossProfileApps}.
*
* <p>If {@code requireMainActivity} is true, then this also asserts that the activity is a
* launcher activity.
*/
private void verifyActivityInManifest(ComponentName component, boolean requireMainActivity) {
Intent launchIntent = new Intent();
if (requireMainActivity) {
launchIntent
.setAction(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
.setPackage(component.getPackageName());
} else {
launchIntent.setComponent(component);
}
boolean existsMatchingActivity =
Iterables.any(
packageManager.queryIntentActivities(
launchIntent, MATCH_DIRECT_BOOT_AWARE | MATCH_DIRECT_BOOT_UNAWARE),
resolveInfo -> {
ActivityInfo activityInfo = resolveInfo.activityInfo;
return TextUtils.equals(activityInfo.packageName, component.getPackageName())
&& TextUtils.equals(activityInfo.name, component.getClassName())
&& activityInfo.exported;
});
if (!existsMatchingActivity) {
throw new SecurityException(
"Attempt to launch activity without "
+ " category Intent.CATEGORY_LAUNCHER or activity is not exported"
+ component);
}
}
// BEGIN-INTERNAL
@Implementation(minSdk = R)
@RequiresPermission(
allOf={android.Manifest.permission.MANAGE_APP_OPS_MODES,
android.Manifest.permission.INTERACT_ACROSS_USERS})
protected void setInteractAcrossProfilesAppOp(String packageName, @Mode int newMode) {
packageNameAppOpModes.put(packageName, newMode);
}
/**
* Returns the app-op mode associated with the given package name. If not set, returns {@code
* null}.
*/
@Nullable
public @Mode Integer getInteractAcrossProfilesAppOp(String packageName) {
return packageNameAppOpModes.get(packageName);
}
public void addCrossProfilePackage(String packageName){
configurableInteractAcrossProfilePackages.add(packageName);
}
@Implementation(minSdk = R)
protected void resetInteractAcrossProfilesAppOps(
@NonNull Collection<String> previousCrossProfilePackages,
@NonNull Set<String> newCrossProfilePackages) {
final List<String> unsetCrossProfilePackages =
previousCrossProfilePackages.stream()
.filter(packageName -> !newCrossProfilePackages.contains(packageName))
.collect(Collectors.toList());
for (String packageName : unsetCrossProfilePackages) {
if (!canConfigureInteractAcrossProfiles(packageName)) {
setInteractAcrossProfilesAppOp(packageName,
AppOpsManager.opToDefaultMode(INTERACT_ACROSS_PROFILES_APPOP));
}
}
}
// BEGIN-INTERNAL
@Implementation(minSdk = R)
protected void clearInteractAcrossProfilesAppOps() {
findAllPackageNames().forEach(
packageName -> setInteractAcrossProfilesAppOp(
packageName, AppOpsManager.opToDefaultMode(INTERACT_ACROSS_PROFILES_APPOP)));
}
private List<String> findAllPackageNames() {
return context.getPackageManager()
.getInstalledApplications(/* flags= */ 0)
.stream()
.map(applicationInfo -> applicationInfo.packageName)
.collect(Collectors.toList());
}
// END-INTERNAL
@Implementation
protected boolean canConfigureInteractAcrossProfiles(@NonNull String packageName) {
return configurableInteractAcrossProfilePackages.contains(packageName);
}
@Resetter
public static void reset() {
configurableInteractAcrossProfilePackages.clear();
}
// END-INTERNAL
/**
* Container object to hold parameters passed to {@link #startMainActivity(ComponentName,
* UserHandle)}.
*
* @deprecated Use {@link #peekNextStartedActivity()} and {@link StartedActivity} instead.
*/
@Deprecated
public static class StartedMainActivity {
private final ComponentName componentName;
private final UserHandle userHandle;
public StartedMainActivity(ComponentName componentName, UserHandle userHandle) {
this.componentName = checkNotNull(componentName);
this.userHandle = checkNotNull(userHandle);
}
public ComponentName getComponentName() {
return componentName;
}
public UserHandle getUserHandle() {
return userHandle;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
StartedMainActivity that = (StartedMainActivity) o;
return Objects.equals(componentName, that.componentName)
&& Objects.equals(userHandle, that.userHandle);
}
@Override
public int hashCode() {
return Objects.hash(componentName, userHandle);
}
}
/**
* Container object to hold parameters passed to {@link #startMainActivity(ComponentName,
* UserHandle)} or {@link #startActivity(ComponentName, UserHandle)}.
*/
public static final class StartedActivity {
private final ComponentName componentName;
private final UserHandle userHandle;
public StartedActivity(ComponentName componentName, UserHandle userHandle) {
this.componentName = checkNotNull(componentName);
this.userHandle = checkNotNull(userHandle);
}
public ComponentName getComponentName() {
return componentName;
}
public UserHandle getUserHandle() {
return userHandle;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
StartedActivity that = (StartedActivity) o;
return Objects.equals(componentName, that.componentName)
&& Objects.equals(userHandle, that.userHandle);
}
@Override
public int hashCode() {
return Objects.hash(componentName, userHandle);
}
}
}