Work profile first run updates

- Show until the user dismisses it.
- Fetch the app icon based upon config ComponentName.
- Fetch the app name based upon config.
- Extract relevant logic to WorkProfileMessageController
- Some no-op cleanups in SaveIMageInBackgroundTask to prevent sysui
  studio from complaining.

Flag: The calls into the new message controller are protected by
      SCREENSHOT_WORK_PROFILE_POLICY (in teamfood).
Bug: 254245929
Test: atest WorkProfileMessageControllerTest
Test: Manually testing dismissal and UI.
Change-Id: I943303992fdc2caeba76a06f88c1b91419d02be8
This commit is contained in:
Matt Casey
2022-12-28 20:21:55 +00:00
parent de15767636
commit af2116862d
7 changed files with 305 additions and 18 deletions

View File

@@ -437,6 +437,11 @@
This name is in the ComponentName flattened format (package/class) -->
<string name="config_screenshotEditor" translatable="false"></string>
<!-- ComponentName for the file browsing app that the system would expect to be used in work
profile. The icon for this app will be shown to the user when informing them that a
screenshot has been saved to work profile. If blank, a default icon will be shown. -->
<string name="config_sceenshotWorkProfileFilesApp" translatable="false"></string>
<!-- Remote copy default activity. Must handle REMOTE_COPY_ACTION intents.
This name is in the ComponentName flattened format (package/class) -->
<string name="config_remoteCopyPackage" translatable="false"></string>

View File

@@ -240,7 +240,9 @@
<!-- Content description for the right boundary of the screenshot being cropped, with the current position as a percentage. [CHAR LIMIT=NONE] -->
<string name="screenshot_right_boundary_pct">Right boundary <xliff:g id="percent" example="50">%1$d</xliff:g> percent</string>
<!-- Notification displayed when a screenshot is saved in a work profile. [CHAR LIMIT=NONE] -->
<string name="screenshot_work_profile_notification" translatable="false">Work screenshots are saved in the work <xliff:g id="app" example="Files">%1$s</xliff:g> app</string>
<string name="screenshot_work_profile_notification">Work screenshots are saved in the <xliff:g id="app" example="Work Files">%1$s</xliff:g> app</string>
<!-- Default name referring to the app on the device that lets the user browse stored files. [CHAR LIMIT=NONE] -->
<string name="screenshot_default_files_app_name">Files</string>
<!-- Notification title displayed for screen recording [CHAR LIMIT=50]-->
<string name="screenrecord_name">Screen Recorder</string>

View File

