Merge "Add mute button in video preview" into sc-mainline-prod
diff --git a/TEST_MAPPING b/TEST_MAPPING
index 46e36fc..b4a098b 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -14,6 +14,9 @@
},
{
"name": "CtsMediaProviderTranscodeTests[com.google.android.mediaprovider.apex]"
+ },
+ {
+ "name": "CtsPhotoPickerTest[com.google.android.mediaprovider.apex]"
}
],
"presubmit": [
diff --git a/apex/framework/java/android/provider/CloudMediaProvider.java b/apex/framework/java/android/provider/CloudMediaProvider.java
index a3e52c8..bf2e55c 100644
--- a/apex/framework/java/android/provider/CloudMediaProvider.java
+++ b/apex/framework/java/android/provider/CloudMediaProvider.java
@@ -16,7 +16,10 @@
package android.provider;
+import static android.provider.CloudMediaProviderContract.EXTRA_LOOPING_PLAYBACK_ENABLED;
import static android.provider.CloudMediaProviderContract.EXTRA_SURFACE_CONTROLLER;
+import static android.provider.CloudMediaProviderContract.EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED;
+import static android.provider.CloudMediaProviderContract.EXTRA_SURFACE_EVENT_CALLBACK;
import static android.provider.CloudMediaProviderContract.METHOD_CREATE_SURFACE_CONTROLLER;
import static android.provider.CloudMediaProviderContract.METHOD_GET_ACCOUNT_INFO;
import static android.provider.CloudMediaProviderContract.METHOD_GET_MEDIA_INFO;
@@ -28,6 +31,7 @@
import static android.provider.CloudMediaProviderContract.URI_PATH_SURFACE_CONTROLLER;
import android.annotation.DurationMillisLong;
+import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.SuppressLint;
@@ -44,12 +48,16 @@
import android.net.Uri;
import android.os.Bundle;
import android.os.CancellationSignal;
+import android.os.IBinder;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import android.view.Surface;
import android.view.SurfaceHolder;
import java.io.FileNotFoundException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Objects;
/**
* Base class for a cloud media provider. A cloud media provider offers read-only access to durable
@@ -105,6 +113,9 @@
private static final int MATCH_MEDIA_INFO = 5;
private static final int MATCH_SURFACE_CONTROLLER = 6;
+ private static final boolean DEFAULT_LOOPING_PLAYBACK_ENABLED = true;
+ private static final boolean DEFAULT_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED = false;
+
private final UriMatcher mMatcher = new UriMatcher(UriMatcher.NO_MATCH);
private volatile int mMediaStoreAuthorityAppId;
@@ -306,13 +317,17 @@
* <p>This is meant to be called on the main thread, hence the implementation should not block
* by performing any heavy operation.
*
- * @param extras containing configuration parameters for {@link SurfaceController}
+ * @param config containing configuration parameters for {@link SurfaceController}
* <ul>
* <li> {@link CloudMediaProviderContract#EXTRA_LOOPING_PLAYBACK_ENABLED}
+ * <li> {@link CloudMediaProviderContract#EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED}
* </ul>
+ * @param callback {@link SurfaceEventCallback} to send event updates for {@link Surface} to
+ * picker launched via {@link MediaStore#ACTION_PICK_IMAGES}
*/
@Nullable
- public SurfaceController onCreateSurfaceController(@Nullable Bundle extras) {
+ public SurfaceController onCreateSurfaceController(@NonNull Bundle config,
+ @NonNull SurfaceEventCallback callback) {
return null;
}
@@ -342,19 +357,38 @@
} else if (METHOD_GET_ACCOUNT_INFO.equals(method)) {
return onGetAccountInfo(extras);
} else if (METHOD_CREATE_SURFACE_CONTROLLER.equals(method)) {
- SurfaceController controller = onCreateSurfaceController(extras);
- Bundle bundle = new Bundle();
- if (controller == null) {
- return bundle;
- }
- bundle.putBinder(EXTRA_SURFACE_CONTROLLER,
- new SurfaceControllerWrapper(controller).asBinder());
- return bundle;
+ return onCreateSurfaceController(extras);
} else {
throw new UnsupportedOperationException("Method not supported " + method);
}
}
+ private Bundle onCreateSurfaceController(@NonNull Bundle extras) {
+ Objects.requireNonNull(extras);
+
+ final IBinder binder = extras.getBinder(EXTRA_SURFACE_EVENT_CALLBACK);
+ if (binder == null) {
+ throw new IllegalArgumentException("Missing surface event callback");
+ }
+
+ final SurfaceEventCallback callback =
+ new SurfaceEventCallback(ICloudSurfaceEventCallback.Stub.asInterface(binder));
+ final Bundle config = new Bundle();
+ config.putBoolean(EXTRA_LOOPING_PLAYBACK_ENABLED, DEFAULT_LOOPING_PLAYBACK_ENABLED);
+ config.putBoolean(EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED,
+ DEFAULT_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED);
+ final SurfaceController controller = onCreateSurfaceController(config, callback);
+ if (controller == null) {
+ Log.d(TAG, "onCreateSurfaceController returned null");
+ return Bundle.EMPTY;
+ }
+
+ Bundle result = new Bundle();
+ result.putBinder(EXTRA_SURFACE_CONTROLLER,
+ new SurfaceControllerWrapper(controller).asBinder());
+ return result;
+ }
+
/**
* Implementation is provided by the parent class. Cannot be overridden.
*
@@ -608,8 +642,19 @@
* @param surfaceId id which uniquely identifies the {@link Surface} for rendering
* @param timestampMillis the timestamp in milliseconds from the start to seek to
*/
- public abstract void onMediaSeekTo(int surfaceId,
- @DurationMillisLong long timestampMillis);
+ public abstract void onMediaSeekTo(int surfaceId, @DurationMillisLong long timestampMillis);
+
+ /**
+ * Changes the configuration parameters for the SurfaceController.
+ *
+ * @param config the updated config to change to. This can include config changes for the
+ * following:
+ * <ul>
+ * <li> {@link CloudMediaProviderContract#EXTRA_LOOPING_PLAYBACK_ENABLED}
+ * <li> {@link CloudMediaProviderContract#EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED}
+ * </ul>
+ */
+ public abstract void onConfigChange(@NonNull Bundle config);
/**
* Indicates destruction of this SurfaceController object.
@@ -620,7 +665,93 @@
public abstract void onDestroy();
}
- /** @hide */
+ /**
+ * This class is used by {@link CloudMediaProvider} to send {@link Surface} event updates to
+ * picker launched via {@link MediaStore#ACTION_PICK_IMAGES}.
+ *
+ * @see MediaStore#ACTION_PICK_IMAGES
+ */
+ public static final class SurfaceEventCallback {
+
+ /** {@hide} */
+ @IntDef(flag = true, prefix = { "PLAYBACK_EVENT_" }, value = {
+ PLAYBACK_EVENT_BUFFERING,
+ PLAYBACK_EVENT_READY,
+ PLAYBACK_EVENT_STARTED,
+ PLAYBACK_EVENT_PAUSED,
+ PLAYBACK_EVENT_COMPLETED,
+ PLAYBACK_EVENT_ERROR_RETRIABLE_FAILURE,
+ PLAYBACK_EVENT_ERROR_PERMANENT_FAILURE
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface PlaybackEvent {}
+
+ /**
+ * Constant to notify that the playback is buffering
+ */
+ public static final int PLAYBACK_EVENT_BUFFERING = 1;
+
+ /**
+ * Constant to notify that the playback is ready to be played
+ */
+ public static final int PLAYBACK_EVENT_READY = 2;
+
+ /**
+ * Constant to notify that the playback has started
+ */
+ public static final int PLAYBACK_EVENT_STARTED = 3;
+
+ /**
+ * Constant to notify that the playback is paused.
+ */
+ public static final int PLAYBACK_EVENT_PAUSED = 4;
+
+ /**
+ * Constant to notify that the playback event has completed
+ */
+ public static final int PLAYBACK_EVENT_COMPLETED = 5;
+
+ /**
+ * Constant to notify that the playback has failed with a retriable error.
+ */
+ public static final int PLAYBACK_EVENT_ERROR_RETRIABLE_FAILURE = 6;
+
+ /**
+ * Constant to notify that the playback has failed with a permanent error.
+ */
+ public static final int PLAYBACK_EVENT_ERROR_PERMANENT_FAILURE = 7;
+
+ private final ICloudSurfaceEventCallback mCallback;
+
+ SurfaceEventCallback (ICloudSurfaceEventCallback callback) {
+ mCallback = callback;
+ }
+
+ /**
+ * This is called to notify playback event update for a {@link Surface}
+ * on the picker launched via {@link MediaStore#ACTION_PICK_IMAGES}.
+ *
+ * @param surfaceId id which uniquely identifies a {@link Surface}
+ * @param playbackEventType playback event type to notify picker about
+ * @param playbackEventInfo {@link Bundle} which may contain extra information about the
+ * playback event. There is no particular event info that
+ * we are currently expecting. This may change if we want to
+ * support more features for Video Preview like progress/seek
+ * bar or show video playback error messages to the user.
+ */
+ public void onPlaybackEvent(int surfaceId, @PlaybackEvent int playbackEventType,
+ @Nullable Bundle playbackEventInfo) {
+ try {
+ mCallback.onPlaybackEvent(surfaceId, playbackEventType, playbackEventInfo);
+ } catch (Exception e) {
+ Log.d(TAG, "Failed to notify playback event (" + playbackEventType + ") for "
+ + "surfaceId: " + surfaceId + " ; playbackEventInfo: " + playbackEventInfo,
+ e);
+ }
+ }
+ }
+
+ /** {@hide} */
private static class SurfaceControllerWrapper extends ICloudMediaSurfaceController.Stub {
final private SurfaceController mSurfaceController;
@@ -681,6 +812,12 @@
}
@Override
+ public void onConfigChange(@NonNull Bundle config) {
+ Log.i(TAG, "Config changed. Updated config params: " + config);
+ mSurfaceController.onConfigChange(config);
+ }
+
+ @Override
public void onDestroy() {
Log.i(TAG, "Controller destroyed");
mSurfaceController.onDestroy();
diff --git a/apex/framework/java/android/provider/CloudMediaProviderContract.java b/apex/framework/java/android/provider/CloudMediaProviderContract.java
index 95d6845..3e0c3c9 100644
--- a/apex/framework/java/android/provider/CloudMediaProviderContract.java
+++ b/apex/framework/java/android/provider/CloudMediaProviderContract.java
@@ -535,14 +535,39 @@
* <p>
* In case this is not present, the default value should be false.
*
- * @see CloudMediaProvider#onCreateSurfaceController(Bundle)
+ * @see CloudMediaProvider#onCreateSurfaceController
+ * @see CloudMediaProvider.SurfaceController#onConfigChange
* <p>
* Type: BOOLEAN
+ * By default, the value is true
*/
public static final String EXTRA_LOOPING_PLAYBACK_ENABLED =
"android.provider.extra.LOOPING_PLAYBACK_ENABLED";
/**
+ * Indicates whether to mute audio during preview of media items.
+ *
+ * @see CloudMediaProvider#onCreateSurfaceController
+ * @see CloudMediaProvider.SurfaceController#onConfigChange
+ * <p>
+ * Type: BOOLEAN
+ * By default, the value is false
+ */
+ public static final String EXTRA_SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED =
+ "android.provider.extra.SURFACE_CONTROLLER_AUDIO_MUTE_ENABLED";
+
+ /**
+ * Gets surface event callback from picker launched via
+ * {@link MediaStore#ACTION_PICK_IMAGES}).
+ *
+ * @see MediaStore#ACTION_PICK_IMAGES
+ *
+ * {@hide}
+ */
+ public static final String EXTRA_SURFACE_EVENT_CALLBACK =
+ "android.provider.extra.SURFACE_EVENT_CALLBACK";
+
+ /**
* URI path for {@link CloudMediaProvider#onQueryMedia}
*
* {@hide}
diff --git a/apex/framework/java/android/provider/ICloudMediaSurfaceController.aidl b/apex/framework/java/android/provider/ICloudMediaSurfaceController.aidl
index 410d42a..a9f48de 100644
--- a/apex/framework/java/android/provider/ICloudMediaSurfaceController.aidl
+++ b/apex/framework/java/android/provider/ICloudMediaSurfaceController.aidl
@@ -34,5 +34,7 @@
void onMediaPause(int surfaceId);
void onMediaSeekTo(int surfaceId, long timestampMillis);
+ void onConfigChange(in Bundle config);
+
void onDestroy();
}
diff --git a/apex/framework/java/android/provider/ICloudSurfaceEventCallback.aidl b/apex/framework/java/android/provider/ICloudSurfaceEventCallback.aidl
new file mode 100644
index 0000000..369da77
--- /dev/null
+++ b/apex/framework/java/android/provider/ICloudSurfaceEventCallback.aidl
@@ -0,0 +1,27 @@
+/*
+ * 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 android.provider;
+
+import android.os.Bundle;
+
+/**
+ * Interface through which Photo Picker receives playback events from the CloudMediaProviders
+ * @hide
+ */
+interface ICloudSurfaceEventCallback {
+ void onPlaybackEvent(int surfaceId, int eventType, in Bundle eventInfo);
+}
diff --git a/src/com/android/providers/media/photopicker/PhotoPickerActivity.java b/src/com/android/providers/media/photopicker/PhotoPickerActivity.java
index ffbd44c..4386388 100644
--- a/src/com/android/providers/media/photopicker/PhotoPickerActivity.java
+++ b/src/com/android/providers/media/photopicker/PhotoPickerActivity.java
@@ -21,7 +21,10 @@
import android.annotation.IntDef;
import android.app.Activity;
+import android.content.BroadcastReceiver;
import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
import android.content.res.Configuration;
import android.content.res.TypedArray;
import android.graphics.Color;
@@ -32,6 +35,7 @@
import android.os.Binder;
import android.os.Bundle;
import android.os.SystemProperties;
+import android.os.UserHandle;
import android.provider.DeviceConfig;
import android.util.Log;
import android.view.LayoutInflater;
@@ -47,12 +51,15 @@
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
+import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;
import com.android.modules.utils.build.SdkLevel;
import com.android.providers.media.R;
import com.android.providers.media.photopicker.data.Selection;
+import com.android.providers.media.photopicker.data.UserIdManager;
import com.android.providers.media.photopicker.data.model.Category;
+import com.android.providers.media.photopicker.data.model.UserId;
import com.android.providers.media.photopicker.ui.AlbumsTabFragment;
import com.android.providers.media.photopicker.ui.PhotosTabFragment;
import com.android.providers.media.photopicker.ui.PreviewFragment;
@@ -62,9 +69,11 @@
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback;
import com.google.android.material.chip.Chip;
+import com.google.common.collect.Lists;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
+import java.util.List;
/**
* Photo Picker allows users to choose one or more photos and/or videos to share with an app. The
@@ -97,6 +106,7 @@
private View mDragBar;
private View mPrivacyText;
private Toolbar mToolbar;
+ private CrossProfileListeners mCrossProfileListeners;
@TabChipType
private int mSelectedTabChipType;
@@ -157,6 +167,15 @@
// Save the fragment container layout so that we can adjust the padding based on preview or
// non-preview mode.
mFragmentContainerView = findViewById(R.id.fragment_container);
+
+ mCrossProfileListeners = new CrossProfileListeners();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ // This is required to unregister any broadcast receivers.
+ mCrossProfileListeners.onDestroy();
}
/**
@@ -269,14 +288,20 @@
}
restoreBottomSheetState();
} else {
- // This is the first launch, set the default behavior. Hide the title, show the chips
- // and show the PhotosTabFragment
- updateCommonLayouts(MODE_PHOTOS_TAB, /* title */ "");
- onTabChipClick(mPhotosTabChip);
- saveBottomSheetState();
+ setupInitialLaunchState();
}
}
+ /**
+ * Sets up states for the initial launch. This includes updating common layouts, selecting
+ * Photos tab chip and saving the current bottom sheet state for later.
+ */
+ private void setupInitialLaunchState() {
+ updateCommonLayouts(MODE_PHOTOS_TAB, /* title */ "");
+ onTabChipClick(mPhotosTabChip);
+ saveBottomSheetState();
+ }
+
private static Chip generateTabChip(LayoutInflater inflater, ViewGroup parent, String title) {
final Chip chip = (Chip) inflater.inflate(R.layout.picker_chip_tab_header, parent, false);
chip.setText(title);
@@ -570,4 +595,114 @@
final boolean shouldShowPrivacyMessage = mode.isPhotosTabOrAlbumsTab;
mPrivacyText.setVisibility(shouldShowPrivacyMessage ? View.VISIBLE : View.GONE);
}
+
+ private class CrossProfileListeners {
+
+ private final List<String> MANAGED_PROFILE_FILTER_ACTIONS = Lists.newArrayList(
+ Intent.ACTION_MANAGED_PROFILE_ADDED, // add profile button switch
+ Intent.ACTION_MANAGED_PROFILE_REMOVED, // remove profile button switch
+ Intent.ACTION_MANAGED_PROFILE_UNLOCKED, // activate profile button switch
+ Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE // disable profile button switch
+ );
+
+ private final UserIdManager mUserIdManager;
+
+ public CrossProfileListeners() {
+ mUserIdManager = mPickerViewModel.getUserIdManager();
+
+ registerBroadcastReceivers();
+ }
+
+ public void onDestroy() {
+ unregisterReceiver(mReceiver);
+ }
+
+ private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ final String action = intent.getAction();
+
+ final UserHandle userHandle = intent.getParcelableExtra(Intent.EXTRA_USER);
+ final UserId userId = UserId.of(userHandle);
+
+ // We only need to refresh the layout when the received profile user is the
+ // managed user corresponding to the current profile or a new work profile is added
+ // for the current user.
+ if (!userId.equals(mUserIdManager.getManagedUserId()) &&
+ !action.equals(Intent.ACTION_MANAGED_PROFILE_ADDED)) {
+ return;
+ }
+
+ switch (action) {
+ case Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE:
+ handleWorkProfileOff();
+ break;
+ case Intent.ACTION_MANAGED_PROFILE_REMOVED:
+ handleWorkProfileRemoved();
+ break;
+ case Intent.ACTION_MANAGED_PROFILE_UNLOCKED:
+ handleWorkProfileOn();
+ break;
+ case Intent.ACTION_MANAGED_PROFILE_ADDED:
+ handleWorkProfileAdded();
+ break;
+ default:
+ // do nothing
+ }
+ }
+ };
+
+ private void registerBroadcastReceivers() {
+ final IntentFilter managedProfileFilter = new IntentFilter();
+ for (String managedProfileAction : MANAGED_PROFILE_FILTER_ACTIONS) {
+ managedProfileFilter.addAction(managedProfileAction);
+ }
+ registerReceiver(mReceiver, managedProfileFilter);
+ }
+
+ private void handleWorkProfileOff() {
+ if (mUserIdManager.isManagedUserSelected()) {
+ switchToPersonalProfileInitialLaunchState();
+ }
+ mUserIdManager.updateWorkProfileOffValue();
+ }
+
+ private void handleWorkProfileRemoved() {
+ if (mUserIdManager.isManagedUserSelected()) {
+ switchToPersonalProfileInitialLaunchState();
+ }
+ mUserIdManager.resetUserIds();
+ }
+
+ private void handleWorkProfileAdded() {
+ mUserIdManager.resetUserIds();
+ }
+
+ private void handleWorkProfileOn() {
+ // Update UI for switch to profile button
+ // When the managed profile becomes available, the provider may not be available
+ // immediately, we need to check if it is ready before we reload the content.
+ mUserIdManager.waitForMediaProviderToBeAvailable();
+ }
+
+ private void switchToPersonalProfileInitialLaunchState() {
+ // We reset the state of the PhotoPicker as we do not want to make any
+ // assumptions on the state of the PhotoPicker when it was in Work Profile mode.
+ resetToPersonalProfile();
+
+ final FragmentManager fragmentManager = getSupportFragmentManager();
+ // This is important so that doing a back does not take back to work profile fragment
+ // state.
+ fragmentManager.popBackStack();
+ PhotosTabFragment.show(fragmentManager, Category.getDefaultCategory());
+ }
+
+ /**
+ * Reset to Photo Picker initial launch state (Photos grid tab) in personal profile mode.
+ */
+ private void resetToPersonalProfile() {
+ mPickerViewModel.resetToPersonalProfile();
+ setupInitialLaunchState();
+ }
+ }
}
diff --git a/src/com/android/providers/media/photopicker/PhotoPickerProvider.java b/src/com/android/providers/media/photopicker/PhotoPickerProvider.java
index 576ce99..820161b 100644
--- a/src/com/android/providers/media/photopicker/PhotoPickerProvider.java
+++ b/src/com/android/providers/media/photopicker/PhotoPickerProvider.java
@@ -17,6 +17,7 @@
package com.android.providers.media.photopicker;
import static android.provider.CloudMediaProviderContract.EXTRA_LOOPING_PLAYBACK_ENABLED;
+import static android.provider.CloudMediaProvider.SurfaceEventCallback.PLAYBACK_EVENT_READY;
import static android.provider.CloudMediaProviderContract.MediaInfo;
import android.annotation.DurationMillisLong;
@@ -161,11 +162,12 @@
@Override
@Nullable
- public SurfaceController onCreateSurfaceController(@Nullable Bundle extras) {
+ public SurfaceController onCreateSurfaceController(@Nullable Bundle config,
+ SurfaceEventCallback callback) {
if (RemotePreviewHandler.isRemotePreviewEnabled()) {
- boolean enableLoop = extras != null && extras.getBoolean(EXTRA_LOOPING_PLAYBACK_ENABLED,
+ boolean enableLoop = config != null && config.getBoolean(EXTRA_LOOPING_PLAYBACK_ENABLED,
false);
- return new SurfaceControllerImpl(getContext(), enableLoop);
+ return new SurfaceControllerImpl(getContext(), enableLoop, callback);
}
return null;
}
@@ -205,12 +207,14 @@
BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS).build();
private final Context mContext;
+ private final SurfaceEventCallback mCallback;
private final Handler mHandler = new Handler(Looper.getMainLooper());
private final boolean mEnableLoop;
private ExoPlayer mPlayer;
private int mCurrentSurfaceId = -1;
- SurfaceControllerImpl(Context context, boolean enableLoop) {
+ SurfaceControllerImpl(Context context, boolean enableLoop, SurfaceEventCallback callback) {
+ mCallback = callback;
mContext = context;
mEnableLoop = enableLoop;
Log.d(TAG, "Surface controller created.");
@@ -249,10 +253,8 @@
mPlayer.setVideoSurface(surface);
mCurrentSurfaceId = surfaceId;
mPlayer.prepare();
- // TODO(b/215175249): Don't directly start playback here, just send an event
- // instead. It should be up to the photo picker to decide when to start
- // playback.
- mPlayer.setPlayWhenReady(true);
+
+ mCallback.onPlaybackEvent(surfaceId, PLAYBACK_EVENT_READY, null);
Log.d(TAG, "Surface prepared: " + surfaceId + ". Surface: " + surface
+ ". MediaId: " + mediaId);
@@ -313,6 +315,13 @@
}
@Override
+ public void onConfigChange(@NonNull Bundle config) {
+ // TODO(b/195009562): Implement mute/unmute audio and loop enabled/disabled
+ // for video preview
+ Log.d(TAG, "Config changed. Updated config params: " + config);
+ }
+
+ @Override
public void onDestroy() {
Log.d(TAG, "Surface controller destroyed.");
}
diff --git a/src/com/android/providers/media/photopicker/data/ItemsProvider.java b/src/com/android/providers/media/photopicker/data/ItemsProvider.java
index 38f594a..9c41579 100644
--- a/src/com/android/providers/media/photopicker/data/ItemsProvider.java
+++ b/src/com/android/providers/media/photopicker/data/ItemsProvider.java
@@ -220,6 +220,11 @@
final Uri contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL);
try (ContentProviderClient client = userId.getContentResolver(mContext)
.acquireUnstableContentProviderClient(MediaStore.AUTHORITY)) {
+ if (client == null) {
+ Log.e(TAG, "Unable to acquire unstable content provider for "
+ + MediaStore.AUTHORITY);
+ return null;
+ }
Bundle extras = new Bundle();
extras.putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection);
extras.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs);
@@ -254,6 +259,11 @@
final Bundle extras = new Bundle();
try (ContentProviderClient client = userId.getContentResolver(mContext)
.acquireUnstableContentProviderClient(MediaStore.AUTHORITY)) {
+ if (client == null) {
+ Log.e(TAG, "Unable to acquire unstable content provider for "
+ + MediaStore.AUTHORITY);
+ return null;
+ }
extras.putInt(MediaStore.QUERY_ARG_LIMIT, limit);
extras.putString(MediaStore.QUERY_ARG_MIME_TYPE, mimeType);
if (category != null) {
@@ -280,6 +290,11 @@
final Bundle extras = new Bundle();
try (ContentProviderClient client = userId.getContentResolver(mContext)
.acquireUnstableContentProviderClient(MediaStore.AUTHORITY)) {
+ if (client == null) {
+ Log.e(TAG, "Unable to acquire unstable content provider for "
+ + MediaStore.AUTHORITY);
+ return null;
+ }
extras.putString(MediaStore.QUERY_ARG_MIME_TYPE, mimeType);
final Uri uri = PickerUriResolver.PICKER_INTERNAL_URI.buildUpon()
diff --git a/src/com/android/providers/media/photopicker/data/Selection.java b/src/com/android/providers/media/photopicker/data/Selection.java
index 7d7883c..79692ba 100644
--- a/src/com/android/providers/media/photopicker/data/Selection.java
+++ b/src/com/android/providers/media/photopicker/data/Selection.java
@@ -95,6 +95,15 @@
}
/**
+ * Clear all selected items
+ */
+ public void clearSelectedItems() {
+ mSelectedItems.clear();
+ mSelectedItemSize.postValue(mSelectedItems.size());
+ updateSelectionAllowed();
+ }
+
+ /**
* @return {@code true} if give {@code item} is present in selected items
* {@link #mSelectedItems}, {@code false} otherwise
*/
diff --git a/src/com/android/providers/media/photopicker/data/UserIdManager.java b/src/com/android/providers/media/photopicker/data/UserIdManager.java
index 1f265de..07d8c5c 100644
--- a/src/com/android/providers/media/photopicker/data/UserIdManager.java
+++ b/src/com/android/providers/media/photopicker/data/UserIdManager.java
@@ -18,18 +18,19 @@
import static androidx.core.util.Preconditions.checkNotNull;
+import android.annotation.NonNull;
import android.annotation.Nullable;
-import android.annotation.WorkerThread;
-import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
-import android.content.IntentFilter;
import android.content.pm.PackageManager;
+import android.os.Handler;
+import android.os.Looper;
import android.os.UserHandle;
import android.os.UserManager;
import android.util.Log;
-import com.android.internal.annotations.GuardedBy;
+import androidx.lifecycle.MutableLiveData;
+
import com.android.internal.annotations.VisibleForTesting;
import com.android.providers.media.photopicker.data.model.UserId;
import com.android.providers.media.photopicker.util.CrossProfileUtils;
@@ -122,10 +123,32 @@
void setIntentAndCheckRestrictions(Intent intent);
/**
- * Updates cross profile restrictions values
+ * Waits for Media Provider of the work profile to be available.
*/
- @WorkerThread
- void updateCrossProfileValues();
+ void waitForMediaProviderToBeAvailable();
+
+ /**
+ * Checks if work profile is switched off and updates the data.
+ */
+ void updateWorkProfileOffValue();
+
+ /**
+ * Resets the user ids. This is usually called as a result of receiving broadcast that
+ * managed profile has been added or removed.
+ */
+ void resetUserIds();
+
+ /**
+ * @return {@link MutableLiveData} to check if cross profile interaction allowed or not
+ */
+ @NonNull
+ MutableLiveData<Boolean> getCrossProfileAllowed();
+
+ /**
+ * @return {@link MutableLiveData} to check if there are multiple user profiles or not
+ */
+ @NonNull
+ MutableLiveData<Boolean> getIsMultiUserProfiles();
/**
* Creates an implementation of {@link UserIdManager}.
@@ -135,41 +158,34 @@
}
/**
- * Implementation of {@link UserIdManager}.
+ * Implementation of {@link UserIdManager}. The class assumes that all its public methods are
+ * called from main thread only.
*/
final class RuntimeUserIdManager implements UserIdManager {
private static final String TAG = "UserIdManager";
+ // These values are copied from DocumentsUI
+ private static final int PROVIDER_AVAILABILITY_MAX_RETRIES = 10;
+ private static final long PROVIDER_AVAILABILITY_CHECK_DELAY = 4000;
+
private final Context mContext;
private final UserId mCurrentUser;
+ private final Handler mHandler;
- @GuardedBy("mLock")
- private final Object mLock = new Object();
- @GuardedBy("mLock")
+ private Runnable mIsProviderAvailableRunnable;
+
private UserId mPersonalUser = null;
- @GuardedBy("mLock")
private UserId mManagedUser = null;
- @GuardedBy("mLock")
private UserId mCurrentUserProfile = null;
- private Intent mIntent = null;
// Set default values to negative case, only set as false if checks pass.
private boolean mIsBlockedByAdmin = true;
private boolean mIsWorkProfileOff = true;
- private final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() {
-
- @Override
- public void onReceive(Context context, Intent intent) {
- synchronized (mLock) {
- mPersonalUser = null;
- mManagedUser = null;
- setUserIds();
- }
- }
- };
+ private final MutableLiveData<Boolean> mIsMultiUserProfiles = new MutableLiveData<>();
+ private final MutableLiveData<Boolean> mIsCrossProfileAllowed = new MutableLiveData<>();
private RuntimeUserIdManager(Context context) {
this(context, UserId.CURRENT_USER);
@@ -180,94 +196,104 @@
mContext = context.getApplicationContext();
mCurrentUser = checkNotNull(currentUser);
mCurrentUserProfile = mCurrentUser;
+ mHandler = new Handler(Looper.getMainLooper());
setUserIds();
+ }
- IntentFilter filter = new IntentFilter();
- filter.addAction(Intent.ACTION_MANAGED_PROFILE_ADDED);
- filter.addAction(Intent.ACTION_MANAGED_PROFILE_REMOVED);
- mContext.registerReceiver(mIntentReceiver, filter);
+ @Override
+ public MutableLiveData<Boolean> getCrossProfileAllowed() {
+ return mIsCrossProfileAllowed;
+ }
+
+ @Override
+ public MutableLiveData<Boolean> getIsMultiUserProfiles() {
+ return mIsMultiUserProfiles;
+ }
+
+ @Override
+ public void resetUserIds() {
+ assertMainThread();
+ setUserIds();
}
@Override
public boolean isMultiUserProfiles() {
- synchronized (mLock) {
- return mPersonalUser != null;
- }
+ assertMainThread();
+ return mPersonalUser != null;
}
@Override
public UserId getPersonalUserId() {
- synchronized (mLock) {
- return mPersonalUser;
- }
+ assertMainThread();
+ return mPersonalUser;
}
@Override
public UserId getManagedUserId() {
- synchronized (mLock) {
- return mManagedUser;
- }
+ assertMainThread();
+ return mManagedUser;
}
@Override
public UserId getCurrentUserProfileId() {
- synchronized (mLock) {
- return mCurrentUserProfile;
- }
+ assertMainThread();
+ return mCurrentUserProfile;
}
@Override
public void setManagedAsCurrentUserProfile() {
+ assertMainThread();
setCurrentUserProfileId(getManagedUserId());
}
@Override
public void setPersonalAsCurrentUserProfile() {
+ assertMainThread();
setCurrentUserProfileId(getPersonalUserId());
}
@Override
public void setIntentAndCheckRestrictions(Intent intent) {
- mIntent = intent;
- updateCrossProfileValues();
+ assertMainThread();
+ if (isMultiUserProfiles()) {
+ updateCrossProfileValues(intent);
+ }
}
public boolean isCurrentUserSelected() {
- synchronized (mLock) {
- return mCurrentUserProfile.equals(UserId.CURRENT_USER);
- }
+ assertMainThread();
+ return mCurrentUserProfile.equals(UserId.CURRENT_USER);
}
public boolean isManagedUserSelected() {
- synchronized (mLock) {
- return mCurrentUserProfile.equals(getManagedUserId());
- }
+ assertMainThread();
+ return mCurrentUserProfile.equals(getManagedUserId());
}
@Override
public boolean isPersonalUserId() {
+ assertMainThread();
return mCurrentUser.equals(getPersonalUserId());
}
@Override
public boolean isManagedUserId() {
+ assertMainThread();
return mCurrentUser.equals(getManagedUserId());
}
- private void setUserIds() {
- synchronized (mLock) {
- setUserIdsInternalLocked();
- }
- }
-
private void setCurrentUserProfileId(UserId userId) {
- synchronized (mLock) {
- mCurrentUserProfile = userId;
- }
+ mCurrentUserProfile = userId;
}
- @GuardedBy("mLock")
- private void setUserIdsInternalLocked() {
+ private void setUserIds() {
+ setUserIdsInternal();
+ mIsMultiUserProfiles.postValue(isMultiUserProfiles());
+ }
+
+ private void setUserIdsInternal() {
+ mPersonalUser = null;
+ mManagedUser = null;
UserManager userManager = mContext.getSystemService(UserManager.class);
if (userManager == null) {
Log.e(TAG, "Cannot obtain user manager");
@@ -308,49 +334,126 @@
@Override
public boolean isCrossProfileAllowed() {
+ assertMainThread();
return (!isWorkProfileOff() && !isBlockedByAdmin());
}
@Override
public boolean isWorkProfileOff() {
+ assertMainThread();
return mIsWorkProfileOff;
}
@Override
public boolean isBlockedByAdmin() {
+ assertMainThread();
return mIsBlockedByAdmin;
}
@Override
- @WorkerThread
- public void updateCrossProfileValues() {
- setCrossProfileValues();
+ public void updateWorkProfileOffValue() {
+ assertMainThread();
+ mIsWorkProfileOff = isWorkProfileOffInternal(getManagedUserId());
+ mIsCrossProfileAllowed.postValue(isCrossProfileAllowed());
}
- @WorkerThread
- private void setCrossProfileValues() {
- final PackageManager packageManager = mContext.getPackageManager();
+ @Override
+ public void waitForMediaProviderToBeAvailable() {
+ assertMainThread();
+ final UserId managedUserProfileId = getManagedUserId();
+ if (CrossProfileUtils.isMediaProviderAvailable(managedUserProfileId, mContext)) {
+ mIsWorkProfileOff = false;
+ mIsCrossProfileAllowed.postValue(isCrossProfileAllowed());
+ stopWaitingForProviderToBeAvailable();
+ return;
+ }
+ waitForProviderToBeAvailable(managedUserProfileId, /* numOfTries */ 1);
+ }
+
+ private void updateCrossProfileValues(Intent intent) {
+ setCrossProfileValues(intent);
+ mIsCrossProfileAllowed.postValue(isCrossProfileAllowed());
+ }
+
+ private void setCrossProfileValues(Intent intent) {
// 1. Check if PICK_IMAGES intent is allowed by admin to show cross user content
- if (mIntent == null) {
+ setBlockedByAdminValue(intent);
+
+ // 2. Check if work profile is off
+ updateWorkProfileOffValue();
+
+ // 3. For first initial setup, wait for MediaProvider to be on.
+ // (This is not blocking)
+ if (mIsWorkProfileOff) {
+ waitForMediaProviderToBeAvailable();
+ }
+ }
+
+ private void setBlockedByAdminValue(Intent intent) {
+ if (intent == null) {
Log.e(TAG, "No intent specified to check if cross profile forwarding is"
+ " allowed.");
return;
}
- if (!CrossProfileUtils.isIntentAllowedCrossProfileAccess(mIntent, packageManager)) {
+ final PackageManager packageManager = mContext.getPackageManager();
+ if (!CrossProfileUtils.isIntentAllowedCrossProfileAccess(intent, packageManager)) {
mIsBlockedByAdmin = true;
return;
}
mIsBlockedByAdmin = false;
+ }
- // 2. Check if work profile is off
- if (!isManagedUserSelected()) {
- final UserId managedUserProfileId = getManagedUserId();
- if (!CrossProfileUtils.isMediaProviderAvailable(managedUserProfileId, mContext)) {
- mIsWorkProfileOff = true;
+ private boolean isWorkProfileOffInternal(UserId managedUserProfileId) {
+ return CrossProfileUtils.isQuietModeEnabled(managedUserProfileId, mContext) ||
+ !CrossProfileUtils.isMediaProviderAvailable(managedUserProfileId, mContext);
+ }
+
+ private void waitForProviderToBeAvailable(UserId userId, int numOfTries) {
+ // The runnable should make sure to post update on the live data if it is changed.
+ mIsProviderAvailableRunnable = () -> {
+ // We stop the recursive check when
+ // 1. the provider is available
+ // 2. the profile is in quiet mode, i.e. provider will not be available
+ // 3. after maximum retries
+ if (CrossProfileUtils.isMediaProviderAvailable(userId, mContext)) {
+ mIsWorkProfileOff = false;
+ mIsCrossProfileAllowed.postValue(isCrossProfileAllowed());
return;
}
+
+ if (CrossProfileUtils.isQuietModeEnabled(userId, mContext)) {
+ return;
+ }
+
+ if (numOfTries <= PROVIDER_AVAILABILITY_MAX_RETRIES) {
+ Log.d(TAG, "MediaProvider is not available. Retry after " +
+ PROVIDER_AVAILABILITY_CHECK_DELAY);
+ waitForProviderToBeAvailable(userId, numOfTries + 1);
+ return;
+ }
+
+ Log.w(TAG, "Failed waiting for MediaProvider for user:" + userId +
+ " to be available");
+ };
+
+ mHandler.postDelayed(mIsProviderAvailableRunnable, PROVIDER_AVAILABILITY_CHECK_DELAY);
+ }
+
+ private void stopWaitingForProviderToBeAvailable() {
+ if (mIsProviderAvailableRunnable == null) {
+ return;
}
- mIsWorkProfileOff = false;
+ mHandler.removeCallbacks(mIsProviderAvailableRunnable);
+ mIsProviderAvailableRunnable = null;
+ }
+
+ private void assertMainThread() {
+ if (Looper.getMainLooper().isCurrentThread()) return;
+
+ throw new IllegalStateException("UserIdManager methods are expected to be called from "
+ + "main thread. " + (Looper.myLooper() == null ? "" : "Current thread "
+ + Looper.myLooper().getThread() + ", Main thread "
+ + Looper.getMainLooper().getThread()));
}
}
}
diff --git a/src/com/android/providers/media/photopicker/ui/TabFragment.java b/src/com/android/providers/media/photopicker/ui/TabFragment.java
index c5626ab..ddc36c5 100644
--- a/src/com/android/providers/media/photopicker/ui/TabFragment.java
+++ b/src/com/android/providers/media/photopicker/ui/TabFragment.java
@@ -30,6 +30,8 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.RecyclerView;
@@ -38,7 +40,6 @@
import com.android.providers.media.photopicker.data.Selection;
import com.android.providers.media.photopicker.data.UserIdManager;
import com.android.providers.media.photopicker.viewmodel.PickerViewModel;
-import com.android.providers.media.util.ForegroundThread;
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
@@ -49,6 +50,7 @@
* The base abstract Tab fragment
*/
public abstract class TabFragment extends Fragment {
+
protected PickerViewModel mPickerViewModel;
protected Selection mSelection;
protected ImageLoader mImageLoader;
@@ -141,6 +143,25 @@
});
}
+ // Initial setup
+ setUpProfileButtonWithListeners(mUserIdManager.isMultiUserProfiles());
+
+ // Observe for cross profile access changes.
+ final LiveData<Boolean> crossProfileAllowed = mUserIdManager.getCrossProfileAllowed();
+ if (crossProfileAllowed != null) {
+ crossProfileAllowed.observe(this, isCrossProfileAllowed -> {
+ setUpProfileButton();
+ });
+ }
+
+ // Observe for multi-user changes.
+ final LiveData<Boolean> isMultiUserProfiles = mUserIdManager.getIsMultiUserProfiles();
+ if (isMultiUserProfiles != null) {
+ isMultiUserProfiles.observe(this, this::setUpProfileButtonWithListeners);
+ }
+ }
+
+ private void setUpListenersForProfileButton() {
mProfileButton.setOnClickListener(v -> onClickProfileButton());
mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
@@ -156,12 +177,6 @@
}
@Override
- public void onResume() {
- super.onResume();
- updateProfileButtonAsync();
- }
-
- @Override
public void onDestroy() {
super.onDestroy();
if (mRecyclerView != null) {
@@ -169,12 +184,13 @@
}
}
- private void updateProfileButtonAsync() {
- ForegroundThread.getExecutor().execute(() -> {
- mUserIdManager.updateCrossProfileValues();
-
- getActivity().runOnUiThread(() -> setUpProfileButton());
- });
+ private void setUpProfileButtonWithListeners(boolean isMultiUserProfile) {
+ if (isMultiUserProfile) {
+ setUpListenersForProfileButton();
+ } else {
+ mRecyclerView.clearOnScrollListeners();
+ }
+ setUpProfileButton();
}
private void setUpProfileButton() {
diff --git a/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewHandler.java b/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewHandler.java
index b03d9d4..1a4010f 100644
--- a/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewHandler.java
+++ b/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewHandler.java
@@ -18,7 +18,9 @@
import static android.provider.CloudMediaProviderContract.EXTRA_LOOPING_PLAYBACK_ENABLED;
import static android.provider.CloudMediaProviderContract.EXTRA_SURFACE_CONTROLLER;
+import static android.provider.CloudMediaProviderContract.EXTRA_SURFACE_EVENT_CALLBACK;
import static android.provider.CloudMediaProviderContract.METHOD_CREATE_SURFACE_CONTROLLER;
+import static android.provider.CloudMediaProvider.SurfaceEventCallback.PLAYBACK_EVENT_READY;
import static com.android.providers.media.PickerUriResolver.createSurfaceControllerUri;
@@ -29,6 +31,8 @@
import android.os.RemoteException;
import android.os.SystemProperties;
import android.provider.ICloudMediaSurfaceController;
+
+import android.provider.ICloudSurfaceEventCallback;
import android.util.ArrayMap;
import android.util.Log;
import android.view.Surface;
@@ -56,6 +60,7 @@
private final Map<String, SurfaceControllerProxy> mControllers =
new ArrayMap<>();
private final SurfaceHolder.Callback mSurfaceHolderCallback = new PreviewSurfaceCallback();
+ private final SurfaceEventCallbackWrapper mSurfaceEventCallbackWrapper;
private Item mCurrentPreviewItem;
private boolean mIsInBackground = false;
@@ -67,6 +72,7 @@
public RemotePreviewHandler(Context context) {
mContext = context;
+ mSurfaceEventCallbackWrapper = new SurfaceEventCallbackWrapper();
}
/**
@@ -164,6 +170,15 @@
return null;
}
+ private RemotePreviewSession getSessionForSurfaceId(int surfaceId) {
+ for (RemotePreviewSession session : mSessionMap.values()) {
+ if (session.getSurfaceId() == surfaceId) {
+ return session;
+ }
+ }
+ return null;
+ }
+
@Nullable
private SurfaceControllerProxy getSurfaceController(String authority) {
if (mControllers.containsKey(authority)) {
@@ -197,6 +212,7 @@
Log.i(TAG, "Creating new SurfaceController for authority: " + authority);
Bundle extras = new Bundle();
extras.putBoolean(EXTRA_LOOPING_PLAYBACK_ENABLED, true);
+ extras.putBinder(EXTRA_SURFACE_EVENT_CALLBACK, mSurfaceEventCallbackWrapper);
final Bundle surfaceControllerBundle = mContext.getContentResolver().call(
createSurfaceControllerUri(authority),
METHOD_CREATE_SURFACE_CONTROLLER, /* arg */ null, extras);
@@ -206,6 +222,32 @@
: null;
}
+ /**
+ * Wrapper class for {@link ICloudSurfaceEventCallback} interface implementation.
+ */
+ private final class SurfaceEventCallbackWrapper extends ICloudSurfaceEventCallback.Stub {
+
+ @Override
+ public void onPlaybackEvent(int surfaceId, int eventType, Bundle eventInfo) {
+ final RemotePreviewSession session = getSessionForSurfaceId(surfaceId);
+
+ if (session == null) {
+ Log.w(TAG, "No RemotePreviewSession found.");
+ return;
+ }
+ switch (eventType) {
+ case PLAYBACK_EVENT_READY:
+ session.playMedia();
+ return;
+ default:
+ Log.d(TAG, "RemotePreviewHandler onPlaybackEvent for surfaceId: " +
+ surfaceId + " ; media id: " + session.getMediaId() +
+ " ; eventType: " + eventType + " ; eventInfo: " + eventInfo);
+
+ }
+ }
+ }
+
private final class PreviewSurfaceCallback implements SurfaceHolder.Callback {
@Override
diff --git a/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewSession.java b/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewSession.java
index 3f8fc4d..35b421b 100644
--- a/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewSession.java
+++ b/src/com/android/providers/media/photopicker/ui/remotepreview/RemotePreviewSession.java
@@ -44,6 +44,10 @@
this.mSurfaceController = surfaceController;
}
+ int getSurfaceId() {
+ return mSurfaceId;
+ }
+
@NonNull
String getMediaId() {
return mMediaId;
diff --git a/src/com/android/providers/media/photopicker/util/CrossProfileUtils.java b/src/com/android/providers/media/photopicker/util/CrossProfileUtils.java
index a9b345c..301b623 100644
--- a/src/com/android/providers/media/photopicker/util/CrossProfileUtils.java
+++ b/src/com/android/providers/media/photopicker/util/CrossProfileUtils.java
@@ -21,6 +21,7 @@
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
+import android.os.UserManager;
import android.provider.MediaStore;
import android.util.Log;
@@ -73,4 +74,16 @@
}
return false;
}
+
+ /**
+ * Whether the given profile is in quiet mode or not.
+ * Notes: Quiet mode is only supported for managed profiles.
+ *
+ * @param userId The user id of the profile to be queried.
+ * @return true if the profile is in quiet mode, false otherwise.
+ */
+ public static boolean isQuietModeEnabled(UserId userId, Context context) {
+ final UserManager userManager = context.getSystemService(UserManager.class);
+ return userManager.isQuietModeEnabled(userId.getUserHandle());
+ }
}
diff --git a/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java b/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java
index 3b80a1d..b5f708a 100644
--- a/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java
+++ b/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java
@@ -115,6 +115,19 @@
}
/**
+ * Reset to personal profile mode.
+ */
+ public void resetToPersonalProfile() {
+ // 1. Clear Selected items
+ mSelection.clearSelectedItems();
+ // 2. Change profile to personal user
+ mUserIdManager.setPersonalAsCurrentUserProfile();
+ // 3. Update Item and Category lists
+ updateItems();
+ updateCategories();
+ }
+
+ /**
* @return the list of Items with all photos and videos {@link #mItemList} on the device.
*/
public LiveData<List<Item>> getItems() {
@@ -124,9 +137,8 @@
return mItemList;
}
- private List<Item> loadItems(@Nullable @CategoryType String category) {
+ private List<Item> loadItems(@Nullable @CategoryType String category, UserId userId) {
final List<Item> items = new ArrayList<>();
- final UserId userId = mUserIdManager.getCurrentUserProfileId();
try (Cursor cursor = mItemsProvider.getItems(category, /* offset */ 0,
/* limit */ -1, mMimeTypeFilter, userId)) {
@@ -179,8 +191,9 @@
}
private void loadItemsAsync() {
+ final UserId userId = mUserIdManager.getCurrentUserProfileId();
ForegroundThread.getExecutor().execute(() -> {
- mItemList.postValue(loadItems(/* category= */ null));
+ mItemList.postValue(loadItems(/* category= */ null, userId));
});
}
@@ -207,8 +220,9 @@
}
private void loadCategoryItemsAsync(@NonNull @CategoryType String category) {
+ final UserId userId = mUserIdManager.getCurrentUserProfileId();
ForegroundThread.getExecutor().execute(() -> {
- mCategoryItemList.postValue(loadItems(category));
+ mCategoryItemList.postValue(loadItems(category, userId));
});
}
@@ -232,9 +246,8 @@
return mCategoryList;
}
- private List<Category> loadCategories() {
+ private List<Category> loadCategories(UserId userId) {
final List<Category> categoryList = new ArrayList<>();
- final UserId userId = mUserIdManager.getCurrentUserProfileId();
try (final Cursor cursor = mItemsProvider.getCategories(mMimeTypeFilter, userId)) {
if (cursor == null || cursor.getCount() == 0) {
Log.d(TAG, "Didn't receive any categories, either cursor is null or"
@@ -254,8 +267,9 @@
}
private void loadCategoriesAsync() {
+ final UserId userId = mUserIdManager.getCurrentUserProfileId();
ForegroundThread.getExecutor().execute(() -> {
- mCategoryList.postValue(loadCategories());
+ mCategoryList.postValue(loadCategories(userId));
});
}
diff --git a/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java b/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java
index 0ccbafe..49dfaf6 100644
--- a/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java
+++ b/tests/src/com/android/providers/media/photopicker/ItemsProviderTest.java
@@ -391,12 +391,12 @@
public void testGetItems_sortOrder() throws Exception {
try {
final long timeNow = System.nanoTime() / 1000;
+ final Uri imageFileDateNowPlus1Uri = prepareFileAndGetUri(
+ new File(getDownloadsDir(), "latest_" + IMAGE_FILE_NAME), timeNow + 1000);
final Uri imageFileDateNowUri
- = createFileAndGet(getDcimDir(), IMAGE_FILE_NAME, timeNow);
+ = prepareFileAndGetUri(new File(getDcimDir(), IMAGE_FILE_NAME), timeNow);
final Uri videoFileDateNowUri
- = createFileAndGet(getCameraDir(), VIDEO_FILE_NAME, timeNow);
- final Uri imageFileDateNowPlus1Uri = createFileAndGet(getDownloadsDir(),
- "latest_" + IMAGE_FILE_NAME, timeNow + 1000);
+ = prepareFileAndGetUri(new File(getCameraDir(), VIDEO_FILE_NAME), timeNow);
// This is the list of uris based on the expected sort order of items returned by
// ItemsProvider#getItems
@@ -747,19 +747,16 @@
return assertCreateNewFile(getDownloadsDir(), IMAGE_FILE_NAME);
}
- private File assertCreateNewFile(File dir, String fileName) throws Exception {
- if (!dir.exists()) {
- dir.mkdirs();
- }
- assertThat(dir.exists()).isTrue();
-
- final File file = new File(dir, fileName);
- createAndPrepareFile(file);
+ private File assertCreateNewFile(File parentDir, String fileName) throws Exception {
+ final File file = new File(parentDir, fileName);
+ prepareFileAndGetUri(file, /* lastModifiedTime */ -1);
return file;
}
- private Uri createAndPrepareFile(File file) throws IOException {
+ private Uri prepareFileAndGetUri(File file, long lastModifiedTime) throws IOException {
+ ensureParentExists(file.getParentFile());
+
assertThat(file.createNewFile()).isTrue();
// Write 1 byte because 0byte files are not valid in the picker db
@@ -767,12 +764,26 @@
fos.write(1);
}
+ if (lastModifiedTime != -1) {
+ file.setLastModified(lastModifiedTime);
+ }
+
final Uri uri = MediaStore.scanFile(mIsolatedResolver, file);
+ assertWithMessage("Uri obtained by scanning file " + file)
+ .that(uri).isNotNull();
+ // Wait for picker db sync
MediaStore.waitForIdle(mIsolatedResolver);
return uri;
}
+ private void ensureParentExists(File parent) {
+ if (!parent.exists()) {
+ parent.mkdirs();
+ }
+ assertThat(parent.exists()).isTrue();
+ }
+
private File getDownloadsDir() {
return new File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOWNLOADS);
}
@@ -825,14 +836,4 @@
}
}
-
- private Uri createFileAndGet(File parent, String fileName, long lastModifiedTime)
- throws IOException {
- final File file = new File(parent, fileName);
- final Uri uri = createAndPrepareFile(file);
-
- assertWithMessage("Uri obtained by scanning file " + file)
- .that(uri).isNotNull();
- return uri;
- }
}
diff --git a/tests/src/com/android/providers/media/photopicker/data/SelectionTest.java b/tests/src/com/android/providers/media/photopicker/data/SelectionTest.java
index 4a7eaa1..ec68646 100644
--- a/tests/src/com/android/providers/media/photopicker/data/SelectionTest.java
+++ b/tests/src/com/android/providers/media/photopicker/data/SelectionTest.java
@@ -57,7 +57,9 @@
final Context context = InstrumentationRegistry.getTargetContext();
when(mApplication.getApplicationContext()).thenReturn(context);
- mSelection = new PickerViewModel(mApplication).getSelection();
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ mSelection = new PickerViewModel(mApplication).getSelection();
+ });
}
@Test
@@ -90,6 +92,23 @@
}
@Test
+ public void testClearSelectedItem() {
+ final String id = "1";
+ final Item item = generateFakeImageItem(id);
+ final String id2 = "2";
+ final Item item2 = generateFakeImageItem(id2);
+
+ assertThat(mSelection.getSelectedItemCount().getValue()).isEqualTo(0);
+
+ mSelection.addSelectedItem(item);
+ mSelection.addSelectedItem(item2);
+ assertThat(mSelection.getSelectedItemCount().getValue()).isEqualTo(2);
+
+ mSelection.clearSelectedItems();
+ assertThat(mSelection.getSelectedItemCount().getValue()).isEqualTo(0);
+ }
+
+ @Test
public void testSetSelectedItem() {
final String id1 = "1";
final Item item1 = generateFakeImageItem(id1);
diff --git a/tests/src/com/android/providers/media/photopicker/data/UserIdManagerTest.java b/tests/src/com/android/providers/media/photopicker/data/UserIdManagerTest.java
index 93d3cc8..83f8fab 100644
--- a/tests/src/com/android/providers/media/photopicker/data/UserIdManagerTest.java
+++ b/tests/src/com/android/providers/media/photopicker/data/UserIdManagerTest.java
@@ -18,6 +18,7 @@
import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -26,6 +27,8 @@
import android.os.UserHandle;
import android.os.UserManager;
+import androidx.test.platform.app.InstrumentationRegistry;
+
import com.android.providers.media.photopicker.data.model.UserId;
import org.junit.Before;
@@ -60,19 +63,34 @@
when(mockContext.getSystemService(UserManager.class)).thenReturn(mockUserManager);
}
+ @Test
+ public void testUserIdManagerThrowsErrorIfCalledFromNonMainThread() {
+ UserId currentUser = UserId.of(personalUser);
+ initializeUserIdManager(currentUser, Arrays.asList(personalUser));
+
+ assertThrows(IllegalStateException.class, () -> userIdManager.isMultiUserProfiles());
+ assertThrows(IllegalStateException.class, () -> userIdManager.getCurrentUserProfileId());
+ assertThrows(IllegalStateException.class, () -> userIdManager.isPersonalUserId());
+ assertThrows(IllegalStateException.class, () -> userIdManager.isManagedUserId());
+ assertThrows(IllegalStateException.class, () -> userIdManager.getPersonalUserId());
+ assertThrows(IllegalStateException.class, () -> userIdManager.getManagedUserId());
+ }
+
// common cases for User Profiles
@Test
public void testUserIds_personaUser_currentUserIsPersonalUser() {
// Returns the current user if there is only 1 user.
UserId currentUser = UserId.of(personalUser);
initializeUserIdManager(currentUser, Arrays.asList(personalUser));
- assertThat(userIdManager.isMultiUserProfiles()).isFalse();
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ assertThat(userIdManager.isMultiUserProfiles()).isFalse();
- assertThat(userIdManager.isPersonalUserId()).isFalse();
- assertThat(userIdManager.isManagedUserId()).isFalse();
+ assertThat(userIdManager.isPersonalUserId()).isFalse();
+ assertThat(userIdManager.isManagedUserId()).isFalse();
- assertThat(userIdManager.getPersonalUserId()).isNull();
- assertThat(userIdManager.getManagedUserId()).isNull();
+ assertThat(userIdManager.getPersonalUserId()).isNull();
+ assertThat(userIdManager.getManagedUserId()).isNull();
+ });
}
@Test
@@ -80,13 +98,15 @@
// Returns both if there are personal and managed users.
UserId currentUser = UserId.of(personalUser);
initializeUserIdManager(currentUser, Arrays.asList(personalUser, managedUser1));
- assertThat(userIdManager.isMultiUserProfiles()).isTrue();
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ assertThat(userIdManager.isMultiUserProfiles()).isTrue();
- assertThat(userIdManager.isPersonalUserId()).isTrue();
- assertThat(userIdManager.isManagedUserId()).isFalse();
+ assertThat(userIdManager.isPersonalUserId()).isTrue();
+ assertThat(userIdManager.isManagedUserId()).isFalse();
- assertThat(userIdManager.getPersonalUserId()).isEqualTo(currentUser);
- assertThat(userIdManager.getManagedUserId()).isEqualTo(UserId.of(managedUser1));
+ assertThat(userIdManager.getPersonalUserId()).isEqualTo(currentUser);
+ assertThat(userIdManager.getManagedUserId()).isEqualTo(UserId.of(managedUser1));
+ });
}
@Test
@@ -94,13 +114,15 @@
// Returns both if there are system and managed users.
UserId currentUser = UserId.of(managedUser1);
initializeUserIdManager(currentUser, Arrays.asList(personalUser, managedUser1));
- assertThat(userIdManager.isMultiUserProfiles()).isTrue();
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ assertThat(userIdManager.isMultiUserProfiles()).isTrue();
- assertThat(userIdManager.isPersonalUserId()).isFalse();
- assertThat(userIdManager.isManagedUserId()).isTrue();
+ assertThat(userIdManager.isPersonalUserId()).isFalse();
+ assertThat(userIdManager.isManagedUserId()).isTrue();
- assertThat(userIdManager.getPersonalUserId()).isEqualTo(UserId.of(personalUser));
- assertThat(userIdManager.getManagedUserId()).isEqualTo(currentUser);
+ assertThat(userIdManager.getPersonalUserId()).isEqualTo(UserId.of(personalUser));
+ assertThat(userIdManager.getManagedUserId()).isEqualTo(currentUser);
+ });
}
// other cases for User Profiles involving different users
@@ -109,28 +131,31 @@
// When there is no managed user, returns the current user.
UserId currentUser = UserId.of(otherUser2);
initializeUserIdManager(currentUser, Arrays.asList(otherUser1, otherUser2));
- assertThat(userIdManager.isMultiUserProfiles()).isFalse();
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ assertThat(userIdManager.isMultiUserProfiles()).isFalse();
- assertThat(userIdManager.isPersonalUserId()).isFalse();
- assertThat(userIdManager.isManagedUserId()).isFalse();
+ assertThat(userIdManager.isPersonalUserId()).isFalse();
+ assertThat(userIdManager.isManagedUserId()).isFalse();
- assertThat(userIdManager.getPersonalUserId()).isNull();
- assertThat(userIdManager.getManagedUserId()).isNull();
+ assertThat(userIdManager.getPersonalUserId()).isNull();
+ assertThat(userIdManager.getManagedUserId()).isNull();
+ });
}
@Test
- public void testUserIds_otherUserAndManagedUserAndPersonalUser_currentUserIsOtherUser(
- ) {
+ public void testUserIds_otherUserAndManagedUserAndPersonalUser_currentUserIsOtherUser() {
UserId currentUser = UserId.of(otherUser1);
initializeUserIdManager(currentUser, Arrays.asList(otherUser1, managedUser1,
personalUser));
- assertThat(userIdManager.isMultiUserProfiles()).isFalse();
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ assertThat(userIdManager.isMultiUserProfiles()).isFalse();
- assertThat(userIdManager.isPersonalUserId()).isFalse();
- assertThat(userIdManager.isManagedUserId()).isFalse();
+ assertThat(userIdManager.isPersonalUserId()).isFalse();
+ assertThat(userIdManager.isManagedUserId()).isFalse();
- assertThat(userIdManager.getPersonalUserId()).isNull();
- assertThat(userIdManager.getManagedUserId()).isNull();
+ assertThat(userIdManager.getPersonalUserId()).isNull();
+ assertThat(userIdManager.getManagedUserId()).isNull();
+ });
}
@Test
@@ -141,27 +166,33 @@
UserId currentUser = UserId.of(managedUser1);
initializeUserIdManager(currentUser, Arrays.asList(otherUser1, managedUser1,
personalUser));
- assertThat(userIdManager.isMultiUserProfiles()).isTrue();
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ assertThat(userIdManager.isMultiUserProfiles()).isTrue();
- assertThat(userIdManager.isPersonalUserId()).isFalse();
- assertThat(userIdManager.isManagedUserId()).isTrue();
+ assertThat(userIdManager.isPersonalUserId()).isFalse();
+ assertThat(userIdManager.isManagedUserId()).isTrue();
- assertThat(userIdManager.getPersonalUserId()).isEqualTo(UserId.of(personalUser));
- assertThat(userIdManager.getManagedUserId()).isEqualTo(currentUser);
+ assertThat(userIdManager.getPersonalUserId()).isEqualTo(UserId.of(personalUser));
+ assertThat(userIdManager.getManagedUserId()).isEqualTo(currentUser);
+ });
}
@Test
public void testUserIds_personalUserAndManagedUser_returnCachedList() {
UserId currentUser = UserId.of(personalUser);
initializeUserIdManager(currentUser, Arrays.asList(personalUser, managedUser1));
- assertThat(userIdManager.getPersonalUserId()).isSameInstanceAs(
- userIdManager.getPersonalUserId());
- assertThat(userIdManager.getManagedUserId()).isSameInstanceAs(
- userIdManager.getManagedUserId());
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ assertThat(userIdManager.getPersonalUserId()).isSameInstanceAs(
+ userIdManager.getPersonalUserId());
+ assertThat(userIdManager.getManagedUserId()).isSameInstanceAs(
+ userIdManager.getManagedUserId());
+ });
}
private void initializeUserIdManager(UserId current, List<UserHandle> usersOnDevice) {
when(mockUserManager.getUserProfiles()).thenReturn(usersOnDevice);
- userIdManager = new UserIdManager.RuntimeUserIdManager(mockContext, current);
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ userIdManager = new UserIdManager.RuntimeUserIdManager(mockContext, current);
+ });
}
}
diff --git a/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java b/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java
index 9b995f5..fc5c5a5 100644
--- a/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java
+++ b/tests/src/com/android/providers/media/photopicker/viewmodel/PickerViewModelTest.java
@@ -20,6 +20,7 @@
import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import android.app.Application;
@@ -35,6 +36,7 @@
import androidx.test.runner.AndroidJUnit4;
import com.android.providers.media.photopicker.data.ItemsProvider;
+import com.android.providers.media.photopicker.data.UserIdManager;
import com.android.providers.media.photopicker.data.model.Category;
import com.android.providers.media.photopicker.data.model.Item;
import com.android.providers.media.photopicker.data.model.ItemTest;
@@ -73,9 +75,14 @@
final Context context = InstrumentationRegistry.getTargetContext();
when(mApplication.getApplicationContext()).thenReturn(context);
- mPickerViewModel = new PickerViewModel(mApplication);
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ mPickerViewModel = new PickerViewModel(mApplication);
+ });
mItemsProvider = new TestItemsProvider(context);
mPickerViewModel.setItemsProvider(mItemsProvider);
+ UserIdManager userIdManager = mock(UserIdManager.class);
+ when(userIdManager.getCurrentUserProfileId()).thenReturn(UserId.CURRENT_USER);
+ mPickerViewModel.setUserIdManager(userIdManager);
}
@Test