diff options
author | 2020-03-02 16:52:59 +0000 | |
---|---|---|
committer | 2020-03-10 09:55:01 +0000 | |
commit | 34810bc0cf5a8a927c80571d226bf1feed6ba911 (patch) | |
tree | d31f6767fdf6e60c3aba86e778fe8de4f84fa06d | |
parent | b7b46985a6672fa827b30fd66ce0089de598488d (diff) |
Add error handling and UI on WP quiet mode / no permission to share
Listen to WP on/off
When broadcast is received, update roots and then refresh the dir if needed.
If canShareAcrossProfile value changes in RootsFragment, refresh
the directory.
Add 2 new inflated message error screens
When WP is in quiet mode, show a button to turn on WP
MODIFY_QUIET_MODE(privileged) is required.
Bug: 148270816
Test: atest DocumentsUIGoogleTests
Test: manual - turning on/off WP > message shown/disappear
Test: manual - changing dpc policy > message shown/disappear
Change-Id: Icd307e503294aae4a8c7a9c3091facee7f6ec814
25 files changed, 612 insertions, 189 deletions
diff --git a/AndroidManifest.xml b/AndroidManifest.xml index c95847546..151af406f 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -27,6 +27,7 @@ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.CHANGE_OVERLAY_PACKAGES" /> <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS" /> + <uses-permission android:name="android.permission.MODIFY_QUIET_MODE" /> <!-- Permissions required for reading and logging compat changes --> <uses-permission android:name="android.permission.LOG_COMPAT_CHANGE"/> diff --git a/res/drawable/share_off.xml b/res/drawable/share_off.xml new file mode 100644 index 000000000..23e5c564f --- /dev/null +++ b/res/drawable/share_off.xml @@ -0,0 +1,26 @@ +<!-- + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M19.7225,20.9245L21.2011,22.4031L22.4032,21.201L2.8022,1.6L1.6001,2.8021L8.1265,9.3284L7.64,9.612C7.1,9.112 6.39,8.802 5.6,8.802C3.94,8.802 2.6,10.142 2.6,11.802C2.6,13.462 3.94,14.802 5.6,14.802C6.39,14.802 7.1,14.492 7.64,13.992L14.69,18.112C14.64,18.332 14.6,18.562 14.6,18.802C14.6,20.462 15.94,21.802 17.6,21.802C18.43,21.802 19.18,21.467 19.7225,20.9245ZM16.8938,18.0958L18.3063,19.5083C18.125,19.6895 17.875,19.802 17.6,19.802C17.05,19.802 16.6,19.352 16.6,18.802C16.6,18.527 16.7125,18.277 16.8938,18.0958ZM15.1871,16.3891L9.3881,10.5901L8.51,11.102C8.56,11.332 8.6,11.562 8.6,11.802C8.6,12.042 8.56,12.272 8.51,12.502L15.1871,16.3891ZM15.56,6.992L12.4382,8.8119L11.1766,7.5503L14.69,5.502C14.64,5.282 14.6,5.042 14.6,4.802C14.6,3.142 15.94,1.802 17.6,1.802C19.26,1.802 20.6,3.142 20.6,4.802C20.6,6.462 19.26,7.802 17.6,7.802C16.81,7.802 16.09,7.492 15.56,6.992ZM18.6,4.802C18.6,4.252 18.15,3.802 17.6,3.802C17.05,3.802 16.6,4.252 16.6,4.802C16.6,5.352 17.05,5.802 17.6,5.802C18.15,5.802 18.6,5.352 18.6,4.802ZM5.6,12.802C5.05,12.802 4.6,12.352 4.6,11.802C4.6,11.252 5.05,10.802 5.6,10.802C6.15,10.802 6.6,11.252 6.6,11.802C6.6,12.352 6.15,12.802 5.6,12.802Z" + android:fillType="evenOdd" + android:fillColor="@color/error_image_color"/> +</vector> diff --git a/res/drawable/work_off.xml b/res/drawable/work_off.xml new file mode 100644 index 000000000..323f5e884 --- /dev/null +++ b/res/drawable/work_off.xml @@ -0,0 +1,26 @@ +<!-- + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="@color/error_image_color" + android:pathData="M20,6h-4L16,4c0,-1.11 -0.89,-2 -2,-2h-4c-1.11,0 -2,0.89 -2,2v1.17L10.83,8L20,8v9.17l1.98,1.98c0,-0.05 0.02,-0.1 0.02,-0.16L22,8c0,-1.11 -0.89,-2 -2,-2zM14,6h-4L10,4h4v2zM19,19L8,8 6,6 2.81,2.81 1.39,4.22 3.3,6.13C2.54,6.41 2.01,7.14 2.01,8L2,19c0,1.11 0.89,2 2,2h14.17l1.61,1.61 1.41,-1.41 -0.37,-0.37L19,19zM4,19L4,8h1.17l11,11L4,19z"/> +</vector> + diff --git a/res/layout/item_doc_inflated_message.xml b/res/layout/item_doc_inflated_message.xml index c4e95f4c9..abfdbfd10 100644 --- a/res/layout/item_doc_inflated_message.xml +++ b/res/layout/item_doc_inflated_message.xml @@ -22,30 +22,7 @@ android:background="?android:attr/colorBackground" android:focusable="true"> - <LinearLayout - android:id="@+id/content" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical"> - - <ImageView - android:id="@+id/artwork" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="25dp" - android:layout_marginBottom="25dp" - android:scaleType="fitCenter" - android:maxHeight="250dp" - android:adjustViewBounds="true" - android:contentDescription="@null"/> - - <TextView - android:id="@+id/message" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginBottom="25dp" - android:gravity="center_horizontal" - style="?android:attr/textAppearanceListItem"/> - - </LinearLayout> -</FrameLayout>
\ No newline at end of file + <include android:id="@+id/content" layout="@layout/item_doc_inflated_message_content"/> + <include android:id="@+id/cross_profile" + layout="@layout/item_doc_inflated_message_cross_profile"/> +</FrameLayout> diff --git a/res/layout/item_doc_inflated_message_content.xml b/res/layout/item_doc_inflated_message_content.xml new file mode 100644 index 000000000..0635e1654 --- /dev/null +++ b/res/layout/item_doc_inflated_message_content.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/content" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <ImageView + android:id="@+id/artwork" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="25dp" + android:layout_marginBottom="25dp" + android:scaleType="fitCenter" + android:maxHeight="250dp" + android:adjustViewBounds="true" + android:gravity="bottom|center_horizontal" + android:contentDescription="@null"/> + + <TextView + android:id="@+id/message" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="25dp" + android:gravity="center_horizontal" + style="?android:attr/textAppearanceListItem"/> + +</LinearLayout>
\ No newline at end of file diff --git a/res/layout/item_doc_inflated_message_cross_profile.xml b/res/layout/item_doc_inflated_message_cross_profile.xml new file mode 100644 index 000000000..301467993 --- /dev/null +++ b/res/layout/item_doc_inflated_message_cross_profile.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="424dp" + android:orientation="vertical" + android:gravity="center" + android:paddingStart="24dp" + android:paddingEnd="24dp"> + + <ImageView + android:id="@+id/artwork" + android:layout_width="32dp" + android:layout_height="32dp"/> + <TextView + android:id="@+id/title" + android:layout_marginTop="16dp" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="@style/EmptyStateTitleText"/> + <TextView + android:id="@+id/message" + android:layout_marginTop="8dp" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:textAppearance="@style/EmptyStateMessageText"/> + <Button + android:id="@+id/button" + android:layout_marginTop="24dp" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + style="@style/EmptyStateButton"/> +</LinearLayout>
\ No newline at end of file diff --git a/res/values/colors.xml b/res/values/colors.xml index af5201a93..fb00e168c 100644 --- a/res/values/colors.xml +++ b/res/values/colors.xml @@ -43,4 +43,7 @@ <color name="shortcut_background">#fff5f5f5</color> <color name="briefcase_icon_color">#1A73E8</color> + <color name="cross_profile_button_text_color">#1A73E8</color> + <color name="empty_state_text">#202124</color> + <color name="error_image_color">#757575</color> </resources> diff --git a/res/values/strings.xml b/res/values/strings.xml index 18dc80b0a..30a0c78b2 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -173,6 +173,24 @@ <!-- Error message shown when querying for a list of documents failed [CHAR LIMIT=48] --> <string name="query_error">Can\u2019t load content at the moment</string> + <!-- Error message title shown when the target profile is in quiet mode [CHAR LIMIT=72] --> + <string name="quiet_mode_error_title">Turn on work apps</string> + <!-- Error message content shown when the target profile is in quiet mode [CHAR LIMIT=72] --> + <string name="quiet_mode_error_message">Turn on work apps to access work files</string> + <!-- Button text shown on a button when work profile is off. Clicking on the button will switch on the work profile [CHAR LIMIT=48] --> + <string name="quiet_mode_button">Switch on work</string> + + <!-- Error message title shown when the admin does not allow the user to share files across profile [CHAR LIMIT=72] --> + <string name="cant_share_across_profile_error_title">Can\u2019t share across profiles</string> + <!-- Error message content shown when the admin does not allow the user to share files across profile. Shows in work tab[CHAR LIMIT=200] --> + <string name="cant_share_to_personal_error_message">Your IT admin does not allow you to access + work files from a personal app + </string> + <!-- Error message content shown when the admin does not allow the user to share files across profile. Shows in personal tab[CHAR LIMIT=200] --> + <string name="cant_share_to_work_error_message">Your IT admin does not allow you to + access personal files from a work app + </string> + <!-- Title of storage root location that contains recently modified or used documents [CHAR LIMIT=24] --> <string name="root_recent">Recent</string> <!-- Subtitle of storage root indicating the total free space available, in bytes [CHAR LIMIT=24] --> diff --git a/res/values/styles.xml b/res/values/styles.xml index 8c3fd06f0..a86262d18 100644 --- a/res/values/styles.xml +++ b/res/values/styles.xml @@ -87,6 +87,10 @@ <item name="android:textAppearance">@style/MaterialButtonTextAppearance</item> </style> + <style name="EmptyStateButton" parent="@style/Widget.MaterialComponents.Button.TextButton"> + <item name="android:textAppearance">@style/EmptyStateButtonTextAppearance</item> + </style> + <style name="AlertDialogTheme" parent="@style/ThemeOverlay.AppCompat.Dialog.Alert"> <item name="buttonBarPositiveButtonStyle">@style/DialogTextButton</item> <item name="buttonBarNegativeButtonStyle">@style/DialogTextButton</item> diff --git a/res/values/styles_text.xml b/res/values/styles_text.xml index 689db4941..84fefe8f9 100644 --- a/res/values/styles_text.xml +++ b/res/values/styles_text.xml @@ -90,4 +90,21 @@ <item name="fontFamily">@string/config_fontFamilyMedium</item> </style> + <style name="EmptyStateTitleText"> + <item name="android:textColor">@color/empty_state_text</item> + <item name="android:textSize">18sp</item> + <item name="fontFamily">@string/config_fontFamilyMedium</item> + </style> + + <style name="EmptyStateMessageText"> + <item name="android:textColor">@color/empty_state_text</item> + <item name="android:textSize">14sp</item> + </style> + + <style name="EmptyStateButtonTextAppearance"> + <item name="android:textColor">@color/cross_profile_button_text_color</item> + <item name="android:textSize">14sp</item> + <item name="fontFamily">@string/config_fontFamilyMedium</item> + </style> + </resources>
\ No newline at end of file diff --git a/src/com/android/documentsui/CrossProfileNoPermissionException.java b/src/com/android/documentsui/CrossProfileNoPermissionException.java index 484f07e0f..d945dc728 100644 --- a/src/com/android/documentsui/CrossProfileNoPermissionException.java +++ b/src/com/android/documentsui/CrossProfileNoPermissionException.java @@ -19,5 +19,5 @@ package com.android.documentsui; /** * Represents an exception when no permission to query the target profile. */ -class CrossProfileNoPermissionException extends CrossProfileException { +public class CrossProfileNoPermissionException extends CrossProfileException { } diff --git a/src/com/android/documentsui/CrossProfileQuietModeException.java b/src/com/android/documentsui/CrossProfileQuietModeException.java index db2df1f08..6d7c168a2 100644 --- a/src/com/android/documentsui/CrossProfileQuietModeException.java +++ b/src/com/android/documentsui/CrossProfileQuietModeException.java @@ -16,8 +16,17 @@ package com.android.documentsui; +import static androidx.core.util.Preconditions.checkNotNull; + +import com.android.documentsui.base.UserId; + /** - * Represents an exception when the other profile is in quiet mode. + * Represents an exception when the given profile is in quiet mode. */ -class CrossProfileQuietModeException extends CrossProfileException { +public class CrossProfileQuietModeException extends CrossProfileException { + public final UserId mUserId; + + public CrossProfileQuietModeException(UserId userId) { + mUserId = checkNotNull(userId); + } } diff --git a/src/com/android/documentsui/DirectoryLoader.java b/src/com/android/documentsui/DirectoryLoader.java index 9a9cba48e..87753a831 100644 --- a/src/com/android/documentsui/DirectoryLoader.java +++ b/src/com/android/documentsui/DirectoryLoader.java @@ -146,7 +146,7 @@ public class DirectoryLoader extends AsyncTaskLoader<DirectoryResult> { result.exception = new CrossProfileNoPermissionException(); return result; } else if (mRoot.userId.isQuietModeEnabled(getContext())) { - result.exception = new CrossProfileQuietModeException(); + result.exception = new CrossProfileQuietModeException(mRoot.userId); return result; } else if (mDoc == null) { // TODO (b/35996595): Consider plumbing through the actual exception, though it diff --git a/src/com/android/documentsui/DocumentsApplication.java b/src/com/android/documentsui/DocumentsApplication.java index 745da0afd..c7e21a434 100644 --- a/src/com/android/documentsui/DocumentsApplication.java +++ b/src/com/android/documentsui/DocumentsApplication.java @@ -30,6 +30,8 @@ import android.os.RemoteException; import android.text.format.DateUtils; import android.util.Log; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + import com.android.documentsui.base.Lookup; import com.android.documentsui.base.UserId; import com.android.documentsui.clipping.ClipStorage; @@ -39,10 +41,28 @@ import com.android.documentsui.queries.SearchHistoryManager; import com.android.documentsui.roots.ProvidersCache; import com.android.documentsui.theme.ThemeOverlayManager; +import com.google.common.collect.Lists; + +import java.util.List; + public class DocumentsApplication extends Application { private static final String TAG = "DocumentsApplication"; private static final long PROVIDER_ANR_TIMEOUT = 20 * DateUtils.SECOND_IN_MILLIS; + private static final List<String> PACKAGE_FILTER_ACTIONS = Lists.newArrayList( + Intent.ACTION_PACKAGE_ADDED, + Intent.ACTION_PACKAGE_CHANGED, + Intent.ACTION_PACKAGE_REMOVED, + Intent.ACTION_PACKAGE_DATA_CLEARED + ); + + private static final List<String> MANAGED_PROFILE_FILTER_ACTIONS = Lists.newArrayList( + Intent.ACTION_MANAGED_PROFILE_ADDED, + Intent.ACTION_MANAGED_PROFILE_REMOVED, + Intent.ACTION_MANAGED_PROFILE_UNLOCKED, + Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE + ); + private ProvidersCache mProviders; private ThumbnailCache mThumbnailCache; private ClipStorage mClipStore; @@ -113,7 +133,7 @@ public class DocumentsApplication extends Application { mUserIdManager = UserIdManager.create(this); mProviders = new ProvidersCache(this, mUserIdManager); - mProviders.updateAsync(false); + mProviders.updateAsync(/* forceRefreshAll= */ false, /* callback= */ null); mThumbnailCache = new ThumbnailCache(memoryClassBytes / 4); @@ -127,10 +147,9 @@ public class DocumentsApplication extends Application { mFileTypeLookup = new FileTypeMap(this); final IntentFilter packageFilter = new IntentFilter(); - packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED); - packageFilter.addAction(Intent.ACTION_PACKAGE_CHANGED); - packageFilter.addAction(Intent.ACTION_PACKAGE_REMOVED); - packageFilter.addAction(Intent.ACTION_PACKAGE_DATA_CLEARED); + for (String packageAction : PACKAGE_FILTER_ACTIONS) { + packageFilter.addAction(packageAction); + } packageFilter.addDataScheme("package"); registerReceiver(mCacheReceiver, packageFilter); @@ -138,6 +157,12 @@ public class DocumentsApplication extends Application { localeFilter.addAction(Intent.ACTION_LOCALE_CHANGED); registerReceiver(mCacheReceiver, localeFilter); + final IntentFilter managedProfileFilter = new IntentFilter(); + for (String managedProfileAction : MANAGED_PROFILE_FILTER_ACTIONS) { + managedProfileFilter.addAction(managedProfileAction); + } + registerReceiver(mCacheReceiver, managedProfileFilter); + SearchHistoryManager.getInstance(getApplicationContext()); } @@ -152,11 +177,17 @@ public class DocumentsApplication extends Application { @Override public void onReceive(Context context, Intent intent) { final Uri data = intent.getData(); - if (data != null) { + final String action = intent.getAction(); + if (PACKAGE_FILTER_ACTIONS.contains(action) && data != null) { final String packageName = data.getSchemeSpecificPart(); mProviders.updatePackageAsync(UserId.DEFAULT_USER, packageName); + } else if (MANAGED_PROFILE_FILTER_ACTIONS.contains(action)) { + // After we have reloaded roots. Resend the broadcast locally so the other + // components can reload properly after roots are updated. + mProviders.updateAsync(/* forceRefreshAll= */ true, + () -> LocalBroadcastManager.getInstance(context).sendBroadcast(intent)); } else { - mProviders.updateAsync(true); + mProviders.updateAsync(/* forceRefreshAll= */ true, /* callback= */ null); } } }; diff --git a/src/com/android/documentsui/RecentsLoader.java b/src/com/android/documentsui/RecentsLoader.java index 2eb259e40..62ea6d05f 100644 --- a/src/com/android/documentsui/RecentsLoader.java +++ b/src/com/android/documentsui/RecentsLoader.java @@ -60,7 +60,7 @@ public class RecentsLoader extends MultiRootDocumentsLoader { return result; } else if (mUserId.isQuietModeEnabled(getContext())) { DirectoryResult result = new DirectoryResult(); - result.exception = new CrossProfileQuietModeException(); + result.exception = new CrossProfileQuietModeException(mUserId); return result; } return super.loadInBackground(); diff --git a/src/com/android/documentsui/UserIdManager.java b/src/com/android/documentsui/UserIdManager.java index 3458fd973..b3cc8d0c3 100644 --- a/src/com/android/documentsui/UserIdManager.java +++ b/src/com/android/documentsui/UserIdManager.java @@ -114,8 +114,8 @@ public interface UserIdManager { IntentFilter filter = new IntentFilter(); - filter.addAction(Intent.ACTION_USER_ADDED); - filter.addAction(Intent.ACTION_USER_REMOVED); + filter.addAction(Intent.ACTION_MANAGED_PROFILE_ADDED); + filter.addAction(Intent.ACTION_MANAGED_PROFILE_REMOVED); mContext.registerReceiver(mIntentReceiver, filter); } diff --git a/src/com/android/documentsui/base/UserId.java b/src/com/android/documentsui/base/UserId.java index d17b3e79c..7203a9593 100644 --- a/src/com/android/documentsui/base/UserId.java +++ b/src/com/android/documentsui/base/UserId.java @@ -147,6 +147,19 @@ public final class UserId { } /** + * Disables quiet mode for a managed profile. The caller should check {@code + * MODIFY_QUIET_MODE} permission first. + * + * @return {@code false} if user's credential is needed in order to turn off quiet mode, + * {@code true} otherwise + */ + public boolean requestQuietModeDisabled(Context context) { + final UserManager userManager = + (UserManager) context.getSystemService(Context.USER_SERVICE); + return userManager.requestQuietModeEnabled(false, mUserHandle); + } + + /** * Returns a document uri representing this user. */ public Uri buildDocumentUriAsUser(String authority, String documentId) { diff --git a/src/com/android/documentsui/dirlist/InflateMessageDocumentHolder.java b/src/com/android/documentsui/dirlist/InflateMessageDocumentHolder.java index e332feefd..f655d69b0 100644 --- a/src/com/android/documentsui/dirlist/InflateMessageDocumentHolder.java +++ b/src/com/android/documentsui/dirlist/InflateMessageDocumentHolder.java @@ -18,6 +18,8 @@ package com.android.documentsui.dirlist; import android.content.Context; import android.database.Cursor; +import android.text.TextUtils; +import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; @@ -30,14 +32,34 @@ import com.android.documentsui.R; * Used by {@link DirectoryAddonsAdapter}. */ final class InflateMessageDocumentHolder extends MessageHolder { + public static final int LAYOUT_CROSS_PROFILE_ERROR = 1; + private Message mMessage; - private TextView mMsgView; - private ImageView mImageView; + + private TextView mContentMessage; + private ImageView mContentImage; + + private TextView mCrossProfileTitle; + private TextView mCrossProfileMessage; + private ImageView mCrossProfileImage; + private TextView mCrossProfileButton; + + + private View mContentView; + private View mCrossProfileView; public InflateMessageDocumentHolder(Context context, ViewGroup parent) { super(context, parent, R.layout.item_doc_inflated_message); - mMsgView = (TextView) itemView.findViewById(R.id.message); - mImageView = (ImageView) itemView.findViewById(R.id.artwork); + mContentView = itemView.findViewById(R.id.content); + mCrossProfileView = itemView.findViewById(R.id.cross_profile); + + mContentMessage = mContentView.findViewById(R.id.message); + mContentImage = mContentView.findViewById(R.id.artwork); + + mCrossProfileTitle = mCrossProfileView.findViewById(R.id.title); + mCrossProfileMessage = mCrossProfileView.findViewById(R.id.message); + mCrossProfileImage = mCrossProfileView.findViewById(R.id.artwork); + mCrossProfileButton = mCrossProfileView.findViewById(R.id.button); } public void bind(Message message) { @@ -47,7 +69,38 @@ final class InflateMessageDocumentHolder extends MessageHolder { @Override public void bind(Cursor cursor, String modelId) { - mMsgView.setText(mMessage.getMessageString()); - mImageView.setImageDrawable(mMessage.getIcon()); + if (mMessage.getLayout() == LAYOUT_CROSS_PROFILE_ERROR) { + bindCrossProfileMessageView(); + } else { + bindContentMessageView(); + } + } + + private void onButtonClick(View button) { + mMessage.runCallback(); + } + + private void bindContentMessageView() { + mContentView.setVisibility(View.VISIBLE); + mCrossProfileView.setVisibility(View.GONE); + + mContentMessage.setText(mMessage.getMessageString()); + mContentImage.setImageDrawable(mMessage.getIcon()); + } + + private void bindCrossProfileMessageView() { + mContentView.setVisibility(View.GONE); + mCrossProfileView.setVisibility(View.VISIBLE); + + mCrossProfileTitle.setText(mMessage.getTitleString()); + mCrossProfileMessage.setText(mMessage.getMessageString()); + mCrossProfileImage.setImageDrawable(mMessage.getIcon()); + if (!TextUtils.isEmpty(mMessage.getButtonString())) { + mCrossProfileButton.setVisibility(View.VISIBLE); + mCrossProfileButton.setText(mMessage.getButtonString()); + mCrossProfileButton.setOnClickListener(this::onButtonClick); + } else { + mCrossProfileButton.setVisibility(View.GONE); + } } } diff --git a/src/com/android/documentsui/dirlist/Message.java b/src/com/android/documentsui/dirlist/Message.java index ebbe867a7..93eef0477 100644 --- a/src/com/android/documentsui/dirlist/Message.java +++ b/src/com/android/documentsui/dirlist/Message.java @@ -16,16 +16,22 @@ package com.android.documentsui.dirlist; +import android.Manifest; import android.app.AuthenticationRequiredException; +import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; +import android.os.AsyncTask; import androidx.annotation.Nullable; +import com.android.documentsui.CrossProfileNoPermissionException; +import com.android.documentsui.CrossProfileQuietModeException; import com.android.documentsui.DocumentsApplication; import com.android.documentsui.Model.Update; import com.android.documentsui.R; import com.android.documentsui.base.RootInfo; import com.android.documentsui.base.State; +import com.android.documentsui.base.UserId; import com.android.documentsui.dirlist.DocumentsAdapter.Environment; /** @@ -45,6 +51,7 @@ abstract class Message { private @Nullable Drawable mIcon; private boolean mShouldShow = false; protected boolean mShouldKeep = false; + protected int mLayout; Message(Environment env, Runnable defaultCallback) { mEnv = env; @@ -69,6 +76,7 @@ abstract class Message { mMessageString = null; mIcon = null; mShouldShow = false; + mLayout = 0; } void runCallback() { @@ -83,6 +91,10 @@ abstract class Message { return mIcon; } + int getLayout() { + return mLayout; + } + boolean shouldShow() { return mShouldShow; } @@ -171,16 +183,27 @@ abstract class Message { final static class InflateMessage extends Message { + private final boolean mCanModifyQuietMode; + InflateMessage(Environment env, Runnable callback) { super(env, callback); + mCanModifyQuietMode = + mEnv.getContext().checkSelfPermission(Manifest.permission.MODIFY_QUIET_MODE) + == PackageManager.PERMISSION_GRANTED; } @Override void update(Update event) { reset(); if (event.hasCrossProfileException()) { - // TODO: update error message. - updateToInflatedErrorMessage(); + if (event.getException() instanceof CrossProfileQuietModeException) { + updateToQuietModeErrorMessage( + ((CrossProfileQuietModeException) event.getException()).mUserId); + } else if (event.getException() instanceof CrossProfileNoPermissionException) { + updateToCrossProfileNoPermissionErrorMessage(); + } else { + updateToInflatedErrorMessage(); + } } else if (event.hasException() && !event.hasAuthenticationException()) { updateToInflatedErrorMessage(); } else if (event.hasAuthenticationException()) { @@ -190,6 +213,39 @@ abstract class Message { } } + private void updateToQuietModeErrorMessage(UserId userId) { + mLayout = InflateMessageDocumentHolder.LAYOUT_CROSS_PROFILE_ERROR; + CharSequence buttonText = null; + if (mCanModifyQuietMode) { + buttonText = mEnv.getContext().getResources().getText(R.string.quiet_mode_button); + mCallback = () -> + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... voids) { + userId.requestQuietModeDisabled(mEnv.getContext()); + return null; + } + }.execute(); + } + update( + mEnv.getContext().getResources().getText(R.string.quiet_mode_error_title), + mEnv.getContext().getResources().getText(R.string.quiet_mode_error_message), + buttonText, + mEnv.getContext().getDrawable(R.drawable.work_off)); + } + + private void updateToCrossProfileNoPermissionErrorMessage() { + mLayout = InflateMessageDocumentHolder.LAYOUT_CROSS_PROFILE_ERROR; + update( + mEnv.getContext().getResources().getText( + R.string.cant_share_across_profile_error_title), + mEnv.getContext().getResources().getText(UserId.CURRENT_USER.isSystem() + ? R.string.cant_share_to_personal_error_message + : R.string.cant_share_to_work_error_message), + /* buttonString= */ null, + mEnv.getContext().getDrawable(R.drawable.share_off)); + } + private void updateToInflatedErrorMessage() { update(null, mEnv.getContext().getResources().getText(R.string.query_error), null, mEnv.getContext().getDrawable(R.drawable.hourglass)); diff --git a/src/com/android/documentsui/roots/ProvidersCache.java b/src/com/android/documentsui/roots/ProvidersCache.java index e9ebe91f9..f35d05e2f 100644 --- a/src/com/android/documentsui/roots/ProvidersCache.java +++ b/src/com/android/documentsui/roots/ProvidersCache.java @@ -187,7 +187,7 @@ public class ProvidersCache implements ProvidersAccess, LookupApplicationName { return mObservedAuthoritiesDetails.get(new UserAuthority(userId, authority)).packageName; } - public void updateAsync(boolean forceRefreshAll) { + public void updateAsync(boolean forceRefreshAll, @Nullable Runnable callback) { // NOTE: This method is called when the UI language changes. // For that reason we update our RecentsRoot to reflect @@ -205,12 +205,13 @@ public class ProvidersCache implements ProvidersAccess, LookupApplicationName { assert (recentRoot.availableBytes == -1); } - new UpdateTask(forceRefreshAll, null).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + new UpdateTask(forceRefreshAll, null, callback).executeOnExecutor( + AsyncTask.THREAD_POOL_EXECUTOR); } public void updatePackageAsync(UserId userId, String packageName) { - new UpdateTask(false, new UserPackage(userId, packageName)).executeOnExecutor( - AsyncTask.THREAD_POOL_EXECUTOR); + new UpdateTask(false, new UserPackage(userId, packageName), + /* callback= */ null).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } public void updateAuthorityAsync(UserId userId, String authority) { @@ -488,6 +489,8 @@ public class ProvidersCache implements ProvidersAccess, LookupApplicationName { private final boolean mForceRefreshAll; @Nullable private final UserPackage mForceRefreshUserPackage; + @Nullable + private final Runnable mCallback; private final Multimap<UserAuthority, RootInfo> mTaskRoots = ArrayListMultimap.create(); private final HashSet<UserAuthority> mTaskStoppedAuthorities = new HashSet<>(); @@ -499,10 +502,13 @@ public class ProvidersCache implements ProvidersAccess, LookupApplicationName { * all packages should be ignored. * @param forceRefreshUserPackage when non-null, all previously cached * values for this specific user package should be ignored. + * @param callback when non-null, it will be invoked after the task is executed. */ - UpdateTask(boolean forceRefreshAll, @Nullable UserPackage forceRefreshUserPackage) { + UpdateTask(boolean forceRefreshAll, @Nullable UserPackage forceRefreshUserPackage, + @Nullable Runnable callback) { mForceRefreshAll = forceRefreshAll; mForceRefreshUserPackage = forceRefreshUserPackage; + mCallback = callback; } @Override @@ -542,6 +548,13 @@ public class ProvidersCache implements ProvidersAccess, LookupApplicationName { return null; } + @Override + protected void onPostExecute(Void aVoid) { + if (mCallback != null) { + mCallback.run(); + } + } + private void handleDocumentsProvider(ProviderInfo info, UserId userId) { UserAuthority userAuthority = new UserAuthority(userId, info.authority); // Ignore stopped packages for now; we might query them diff --git a/src/com/android/documentsui/sidebar/RootsFragment.java b/src/com/android/documentsui/sidebar/RootsFragment.java index db3171d3c..ed9ba1294 100644 --- a/src/com/android/documentsui/sidebar/RootsFragment.java +++ b/src/com/android/documentsui/sidebar/RootsFragment.java @@ -413,8 +413,13 @@ public class RootsFragment extends Fragment { } } - // TODO: refresh UI - getBaseActivity().getDisplayState().canShareAcrossProfile = profileItem != null; + boolean canShareAcrossProfile = profileItem != null; + if (getBaseActivity().getDisplayState().canShareAcrossProfile != canShareAcrossProfile) { + getBaseActivity().getDisplayState().canShareAcrossProfile = canShareAcrossProfile; + if (!UserId.CURRENT_USER.equals(getBaseActivity().getSelectedUser())) { + mActionHandler.loadDocumentsForCurrentStack(); + } + } // If there are some providers and apps has the same package name, combine them as one item. for (RootItem rootItem : otherProviders) { @@ -448,7 +453,7 @@ public class RootsFragment extends Fragment { } } - if (profileItem != null && Features.CROSS_PROFILE_TABS) { + if (canShareAcrossProfile && Features.CROSS_PROFILE_TABS) { // Combine lists only if we enabled profile tab feature. rootList.addAll(rootListOtherUser); } @@ -466,7 +471,7 @@ public class RootsFragment extends Fragment { mApplicationItemList = rootList; - if (profileItem != null && !Features.CROSS_PROFILE_TABS) { + if (canShareAcrossProfile && !Features.CROSS_PROFILE_TABS) { // Add profile item if we don't support cross-profile tab. result.add(new SpacerItem()); result.add(profileItem); diff --git a/tests/common/com/android/documentsui/dirlist/TestEnvironment.java b/tests/common/com/android/documentsui/dirlist/TestEnvironment.java new file mode 100644 index 000000000..2e91e0733 --- /dev/null +++ b/tests/common/com/android/documentsui/dirlist/TestEnvironment.java @@ -0,0 +1,96 @@ +/* + * 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 com.android.documentsui.dirlist; + +import android.content.Context; +import android.database.Cursor; + +import com.android.documentsui.ActionHandler; +import com.android.documentsui.Model; +import com.android.documentsui.base.Features; +import com.android.documentsui.base.State; +import com.android.documentsui.testing.TestEnv; + +public final class TestEnvironment implements DocumentsAdapter.Environment { + private final Context testContext; + private final TestEnv mEnv; + private final ActionHandler mActionHandler; + + public TestEnvironment(Context testContext, TestEnv env, ActionHandler actionHandler) { + this.testContext = testContext; + mEnv = env; + mActionHandler = actionHandler; + } + + @Override + public Features getFeatures() { + return mEnv.features; + } + + @Override + public ActionHandler getActionHandler() { + return mActionHandler; + } + + @Override + public boolean isSelected(String id) { + return false; + } + + @Override + public boolean isDocumentEnabled(String mimeType, int flags) { + return true; + } + + @Override + public void initDocumentHolder(DocumentHolder holder) { + } + + @Override + public Model getModel() { + return mEnv.model; + } + + @Override + public State getDisplayState() { + return mEnv.state; + } + + @Override + public boolean isInSearchMode() { + return false; + } + + @Override + public Context getContext() { + return testContext; + } + + @Override + public int getColumnCount() { + return 4; + } + + @Override + public void onBindDocumentHolder(DocumentHolder holder, Cursor cursor) { + } + + @Override + public String getCallingAppName() { + return "unknown"; + } +} diff --git a/tests/unit/com/android/documentsui/dirlist/DirectoryAddonsAdapterTest.java b/tests/unit/com/android/documentsui/dirlist/DirectoryAddonsAdapterTest.java index cdbf35ce2..54c563259 100644 --- a/tests/unit/com/android/documentsui/dirlist/DirectoryAddonsAdapterTest.java +++ b/tests/unit/com/android/documentsui/dirlist/DirectoryAddonsAdapterTest.java @@ -17,7 +17,6 @@ package com.android.documentsui.dirlist; import android.content.Context; -import android.database.Cursor; import android.os.Bundle; import android.provider.DocumentsContract; import android.test.AndroidTestCase; @@ -27,10 +26,8 @@ import androidx.recyclerview.widget.RecyclerView; import androidx.test.filters.MediumTest; import com.android.documentsui.ActionHandler; -import com.android.documentsui.Model; import com.android.documentsui.ModelId; import com.android.documentsui.base.DocumentInfo; -import com.android.documentsui.base.Features; import com.android.documentsui.base.State; import com.android.documentsui.testing.TestActionHandler; import com.android.documentsui.testing.TestEnv; @@ -53,7 +50,7 @@ public class DirectoryAddonsAdapterTest extends AndroidTestCase { mEnv.clear(); final Context testContext = TestContext.createStorageTestContext(getContext(), AUTHORITY); - DocumentsAdapter.Environment env = new TestEnvironment(testContext); + DocumentsAdapter.Environment env = new TestEnvironment(testContext, mEnv, mActionHandler); mAdapter = new DirectoryAddonsAdapter( env, @@ -189,68 +186,6 @@ public class DirectoryAddonsAdapterTest extends AndroidTestCase { assertTrue(mAdapter.getItemViewType(index) == type); } - private final class TestEnvironment implements DocumentsAdapter.Environment { - private final Context testContext; - - private TestEnvironment(Context testContext) { - this.testContext = testContext; - } - - @Override - public Features getFeatures() { - return mEnv.features; - } - - @Override - public ActionHandler getActionHandler() { return mActionHandler; } - - @Override - public boolean isSelected(String id) { - return false; - } - - @Override - public boolean isDocumentEnabled(String mimeType, int flags) { - return true; - } - - @Override - public void initDocumentHolder(DocumentHolder holder) {} - - @Override - public Model getModel() { - return mEnv.model; - } - - @Override - public State getDisplayState() { - return mEnv.state; - } - - @Override - public boolean isInSearchMode() { - return false; - } - - @Override - public Context getContext() { - return testContext; - } - - @Override - public int getColumnCount() { - return 4; - } - - @Override - public void onBindDocumentHolder(DocumentHolder holder, Cursor cursor) {} - - @Override - public String getCallingAppName() { - return "unknown"; - } - } - private static class DummyAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { @Override public int getItemCount() { return 0; } diff --git a/tests/unit/com/android/documentsui/dirlist/MessageTest.java b/tests/unit/com/android/documentsui/dirlist/MessageTest.java new file mode 100644 index 000000000..d31f9321a --- /dev/null +++ b/tests/unit/com/android/documentsui/dirlist/MessageTest.java @@ -0,0 +1,109 @@ +/* + * 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 com.android.documentsui.dirlist; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.os.UserHandle; +import android.os.UserManager; + +import androidx.test.filters.SmallTest; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.documentsui.CrossProfileNoPermissionException; +import com.android.documentsui.CrossProfileQuietModeException; +import com.android.documentsui.Model; +import com.android.documentsui.R; +import com.android.documentsui.base.UserId; +import com.android.documentsui.testing.TestActionHandler; +import com.android.documentsui.testing.TestEnv; +import com.android.documentsui.testing.UserManagers; +import com.android.documentsui.util.VersionUtils; + +import org.junit.Before; +import org.junit.Test; + +@SmallTest +public final class MessageTest { + + private UserId mUserId = UserId.of(100); + private Message mInflateMessage; + private Context mContext; + private Runnable mDefaultCallback = () -> { + }; + private UserManager mUserManager; + + @Before + public void setUp() { + mContext = mock(Context.class); + mUserManager = UserManagers.create(); + when(mContext.getSystemService(Context.USER_SERVICE)).thenReturn(mUserManager); + when(mContext.getResources()).thenReturn( + InstrumentationRegistry.getInstrumentation().getTargetContext().getResources()); + DocumentsAdapter.Environment env = + new TestEnvironment(mContext, TestEnv.create(), new TestActionHandler()); + mInflateMessage = new Message.InflateMessage(env, mDefaultCallback); + } + + @Test + public void testInflateMessage_updateToCrossProfileNoPermission() { + Model.Update error = new Model.Update( + new CrossProfileNoPermissionException(), + /* isRemoteActionsEnabled= */ true); + + mInflateMessage.update(error); + assertThat(mInflateMessage.getLayout()) + .isEqualTo(InflateMessageDocumentHolder.LAYOUT_CROSS_PROFILE_ERROR); + assertThat(mInflateMessage.getTitleString()) + .isEqualTo(mContext.getString(R.string.cant_share_across_profile_error_title)); + // The value varies according to the current user. Simply assert not null. + assertThat(mInflateMessage.getMessageString()).isNotNull(); + // No button for this error + assertThat(mInflateMessage.getButtonString()).isNull(); + } + + @Test + public void testInflateMessage_updateToCrossProfileQuietMode() { + Model.Update error = new Model.Update( + new CrossProfileQuietModeException(mUserId), + /* isRemoteActionsEnabled= */ true); + mInflateMessage.update(error); + assertThat(mInflateMessage.getLayout()) + .isEqualTo(InflateMessageDocumentHolder.LAYOUT_CROSS_PROFILE_ERROR); + assertThat(mInflateMessage.getTitleString()) + .isEqualTo(mContext.getString(R.string.quiet_mode_error_title)); + assertThat(mInflateMessage.getMessageString()) + .isEqualTo(mContext.getString(R.string.quiet_mode_error_message)); + if (VersionUtils.isAtLeastR()) { + // On R or above, we should have permission and can populate a button + assertThat(mInflateMessage.getButtonString()).isEqualTo( + mContext.getString(R.string.quiet_mode_button)); + assertThat(mInflateMessage.mCallback).isNotNull(); + mInflateMessage.mCallback.run(); + verify(mUserManager, timeout(3000)) + .requestQuietModeEnabled(false, UserHandle.of(mUserId.getIdentifier())); + } else { + assertThat(mInflateMessage.getButtonString()).isNull(); + } + } +} diff --git a/tests/unit/com/android/documentsui/dirlist/ModelBackedDocumentsAdapterTest.java b/tests/unit/com/android/documentsui/dirlist/ModelBackedDocumentsAdapterTest.java index 5b5d7f440..72014f63b 100644 --- a/tests/unit/com/android/documentsui/dirlist/ModelBackedDocumentsAdapterTest.java +++ b/tests/unit/com/android/documentsui/dirlist/ModelBackedDocumentsAdapterTest.java @@ -17,14 +17,12 @@ package com.android.documentsui.dirlist; import android.content.Context; -import android.database.Cursor; import android.test.AndroidTestCase; import androidx.test.filters.MediumTest; import com.android.documentsui.ActionHandler; import com.android.documentsui.Model; -import com.android.documentsui.base.Features; import com.android.documentsui.base.State; import com.android.documentsui.testing.TestActionHandler; import com.android.documentsui.testing.TestEnv; @@ -45,7 +43,7 @@ public class ModelBackedDocumentsAdapterTest extends AndroidTestCase { mEnv = TestEnv.create(AUTHORITY); mActionHandler = new TestActionHandler(); - DocumentsAdapter.Environment env = new TestEnvironment(testContext); + DocumentsAdapter.Environment env = new TestEnvironment(testContext, mEnv, mActionHandler); mAdapter = new ModelBackedDocumentsAdapter( env, @@ -58,66 +56,4 @@ public class ModelBackedDocumentsAdapterTest extends AndroidTestCase { public void testItemCount() { assertEquals(mEnv.model.getItemCount(), mAdapter.getItemCount()); } - - private final class TestEnvironment implements DocumentsAdapter.Environment { - private final Context testContext; - - @Override - public Features getFeatures() { - return mEnv.features; - } - - @Override - public ActionHandler getActionHandler() { return mActionHandler; } - - private TestEnvironment(Context testContext) { - this.testContext = testContext; - } - - @Override - public boolean isSelected(String id) { - return false; - } - - @Override - public boolean isDocumentEnabled(String mimeType, int flags) { - return true; - } - - @Override - public void initDocumentHolder(DocumentHolder holder) {} - - @Override - public Model getModel() { - return mEnv.model; - } - - @Override - public State getDisplayState() { - return null; - } - - @Override - public boolean isInSearchMode() { - return false; - } - - @Override - public Context getContext() { - return testContext; - } - - @Override - public int getColumnCount() { - return 4; - } - - @Override - public void onBindDocumentHolder(DocumentHolder holder, Cursor cursor) {} - - @Override - public String getCallingAppName() { - return "unknown"; - } - } } |