@@ -20,8 +20,7 @@ import static com.android.systemui.screenshot.LogConfig.DEBUG_ACTIONS;
import static com.android.systemui.screenshot.LogConfig.DEBUG_CALLBACK;
import static com.android.systemui.screenshot.LogConfig.DEBUG_STORAGE;
import static com.android.systemui.screenshot.LogConfig.logTag;
import static com.android.systemui.screenshot.ScreenshotNotificationSmartActionsProvider.ScreenshotSmartActionType.QUICK_SHARE_ACTION;
import static com.android.systemui.screenshot.ScreenshotNotificationSmartActionsProvider.ScreenshotSmartActionType.REGULAR_SMART_ACTIONS;
import static com.android.systemui.screenshot.ScreenshotNotificationSmartActionsProvider.ScreenshotSmartActionType;
import android.app.ActivityTaskManager;
import android.app.Notification;
@@ -155,7 +154,8 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
CompletableFuture<List<Notification.Action>> smartActionsFuture =
mScreenshotSmartActions.getSmartActionsFuture(
mScreenshotId, uri, image, mSmartActionsProvider, REGULAR_SMART_ACTIONS,
mScreenshotId, uri, image, mSmartActionsProvider,
ScreenshotSmartActionType.REGULAR_SMART_ACTIONS,
smartActionsEnabled, user);
List<Notification.Action> smartActions = new ArrayList<>();
if (smartActionsEnabled) {
@@ -166,7 +166,8 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
smartActions.addAll(buildSmartActions(
mScreenshotSmartActions.getSmartActions(
mScreenshotId, smartActionsFuture, timeoutMs,
mSmartActionsProvider, REGULAR_SMART_ACTIONS),
mSmartActionsProvider,
ScreenshotSmartActionType.REGULAR_SMART_ACTIONS),
mContext));
}
@@ -476,7 +477,7 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
CompletableFuture<List<Notification.Action>> quickShareActionsFuture =
mScreenshotSmartActions.getSmartActionsFuture(
mScreenshotId, null, image, mSmartActionsProvider,
QUICK_SHARE_ACTION,
ScreenshotSmartActionType.QUICK_SHARE_ACTION,
true /* smartActionsEnabled */, user);
int timeoutMs = DeviceConfig.getInt(
DeviceConfig.NAMESPACE_SYSTEMUI,
@@ -485,7 +486,8 @@ class SaveImageInBackgroundTask extends AsyncTask<Void, Void, Void> {
List<Notification.Action> quickShareActions =
mScreenshotSmartActions.getSmartActions(
mScreenshotId, quickShareActionsFuture, timeoutMs,
mSmartActionsProvider, QUICK_SHARE_ACTION);
mSmartActionsProvider,
ScreenshotSmartActionType.QUICK_SHARE_ACTION);
if (!quickShareActions.isEmpty()) {
mQuickShareData.quickShareAction = quickShareActions.get(0);
mParams.mQuickShareActionsReadyListener.onActionsReady(mQuickShareData);

View File

@@ -280,6 +280,7 @@ public class ScreenshotController {
private final TimeoutHandler mScreenshotHandler;
private final ActionIntentExecutor mActionExecutor;
private final UserManager mUserManager;
private final WorkProfileMessageController mWorkProfileMessageController;
private final OnBackInvokedCallback mOnBackInvokedCallback = () -> {
if (DEBUG_INPUT) {
@@ -326,7 +327,8 @@ public class ScreenshotController {
BroadcastSender broadcastSender,
ScreenshotNotificationSmartActionsProvider screenshotNotificationSmartActionsProvider,
ActionIntentExecutor actionExecutor,
UserManager userManager
UserManager userManager,
WorkProfileMessageController workProfileMessageController
) {
mScreenshotSmartActions = screenshotSmartActions;
mNotificationsController = screenshotNotificationsController;
@@ -358,6 +360,7 @@ public class ScreenshotController {
mFlags = flags;
mActionExecutor = actionExecutor;
mUserManager = userManager;
mWorkProfileMessageController = workProfileMessageController;
mAccessibilityManager = AccessibilityManager.getInstance(mContext);
@@ -679,7 +682,6 @@ public class ScreenshotController {
return true;
}
});
if (mFlags.isEnabled(SCREENSHOT_WORK_PROFILE_POLICY)) {
mScreenshotView.badgeScreenshot(
mContext.getPackageManager().getUserBadgeForDensity(owner, 0));
@@ -780,9 +782,9 @@ public class ScreenshotController {
mLongScreenshotHolder.setLongScreenshot(longScreenshot);
mLongScreenshotHolder.setTransitionDestinationCallback(
(transitionDestination, onTransitionEnd) -> {
mScreenshotView.startLongScreenshotTransition(
transitionDestination, onTransitionEnd,
longScreenshot);
mScreenshotView.startLongScreenshotTransition(
transitionDestination, onTransitionEnd,
longScreenshot);
// TODO: Do this via ActionIntentExecutor instead.
mContext.sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS));
}
@@ -1033,10 +1035,8 @@ public class ScreenshotController {
private void doPostAnimation(ScreenshotController.SavedImageData imageData) {
mScreenshotView.setChipIntents(imageData);
if (mFlags.isEnabled(SCREENSHOT_WORK_PROFILE_POLICY)
&& mUserManager.isManagedProfile(imageData.owner.getIdentifier())) {
// TODO: Read app from configuration
mScreenshotView.showWorkProfileMessage("Files");
if (mFlags.isEnabled(SCREENSHOT_WORK_PROFILE_POLICY)) {
mWorkProfileMessageController.onScreenshotTaken(imageData.owner, mScreenshotView);
}
}

View File

@@ -33,6 +33,7 @@ import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ValueAnimator;
import android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.Notification;
import android.app.PendingIntent;
@@ -100,7 +101,8 @@ import java.util.ArrayList;
* Handles the visual elements and animations for the screenshot flow.
*/
public class ScreenshotView extends FrameLayout implements
ViewTreeObserver.OnComputeInternalInsetsListener {
ViewTreeObserver.OnComputeInternalInsetsListener,
WorkProfileMessageController.WorkProfileMessageDisplay {
interface ScreenshotViewCallback {
void onUserInteraction();
@@ -351,13 +353,23 @@ public class ScreenshotView extends FrameLayout implements
* been taken and which app can be used to view it.
*
* @param appName The name of the app to use to view screenshots
* @param appIcon Optional icon for the relevant files app
* @param onDismiss Runnable to be run when the user dismisses this message
*/
void showWorkProfileMessage(String appName) {
@Override
public void showWorkProfileMessage(CharSequence appName, @Nullable Drawable appIcon,
Runnable onDismiss) {
if (appIcon != null) {
// Replace the default icon if one is provided.
ImageView imageView = mMessageContainer.findViewById(R.id.screenshot_message_icon);
imageView.setImageDrawable(appIcon);
}
mMessageContent.setText(
mContext.getString(R.string.screenshot_work_profile_notification, appName));
mMessageContainer.setVisibility(VISIBLE);
mMessageContainer.findViewById(R.id.message_dismiss_button).setOnClickListener((v) -> {
mMessageContainer.setVisibility(View.GONE);
onDismiss.run();
});
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (C) 2022 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.systemui.screenshot
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.os.UserHandle
import android.os.UserManager
import android.util.Log
import com.android.systemui.R
import javax.inject.Inject
/**
* Handles all the non-UI portions of the work profile first run:
* - Track whether the user has already dismissed it.
* - Load the proper icon and app name.
*/
class WorkProfileMessageController
@Inject
constructor(
private val context: Context,
private val userManager: UserManager,
private val packageManager: PackageManager,
) {
/**
* Determine if a message should be shown to the user, send message details to messageDisplay if
* appropriate.
*/
fun onScreenshotTaken(userHandle: UserHandle, messageDisplay: WorkProfileMessageDisplay) {
if (userManager.isManagedProfile(userHandle.identifier) && !messageAlreadyDismissed()) {
var badgedIcon: Drawable? = null
var label: CharSequence? = null
val fileManager = fileManagerComponentName()
try {
val info =
packageManager.getActivityInfo(
fileManager,
PackageManager.ComponentInfoFlags.of(0)
)
val icon = packageManager.getActivityIcon(fileManager)
badgedIcon = packageManager.getUserBadgedIcon(icon, userHandle)
label = info.loadLabel(packageManager)
} catch (e: PackageManager.NameNotFoundException) {
Log.w(TAG, "Component $fileManager not found")
}
// If label wasn't loaded, use a default
val badgedLabel =
packageManager.getUserBadgedLabel(label ?: defaultFileAppName(), userHandle)
messageDisplay.showWorkProfileMessage(badgedLabel, badgedIcon) { onMessageDismissed() }
}
}
private fun messageAlreadyDismissed(): Boolean {
return sharedPreference().getBoolean(PREFERENCE_KEY, false)
}
private fun onMessageDismissed() {
val editor = sharedPreference().edit()
editor.putBoolean(PREFERENCE_KEY, true)
editor.apply()
}
private fun sharedPreference() =
context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)
private fun fileManagerComponentName() =
ComponentName.unflattenFromString(
context.getString(R.string.config_sceenshotWorkProfileFilesApp)
)
private fun defaultFileAppName() = context.getString(R.string.screenshot_default_files_app_name)
/** UI that can show work profile messages (ScreenshotView in practice) */
interface WorkProfileMessageDisplay {
/**
* Show the given message and icon, calling onDismiss if the user explicitly dismisses the
* message.
*/
fun showWorkProfileMessage(text: CharSequence, icon: Drawable?, onDismiss: Runnable)
}
companion object {
const val TAG = "WorkProfileMessageCtrl"
const val SHARED_PREFERENCES_NAME = "com.android.systemui.screenshot"
const val PREFERENCE_KEY = "work_profile_first_run"
}
}

View File

@@ -0,0 +1,160 @@
/*
* Copyright (C) 2022 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.systemui.screenshot;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import android.content.ComponentName;
import android.content.Context;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.graphics.drawable.Drawable;
import android.os.UserHandle;
import android.os.UserManager;
import android.testing.AndroidTestingRunner;
import androidx.test.filters.SmallTest;
import com.android.systemui.util.FakeSharedPreferences;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@SmallTest
@RunWith(AndroidTestingRunner.class)
public class WorkProfileMessageControllerTest {
private static final String DEFAULT_LABEL = "default label";
private static final String BADGED_DEFAULT_LABEL = "badged default label";
private static final String APP_LABEL = "app label";
private static final String BADGED_APP_LABEL = "badged app label";
private static final UserHandle NON_WORK_USER = UserHandle.of(0);
private static final UserHandle WORK_USER = UserHandle.of(10);
@Mock
private UserManager mUserManager;
@Mock
private PackageManager mPackageManager;
@Mock
private Context mContext;
@Mock
private WorkProfileMessageController.WorkProfileMessageDisplay mMessageDisplay;
@Mock
private Drawable mActivityIcon;
@Mock
private Drawable mBadgedActivityIcon;
@Mock
private ActivityInfo mActivityInfo;
@Captor
private ArgumentCaptor<Runnable> mRunnableArgumentCaptor;
private FakeSharedPreferences mSharedPreferences = new FakeSharedPreferences();
private WorkProfileMessageController mMessageController;
@Before
public void setup() throws PackageManager.NameNotFoundException {
MockitoAnnotations.initMocks(this);
when(mUserManager.isManagedProfile(eq(WORK_USER.getIdentifier()))).thenReturn(true);
when(mContext.getSharedPreferences(
eq(WorkProfileMessageController.SHARED_PREFERENCES_NAME),
eq(Context.MODE_PRIVATE))).thenReturn(mSharedPreferences);
when(mContext.getString(ArgumentMatchers.anyInt())).thenReturn(DEFAULT_LABEL);
when(mPackageManager.getUserBadgedLabel(eq(DEFAULT_LABEL), any()))
.thenReturn(BADGED_DEFAULT_LABEL);
when(mPackageManager.getUserBadgedLabel(eq(APP_LABEL), any()))
.thenReturn(BADGED_APP_LABEL);
when(mPackageManager.getActivityIcon(any(ComponentName.class)))
.thenReturn(mActivityIcon);
when(mPackageManager.getUserBadgedIcon(
any(), any())).thenReturn(mBadgedActivityIcon);
when(mPackageManager.getActivityInfo(any(),
any(PackageManager.ComponentInfoFlags.class))).thenReturn(mActivityInfo);
when(mActivityInfo.loadLabel(eq(mPackageManager))).thenReturn(APP_LABEL);
mSharedPreferences.edit().putBoolean(
WorkProfileMessageController.PREFERENCE_KEY, false).apply();
mMessageController = new WorkProfileMessageController(mContext, mUserManager,
mPackageManager);
}
@Test
public void testOnScreenshotTaken_notManaged() {
mMessageController.onScreenshotTaken(NON_WORK_USER, mMessageDisplay);
verify(mMessageDisplay, never())
.showWorkProfileMessage(any(), nullable(Drawable.class), any());
}
@Test
public void testOnScreenshotTaken_alreadyDismissed() {
mSharedPreferences.edit().putBoolean(
WorkProfileMessageController.PREFERENCE_KEY, true).apply();
mMessageController.onScreenshotTaken(WORK_USER, mMessageDisplay);
verify(mMessageDisplay, never())
.showWorkProfileMessage(any(), nullable(Drawable.class), any());
}
@Test
public void testOnScreenshotTaken_packageNotFound()
throws PackageManager.NameNotFoundException {
when(mPackageManager.getActivityInfo(any(),
any(PackageManager.ComponentInfoFlags.class))).thenThrow(
new PackageManager.NameNotFoundException());
mMessageController.onScreenshotTaken(WORK_USER, mMessageDisplay);
verify(mMessageDisplay).showWorkProfileMessage(
eq(BADGED_DEFAULT_LABEL), eq(null), any());
}
@Test
public void testOnScreenshotTaken() {
mMessageController.onScreenshotTaken(WORK_USER, mMessageDisplay);
verify(mMessageDisplay).showWorkProfileMessage(
eq(BADGED_APP_LABEL), eq(mBadgedActivityIcon), mRunnableArgumentCaptor.capture());
// Dismiss hasn't been tapped, preference untouched.
assertFalse(
mSharedPreferences.getBoolean(WorkProfileMessageController.PREFERENCE_KEY, false));
mRunnableArgumentCaptor.getValue().run();
// After dismiss has been tapped, the setting should be updated.
assertTrue(
mSharedPreferences.getBoolean(WorkProfileMessageController.PREFERENCE_KEY, false));
}
}