blob: 0f82741742ee948b58436b1b6ee583454aba0299 [file] [log] [blame]
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.vr;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.ActivityManager;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ActivityInfo;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.StrictMode;
import android.provider.Settings;
import android.support.annotation.IntDef;
import android.util.DisplayMetrics;
import android.view.Display;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.WindowManager;
import android.widget.FrameLayout;
import com.google.vr.ndk.base.AndroidCompat;
import com.google.vr.ndk.base.DaydreamApi;
import com.google.vr.ndk.base.GvrUiLayout;
import org.chromium.base.ActivityState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.CollectionUtil;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.PackageUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.annotations.JNINamespace;
import org.chromium.base.library_loader.LibraryLoader;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.task.AsyncTask;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ApplicationLifetime;
import org.chromium.chrome.browser.ChromeActivity;
import org.chromium.chrome.browser.ChromeFeatureList;
import org.chromium.chrome.browser.ChromeTabbedActivity;
import org.chromium.chrome.browser.customtabs.CustomTabActivity;
import org.chromium.chrome.browser.help.HelpAndFeedback;
import org.chromium.chrome.browser.infobar.InfoBarIdentifier;
import org.chromium.chrome.browser.infobar.SimpleConfirmInfoBarBuilder;
import org.chromium.chrome.browser.preferences.ChromePreferenceManager;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.webapps.WebappActivity;
import org.chromium.content_public.browser.ScreenOrientationDelegate;
import org.chromium.content_public.browser.ScreenOrientationProvider;
import org.chromium.ui.display.DisplayAndroidManager;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.ref.WeakReference;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.RejectedExecutionException;
/**
* Manages interactions with the VR Shell.
*/
@JNINamespace("vr")
public class VrShellDelegate
implements View.OnSystemUiVisibilityChangeListener, ScreenOrientationDelegate {
private static final String TAG = "VrShellDelegate";
// Pseudo-random number to avoid request id collisions. Result codes must fit in lower 16 bits
// when used with startActivityForResult...
/* package */ static final int EXIT_VR_RESULT = 7212;
private static final int VR_SERVICES_UPDATE_RESULT = 7213;
private static final int GVR_KEYBOARD_UPDATE_RESULT = 7214;
@IntDef({EnterVRResult.NOT_NECESSARY, EnterVRResult.CANCELLED, EnterVRResult.REQUESTED,
EnterVRResult.SUCCEEDED})
@Retention(RetentionPolicy.SOURCE)
private @interface EnterVRResult {
int NOT_NECESSARY = 0;
int CANCELLED = 1;
int REQUESTED = 2;
int SUCCEEDED = 3;
}
private static final String VR_ENTRY_RESULT_ACTION =
"org.chromium.chrome.browser.vr.VrEntryResult";
private static final long REENTER_VR_TIMEOUT_MS = 1000;
private static final String FEEDBACK_REPORT_TYPE = "USER_INITIATED_FEEDBACK_REPORT_VR";
private static final String VR_CORE_MARKET_URI =
"market://details?id=" + VrCoreVersionChecker.VR_CORE_PACKAGE_ID;
private static final String GVR_KEYBOARD_PACKAGE_ID = "com.google.android.vr.inputmethod";
private static final String GVR_KEYBOARD_MARKET_URI =
"market://details?id=" + GVR_KEYBOARD_PACKAGE_ID;
private static final String SAMSUNG_GALAXY_PREFIX = "SM-";
private static final Set<String> SAMSUNG_GALAXY_8_MODELS =
Collections.unmodifiableSet(CollectionUtil.newHashSet("G950", "N950", "G955", "G892"));
private static final Set<String> SAMSUNG_GALAXY_8_ALT_MODELS = Collections.unmodifiableSet(
CollectionUtil.newHashSet("SC-02J", "SCV36", "SC-03J", "SCV35", "SC-01K", "SCV37"));
// This value is intentionally probably overkill. This is the time we need to wait from when
// Chrome is resumed, to when Chrome actually renders a black frame, so that we can cancel the
// stay_hidden animation and not see a white monoscopic frame in-headset. 150ms is definitely
// too short, 250ms is sometimes too short for debug builds. 500ms should hopefully be safe even
// under fairly exceptional conditions, and won't delay entering VR a noticeable amount given
// how slow it already is.
private static final int WINDOW_FADE_ANIMATION_DURATION_MS = 500;
/** ID for SavedInstanceState Bundle for whether Chrome was in VR when killed. */
private static final String IN_VR = "in_vr";
private static VrShellDelegate sInstance;
private static VrBroadcastReceiver sVrBroadcastReceiver;
private static VrLifecycleObserver sVrLifecycleObserver;
private static VrDaydreamApi sVrDaydreamApi;
private static VrCoreVersionChecker sVrCoreVersionChecker;
private static Set<Activity> sVrModeEnabledActivitys = new HashSet<>();
private static boolean sRegisteredDaydreamHook;
private static boolean sRegisteredVrAssetsComponent;
private static @VrSupportLevel Integer sVrSupportLevel;
private static boolean sTestVrShellDelegateOnStartup;
private ChromeActivity mActivity;
private int mCachedVrCorePackageVersion;
private int mCachedGvrKeyboardPackageVersion;
// How often to prompt the user to enter VR feedback.
private int mFeedbackFrequency;
private VrShell mVrShell;
private Boolean mIsDaydreamCurrentViewer;
private boolean mProbablyInDon;
private boolean mInVr;
private boolean mNeedsAnimationCancel;
private boolean mCancellingEntryAnimation;
// Whether or not the VR Device ON flow succeeded. If this is true it means the user has a VR
// headset on, but we haven't switched into VR mode yet.
// See further documentation here: https://developers.google.com/vr/daydream/guides/vr-entry
private boolean mDonSucceeded;
private boolean mShowingDaydreamDoff;
private boolean mShowingExitVrPrompt;
private boolean mDoffOptional;
// Listener to be called once we exited VR due to to an unsupported mode, e.g. the user clicked
// the URL bar security icon.
private OnExitVrRequestListener mOnExitVrRequestListener;
private Runnable mPendingExitVrRequest;
private Boolean mShowVrServicesUpdatePrompt;
private boolean mShowingDoffForGvrUpdate;
private boolean mExitedDueToUnsupportedMode;
private boolean mPaused;
private boolean mVisible;
private boolean mRestoreSystemUiVisibility;
private Integer mRestoreOrientation;
private boolean mRequestedWebVr;
private boolean mListeningForWebVrActivate;
private boolean mMaybeActivateAfterHeadsetInsertion;
private Handler mClearMaybeActivateHandler = new Handler();
private boolean mActivateFromHeadsetInsertion;
private boolean mStartedFromVrIntent;
private boolean mInternalIntentUsedToStartVr;
// Set to true if performed VR browsing at least once. That is, this was not simply a WebVr
// presentation experience.
private boolean mVrBrowserUsed;
private int mExpectedDensityChange;
// Gets run when the user exits VR mode by clicking the 'x' button or system UI back button.
private Runnable mCloseButtonListener;
// Gets run when the user exits VR mode by clicking the Gear button.
private Runnable mSettingsButtonListener;
@VisibleForTesting
protected boolean mTestWorkaroundDontCancelVrEntryOnResume;
private long mNativeVrShellDelegate;
/* package */ final static class VrUnsupportedException extends RuntimeException {}
private static final class VrLifecycleObserver
implements ApplicationStatus.ActivityStateListener {
@Override
public void onActivityStateChange(Activity activity, int newState) {
switch (newState) {
case ActivityState.DESTROYED:
if (sVrBroadcastReceiver != null
&& sVrBroadcastReceiver.targetActivity().get() == activity) {
sVrBroadcastReceiver.unregister();
sVrBroadcastReceiver = null;
}
sVrModeEnabledActivitys.remove(activity);
break;
default:
break;
}
if (sInstance != null) sInstance.onActivityStateChange(activity, newState);
}
}
private static final class VrBroadcastReceiver extends BroadcastReceiver {
private final WeakReference<ChromeActivity> mTargetActivity;
public VrBroadcastReceiver(ChromeActivity activity) {
ensureLifecycleObserverInitialized();
mTargetActivity = new WeakReference<ChromeActivity>(activity);
}
@Override
public void onReceive(Context context, Intent intent) {
ChromeActivity activity = mTargetActivity.get();
if (activity == null) return;
getInstance(activity);
assert sInstance != null;
if (sInstance == null) return;
sInstance.onBroadcastReceived();
// Note that even though we are definitely entering VR here, we don't want to set
// the window mode yet, as setting the window mode while we're in the background can
// racily lead to that window mode change essentially being ignored, with future
// attempts to set the same window mode also being ignored.
sInstance.mDonSucceeded = true;
sInstance.mProbablyInDon = false;
setVrModeEnabled(sInstance.mActivity, true);
if (VrDelegate.DEBUG_LOGS) Log.i(TAG, "VrBroadcastReceiver onReceive");
if (!sInstance.mRequestedWebVr && !sInstance.mStartedFromVrIntent) {
// If we didn't request WebVR then we're not coming from a request present call.
// If we didn't set mStartedFromVrIntent this isn't an intent from another app.
// Therefore we can assume this was triggered by NFC.
sInstance.nativeRecordVrStartAction(
sInstance.mNativeVrShellDelegate, VrStartAction.HEADSET_ACTIVATION);
}
// We add a black overlay view so that we can show black while the VR UI is loading.
if (!sInstance.mInVr)
VrModuleProvider.getDelegate().addBlackOverlayViewForActivity(sInstance.mActivity);
// For headset insertion handling it should be impossible in practice to receive this
// broadcast after being resumed. However, with VR entry flows skipped, these events
// can happen out of order. See https://crbug.com/762724.
sInstance.mActivateFromHeadsetInsertion = sInstance.mMaybeActivateAfterHeadsetInsertion;
if (sInstance.mPaused) {
if (activity instanceof ChromeTabbedActivity) {
// We can special case singleInstance activities like CTA to avoid having to use
// moveTaskToFront. Using moveTaskToFront prevents us from disabling window
// animations, and causes the system UI to show up during the preview window and
// window animations.
Intent launchIntent = new Intent(activity, activity.getClass());
launchIntent = VrModuleProvider.getIntentDelegate().setupVrIntent(launchIntent);
sInstance.mInternalIntentUsedToStartVr = true;
sInstance.setExpectingIntent(true);
getVrDaydreamApi().launchInVr(PendingIntent.getActivity(
activity, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT));
} else {
// We start the Activity with a custom animation that keeps it hidden while
// starting up to avoid Android showing stale 2D screenshots when the user is in
// their VR headset. The animation lasts up to 10 seconds, but is cancelled when
// we're resumed as at that time we'll be showing the black overlay added above.
int animation = !sInstance.mInVr && VrDelegate.USE_HIDE_ANIMATION
? R.anim.stay_hidden
: 0;
sInstance.mNeedsAnimationCancel = animation != 0;
Bundle options =
ActivityOptions.makeCustomAnimation(activity, animation, 0).toBundle();
((ActivityManager) activity.getSystemService(Context.ACTIVITY_SERVICE))
.moveTaskToFront(activity.getTaskId(), 0, options);
}
} else {
// If a WebVR app calls requestPresent in response to the displayactivate event
// after the DON flow completes, the DON flow is skipped, meaning our app won't be
// paused when daydream fires our BroadcastReceiver, so onResume won't be called.
sInstance.handleDonFlowSuccess();
}
}
/**
* Unregisters this {@link BroadcastReceiver} from the activity it's registered to.
*/
public void unregister() {
ChromeActivity activity = mTargetActivity.get();
if (activity == null) return;
try {
activity.unregisterReceiver(VrBroadcastReceiver.this);
} catch (IllegalArgumentException e) {
// Ignore this. This means our receiver was already unregistered somehow.
}
}
WeakReference<ChromeActivity> targetActivity() {
return mTargetActivity;
}
}
/**
* Immediately exits VR. If the user is in headset, they will see monoscopic UI while in the
* headset, so use with caution.
*/
public static void forceExitVrImmediately() {
if (sInstance == null) return;
sInstance.shutdownVr(true, true);
}
/**
* See {@link Activity#onActivityResult}.
*/
public static boolean onActivityResultWithNative(int requestCode, int resultCode) {
// Handles the result of the exit VR flow (DOFF).
if (requestCode == EXIT_VR_RESULT) {
if (sInstance != null) sInstance.onExitVrResult(resultCode == Activity.RESULT_OK);
return true;
}
// Handles the result of requesting to update VR services.
if (requestCode == VR_SERVICES_UPDATE_RESULT) {
if (sInstance != null) sInstance.onVrServicesMaybeUpdated();
return true;
}
// Handles the result of requesting to update GVR Keyboard.
if (requestCode == GVR_KEYBOARD_UPDATE_RESULT) {
if (sInstance != null) sInstance.onGvrKeyboardMaybeUpdated();
return true;
}
return false;
}
/**
* Called when the native library is first available.
*/
public static void onNativeLibraryAvailable() {
VrModuleProvider.registerJni();
nativeOnLibraryAvailable();
}
/**
* Whether or not we are currently in VR.
*/
public static boolean isInVr() {
if (sInstance == null) return false;
return sInstance.mInVr;
}
/**
* @return Whether 2D intents can safely be launched without showing non-VR UI to users in VR
* headsets.
*/
public static boolean canLaunch2DIntents() {
if (!isInVr()) return true;
return sInstance.canLaunch2DIntentsInternal();
}
/**
* See {@link ChromeActivity#handleBackPressed}
* Only handles the back press while in VR.
*/
public static boolean onBackPressed() {
if (sInstance == null) return false;
return sInstance.onBackPressedInternal();
}
/**
* Enters VR on the current tab if possible.
*
* @return Whether VR entry succeeded (or is in progress).
*/
public static boolean enterVrIfNecessary() {
boolean created_delegate = sInstance == null;
VrShellDelegate instance = getInstance();
if (instance == null) return false;
int result = instance.enterVrInternal();
if (result == EnterVRResult.CANCELLED && created_delegate) instance.destroy();
return result != EnterVRResult.CANCELLED;
}
/**
* If VR Shell is enabled, and the activity is supported, register with the Daydream
* platform that this app would like to be launched in VR when the device enters VR.
*/
public static void maybeRegisterVrEntryHook(final ChromeActivity activity) {
// Daydream is not supported on pre-N devices.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return;
if (sInstance != null) return; // Will be handled in onResume.
if (!VrModuleProvider.getDelegate().activitySupportsVrBrowsing(activity)
&& sRegisteredVrAssetsComponent)
return;
// Short-circuit the asnyc task if we've already queried support level previously. Creating
// the async task takes ~1ms on my Android Go device.
if (sVrSupportLevel != null && sVrSupportLevel != VrSupportLevel.VR_DAYDREAM) return;
try {
// Reading VR support level and version can be slow, so do it asynchronously.
new AsyncTask<Integer>() {
@Override
protected Integer doInBackground() {
return getVrSupportLevel();
}
@Override
protected void onPostExecute(Integer vrSupportLevel) {
if (vrSupportLevel != VrSupportLevel.VR_DAYDREAM) return;
if (!sRegisteredVrAssetsComponent) {
registerVrAssetsComponentIfDaydreamUser(isDaydreamCurrentViewer());
}
// Registering the daydream intent has to be done on the UI thread. Note that
// this call is slow (~10ms at time of writing).
if (isVrBrowsingEnabled(activity, vrSupportLevel)
&& ApplicationStatus.getStateForActivity(activity)
== ActivityState.RESUMED) {
registerDaydreamIntent(activity);
}
}
}
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
} catch (RejectedExecutionException ex) {
// This isn't critical work, so it's okay to fail silently. If the user does try to
// enter VR the asset component may not be available, and headset insertion will go to
// Daydream rather than Chrome.
}
}
/**
* When the app is pausing we need to unregister with the Daydream platform to prevent this app
* from being launched from the background when the device enters VR.
*/
public static void maybeUnregisterVrEntryHook() {
// Daydream is not supported on pre-N devices.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return;
if (sInstance != null) return; // Will be handled in onPause.
if (!sRegisteredDaydreamHook) return;
unregisterDaydreamIntent();
}
public static void onMultiWindowModeChanged(boolean isInMultiWindowMode) {
if (isInMultiWindowMode && isInVr()) {
sInstance.shutdownVr(true /* disableVrMode */, true /* stayingInChrome */);
}
}
public static void requestToExitVrForSearchEnginePromoDialog(
OnExitVrRequestListener listener, Activity activity) {
// When call site requests to exit VR, depend on the timing, Chrome may not in VR yet
// (Chrome only enter VR after onNewIntentWithNative is called in the cold start case).
// While not in VR, calling requestToExitVr would immediately notify listener that exit VR
// succeed (without showing DOFF screen). If call site decide to show 2D UI when exit VR
// succeeded, it leads to case that 2D UI is showing on top of VR when Chrome eventually
// enters VR. To prevent this from happening, we set mPendingExitVrRequest which should be
// executed at runPendingExitVrTask. runPendingExitVrTask is called after it is safe to
// request exit VR.
if (isInVr()) {
sInstance.requestToExitVrInternal(
listener, UiUnsupportedMode.SEARCH_ENGINE_PROMO, false);
} else {
// Making sure that we response to this request as it is very important that search
// engine promo dialog isn't ignored due to VR.
assert VrModuleProvider.getIntentDelegate().isVrIntent(activity.getIntent());
VrShellDelegate instance = getInstance();
if (instance == null) {
listener.onDenied();
return;
}
sInstance.mPendingExitVrRequest = () -> {
VrShellDelegate.requestToExitVr(listener, UiUnsupportedMode.SEARCH_ENGINE_PROMO);
};
}
}
public static void requestToExitVr(OnExitVrRequestListener listener) {
requestToExitVr(listener, UiUnsupportedMode.GENERIC_UNSUPPORTED_FEATURE);
}
public static void requestToExitVr(
OnExitVrRequestListener listener, @UiUnsupportedMode int reason) {
// If we're not in VR, just say that we've successfully exited VR.
if (sInstance == null || !sInstance.mInVr) {
listener.onSucceeded();
return;
}
sInstance.requestToExitVrInternal(listener, reason, !supports2dInVr());
}
public static void requestToExitVrAndRunOnSuccess(Runnable onSuccess) {
requestToExitVrAndRunOnSuccess(onSuccess, UiUnsupportedMode.GENERIC_UNSUPPORTED_FEATURE);
}
public static void requestToExitVrAndRunOnSuccess(
Runnable onSuccess, @UiUnsupportedMode int reason) {
requestToExitVr(new OnExitVrRequestListener() {
@Override
public void onSucceeded() {
onSuccess.run();
}
@Override
public void onDenied() {}
}, reason);
}
/**
* Called when the {@link ChromeActivity} becomes visible.
*/
public static void onActivityShown(ChromeActivity activity) {
if (sInstance != null && sInstance.mActivity == activity) sInstance.onActivityShown();
}
/**
* Called when the {@link ChromeActivity} is hidden.
*/
public static void onActivityHidden(ChromeActivity activity) {
if (sInstance != null && sInstance.mActivity == activity) sInstance.onActivityHidden();
}
/**
* @return Whether VrShellDelegate handled the density change. If the density change is
* unhandled, the Activity should be recreated in order to handle the change.
*/
public static boolean onDensityChanged(int oldDpi, int newDpi) {
if (VrDelegate.DEBUG_LOGS) Log.i(TAG, "onDensityChanged [%d]->[%d] ", oldDpi, newDpi);
if (sInstance == null) return false;
// If density changed while in VR, we expect a second density change to restore the density
// to what it previously was when we exit VR. We shouldn't have to recreate the activity as
// all non-VR UI is still using the old density.
if (sInstance.mExpectedDensityChange != 0) {
assert !sInstance.mInVr && !sInstance.mDonSucceeded;
int expectedDensity = sInstance.mExpectedDensityChange;
sInstance.mExpectedDensityChange = 0;
return (newDpi == expectedDensity);
}
if (sInstance.mInVr || sInstance.mDonSucceeded) {
sInstance.mExpectedDensityChange = oldDpi;
return true;
}
return false;
}
/**
* @param topContentOffset The top content offset (usually applied by the omnibox).
*/
public static void rawTopContentOffsetChanged(float topContentOffset) {
assert isInVr();
sInstance.mVrShell.rawTopContentOffsetChanged(topContentOffset);
}
/**
* This is called every time ChromeActivity gets a new intent.
*/
public static void onNewIntentWithNative(ChromeActivity activity, Intent intent) {
if (activity.isFinishing()) return;
if (!VrModuleProvider.getIntentDelegate().isLaunchingIntoVr(activity, intent)) return;
VrShellDelegate instance = getInstance(activity);
if (instance == null) return;
instance.onNewVrIntent();
}
/**
* This is called when ChromeTabbedActivity gets a new intent before native is initialized.
*/
public static void maybeHandleVrIntentPreNative(ChromeActivity activity, Intent intent) {
boolean launchingIntoVr =
VrModuleProvider.getIntentDelegate().isLaunchingIntoVr(activity, intent);
if (!launchingIntoVr) {
// We trust that if an intent is targeted for 2D, that Chrome should switch to 2D
// regardless of whether the user is in headset.
if (VrShellDelegate.isInVr()) VrShellDelegate.forceExitVrImmediately();
return;
}
if (VrModuleProvider.getDelegate().bootsToVr() && launchingIntoVr) {
if (VrModuleProvider.getDelegate().relaunchOnMainDisplayIfNecessary(activity, intent)) {
return;
}
}
if (sInstance != null && !sInstance.mInternalIntentUsedToStartVr) {
sInstance.swapHostActivity(activity, false /* disableVrMode */);
// If the user has launched Chrome from the launcher, rather than resuming from the
// dashboard, we don't want to launch into presentation.
sInstance.exitWebVRAndClearState();
}
if (sInstance != null) sInstance.setExpectingIntent(false);
if (VrDelegate.DEBUG_LOGS)
Log.i(TAG, "maybeHandleVrIntentPreNative: preparing for transition");
// We add a black overlay view so that we can show black while the VR UI is loading.
// Note that this alone isn't sufficient to prevent 2D UI from showing when
// auto-presenting WebVR. See comment about the custom animation in {@link
// getVrIntentOptions}.
// TODO(crbug.com/775574): This hack doesn't really work to hide the 2D UI on Samsung
// devices since Chrome gets paused and we prematurely remove the overlay.
if (sInstance == null || !sInstance.mInVr) {
VrModuleProvider.getDelegate().addBlackOverlayViewForActivity(activity);
}
// Enable VR mode and hide system UI. We do this here so we don't get kicked out of
// VR mode and to prevent seeing a flash of system UI.
setVrModeEnabled(activity, true);
VrModuleProvider.getDelegate().setSystemUiVisibilityForVr(activity);
}
/**
* Asynchronously enable VR mode.
*/
public static void setVrModeEnabled(Activity activity, boolean enabled) {
ensureLifecycleObserverInitialized();
if (enabled) {
if (sVrModeEnabledActivitys.contains(activity)) return;
AndroidCompat.setVrModeEnabled(activity, true);
sVrModeEnabledActivitys.add(activity);
} else {
if (!sVrModeEnabledActivitys.contains(activity)) return;
AndroidCompat.setVrModeEnabled(activity, false);
sVrModeEnabledActivitys.remove(activity);
}
}
/**
* Performs pre-inflation VR-related startup.
*/
public static void doPreInflationStartup(ChromeActivity activity, Bundle savedInstanceState) {
// We need to explicitly enable VR mode here so that the system doesn't kick us out of VR,
// or drop us into the 2D-in-VR rendering mode, while we prepare for VR rendering.
if (VrModuleProvider.getIntentDelegate().isLaunchingIntoVr(
activity, activity.getIntent())) {
setVrModeEnabled(activity, true);
} else if (savedInstanceState != null && savedInstanceState.getBoolean(IN_VR, false)) {
// When Chrome is restored from a SavedInstanceState with VR mode still on we need to
// Explicitly turn VR mode off even though we can't really know for sure whether or not
// it's currently on.
AndroidCompat.setVrModeEnabled(activity, false);
sVrModeEnabledActivitys.remove(activity);
}
}
/**
* See {@link Activity#onSaveInstanceState(Bundle)}
*/
public static void onSaveInstanceState(Bundle outState) {
if (isInVr()) outState.putBoolean(IN_VR, true);
}
/**
* @return A Daydream Api instance, for interacting with Daydream platform features.
*/
public static VrDaydreamApi getVrDaydreamApi() {
if (sVrDaydreamApi == null) sVrDaydreamApi = new VrDaydreamApi();
return sVrDaydreamApi;
}
public static boolean isDaydreamReadyDevice() {
return DaydreamApi.isDaydreamReadyPlatform(ContextUtils.getApplicationContext());
}
public static boolean isDaydreamCurrentViewer() {
if (sInstance != null) return sInstance.isDaydreamCurrentViewerInternal();
return getVrDaydreamApi().isDaydreamCurrentViewer();
}
public static boolean supports2dInVr() {
Context context = ContextUtils.getApplicationContext();
return isDaydreamReadyDevice() && DaydreamApi.supports2dInVr(context);
}
protected static void enableTestVrShellDelegateOnStartupForTesting() {
sTestVrShellDelegateOnStartup = true;
}
/* package */ static boolean isVrModeEnabled(Activity activity) {
return sVrModeEnabledActivitys.contains(activity);
}
private static boolean activitySupportsPresentation(Activity activity) {
return activity instanceof ChromeTabbedActivity || activity instanceof CustomTabActivity
|| activity instanceof WebappActivity;
}
private static boolean activitySupportsExitFeedback(Activity activity) {
return activity instanceof ChromeTabbedActivity
&& ChromeFeatureList.isEnabled(ChromeFeatureList.VR_BROWSING_FEEDBACK);
}
private static void registerVrAssetsComponentIfDaydreamUser(boolean isDaydreamCurrentViewer) {
assert !sRegisteredVrAssetsComponent;
if (isDaydreamCurrentViewer) {
nativeRegisterVrAssetsComponent();
sRegisteredVrAssetsComponent = true;
}
ChromePreferenceManager.getInstance().writeBoolean(
ChromePreferenceManager.SHOULD_REGISTER_VR_ASSETS_COMPONENT_ON_STARTUP,
isDaydreamCurrentViewer);
}
private static VrCoreVersionChecker getVrCoreVersionChecker() {
if (sVrCoreVersionChecker == null) sVrCoreVersionChecker = new VrCoreVersionChecker();
return sVrCoreVersionChecker;
}
// We need a custom Intent for entering VR in order to support VR in Custom Tabs. Custom Tabs
// are not a singleInstance activity, so they cannot be resumed through Activity PendingIntents,
// which is the typical way Daydream resumes your Activity. Instead, we use a broadcast intent
// and then use the broadcast to bring ourselves back to the foreground.
/* package */ static PendingIntent getEnterVrPendingIntent(ChromeActivity activity) {
if (sVrBroadcastReceiver != null) sVrBroadcastReceiver.unregister();
IntentFilter filter = new IntentFilter(VR_ENTRY_RESULT_ACTION);
VrBroadcastReceiver receiver = new VrBroadcastReceiver(activity);
// If we set sVrBroadcastReceiver then use it in registerReceiver, findBugs considers this
// a thread-safety issue since it thinks the receiver isn't fully initialized before being
// exposed to other threads. This isn't actually an issue in this case, but we need to set
// sVrBroadcastReceiver after we're done using it here to fix the compile error.
activity.registerReceiver(receiver, filter);
sVrBroadcastReceiver = receiver;
Intent vrIntent = new Intent(VR_ENTRY_RESULT_ACTION);
vrIntent.setPackage(activity.getPackageName());
return PendingIntent.getBroadcast(activity, 0, vrIntent, PendingIntent.FLAG_UPDATE_CURRENT);
}
/**
* Registers the Intent to fire after phone inserted into a headset.
*/
private static void registerDaydreamIntent(final ChromeActivity activity) {
if (sRegisteredDaydreamHook) return;
if (!getVrDaydreamApi().registerDaydreamIntent(getEnterVrPendingIntent(activity))) return;
sRegisteredDaydreamHook = true;
}
/**
* Unregisters the Intent which registered by this context if any.
*/
private static void unregisterDaydreamIntent() {
if (!sRegisteredDaydreamHook) return;
getVrDaydreamApi().unregisterDaydreamIntent();
sRegisteredDaydreamHook = false;
}
private static boolean isVrBrowsingSupported(ChromeActivity activity) {
return VrModuleProvider.getDelegate().activitySupportsVrBrowsing(activity)
&& !willChangeDensityInVr(activity) && isDaydreamCurrentViewer();
}
/**
* @return Whether or not VR Browsing is currently enabled for the given Activity.
*/
/* package */ static boolean isVrBrowsingEnabled(ChromeActivity activity, int vrSupportLevel) {
return isVrBrowsingSupported(activity) && vrSupportLevel == VrSupportLevel.VR_DAYDREAM;
}
/* package */ static boolean isInVrSession() {
Context context = ContextUtils.getApplicationContext();
// The call to isInVrSession crashes when called on a non-Daydream ready device, so we add
// the device check (b/77268533).
try {
return isDaydreamReadyDevice() && DaydreamApi.isInVrSession(context);
} catch (Exception ex) {
Log.e(TAG, "Unable to check if in VR session", ex);
return false;
}
}
/* package */ static boolean willChangeDensityInVr(ChromeActivity activity) {
// Only N+ support launching in VR at all, other OS versions don't care about this.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false;
// If the screen density changed while in VR, we have to disable the VR browser as java UI
// used or created by VR browsing will be broken.
if (sInstance != null && sInstance.mExpectedDensityChange != 0) return true;
if (getVrSupportLevel() != VrSupportLevel.VR_DAYDREAM) return false;
Display display = DisplayAndroidManager.getDefaultDisplayForContext(
ContextUtils.getApplicationContext());
DisplayMetrics metrics = new DisplayMetrics();
display.getRealMetrics(metrics);
if (activity.getLastActiveDensity() != 0
&& (int) activity.getLastActiveDensity() != metrics.densityDpi) {
return true;
}
if (!deviceCanChangeResolutionForVr()) return false;
Display.Mode[] modes = display.getSupportedModes();
// Devices with only one mode won't switch modes while in VR.
if (modes.length <= 1) return false;
Display.Mode vr_mode = modes[0];
for (int i = 1; i < modes.length; ++i) {
if (modes[i].getPhysicalWidth() > vr_mode.getPhysicalWidth()) vr_mode = modes[i];
}
// If we're currently in the mode supported by VR the density won't change.
// We actually can't use display.getMode() to get the current mode as that just always
// returns the same mode ignoring the override, so we just check that our current display
// size is not equal to the vr mode size.
if (vr_mode.getPhysicalWidth() != metrics.widthPixels
&& vr_mode.getPhysicalWidth() != metrics.heightPixels) {
return true;
}
if (vr_mode.getPhysicalHeight() != metrics.widthPixels
&& vr_mode.getPhysicalHeight() != metrics.heightPixels) {
return true;
}
return false;
}
private static boolean deviceCanChangeResolutionForVr() {
// Samsung devices no longer change density when entering VR on O+.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return false;
String model = android.os.Build.MODEL;
if (SAMSUNG_GALAXY_8_ALT_MODELS.contains(model)) return true;
// Only Samsung devices change resolution in VR.
if (!model.startsWith(SAMSUNG_GALAXY_PREFIX)) return false;
CharSequence modelNumber = model.subSequence(3, 7);
// Only S8(+) and Note 8 models change resolution in VR.
if (!SAMSUNG_GALAXY_8_MODELS.contains(modelNumber)) return false;
return true;
}
private static boolean isVrCoreCompatible() {
VrCoreVersionChecker checker = getVrCoreVersionChecker();
if (checker == null) return false;
return checker.getVrCoreCompatibility() == VrCoreCompatibility.VR_READY;
}
private static void startFeedback(Tab tab) {
// TODO(ymalik): This call will connect to the Google Services api which can be slow. Can we
// connect to it beforehand when we know that we'll be prompting for feedback?
HelpAndFeedback.getInstance(tab.getActivity())
.showFeedback(tab.getActivity(), tab.getProfile(), tab.getUrl(),
ContextUtils.getApplicationContext().getPackageName() + "."
+ FEEDBACK_REPORT_TYPE);
}
private static void promptForFeedback(final Tab tab) {
if (tab == null) return;
final ChromeActivity activity = tab.getActivity();
SimpleConfirmInfoBarBuilder.Listener listener = new SimpleConfirmInfoBarBuilder.Listener() {
@Override
public void onInfoBarDismissed() {}
@Override
public boolean onInfoBarButtonClicked(boolean isPrimary) {
if (isPrimary) {
startFeedback(tab);
} else {
VrFeedbackStatus.setFeedbackOptOut(true);
}
return false;
}
};
SimpleConfirmInfoBarBuilder.create(tab, listener,
InfoBarIdentifier.VR_FEEDBACK_INFOBAR_ANDROID, R.drawable.vr_services,
activity.getString(R.string.vr_shell_feedback_infobar_description),
activity.getString(R.string.vr_shell_feedback_infobar_feedback_button),
activity.getString(R.string.no_thanks), true /* autoExpire */);
}
private static void ensureLifecycleObserverInitialized() {
if (sVrLifecycleObserver != null) return;
sVrLifecycleObserver = new VrLifecycleObserver();
ApplicationStatus.registerStateListenerForAllActivities(sVrLifecycleObserver);
}
/**
* Returns the current {@VrSupportLevel}.
*/
@CalledByNative
private static int getVrSupportLevel() {
if (sVrSupportLevel == null) {
if (!isVrCoreCompatible()) {
sVrSupportLevel = VrSupportLevel.VR_NEEDS_UPDATE;
} else if (isDaydreamReadyDevice()) {
sVrSupportLevel = VrSupportLevel.VR_DAYDREAM;
} else {
sVrSupportLevel = VrSupportLevel.VR_CARDBOARD;
}
}
return sVrSupportLevel;
}
@CalledByNative
private static VrShellDelegate getInstance() {
Activity activity = ApplicationStatus.getLastTrackedFocusedActivity();
if (!(activity instanceof ChromeActivity)) return null;
return getInstance((ChromeActivity) activity);
}
@SuppressWarnings("unchecked")
private static VrShellDelegate getInstance(ChromeActivity activity) {
if (!LibraryLoader.getInstance().isInitialized()) return null;
if (activity == null || !activitySupportsPresentation(activity)) return null;
if (sInstance != null) return sInstance;
ThreadUtils.assertOnUiThread();
if (sTestVrShellDelegateOnStartup) {
try {
// This should only ever be run during tests on standalone devices. Normally, we
// create a TestVrShellDelegate during pre-test setup after Chrome has started.
// However, since Chrome is started in VR on standalones, creating a
// TestVrShellDelegate after startup discards the existing VrShellDelegate instance
// that's in use, which is bad. So, in those cases, create a TestVrShellDelegate
// instead of the production version.
Class clazz = Class.forName("org.chromium.chrome.browser.vr.TestVrShellDelegate");
Method method = clazz.getMethod("createTestVrShellDelegate", ChromeActivity.class);
method.invoke(null, activity);
} catch (Exception e) {
assert false;
}
} else {
sInstance = new VrShellDelegate(activity);
}
return sInstance;
}
protected VrShellDelegate(ChromeActivity activity) {
mActivity = activity;
// If an activity isn't resumed at the point, it must have been paused.
mPaused = ApplicationStatus.getStateForActivity(activity) != ActivityState.RESUMED;
mVisible = activity.hasWindowFocus();
mNativeVrShellDelegate = nativeInit();
updateVrSupportLevel(null);
mFeedbackFrequency = VrFeedbackStatus.getFeedbackFrequency();
ensureLifecycleObserverInitialized();
if (!mPaused) onResume();
sInstance = this;
}
public void onActivityStateChange(Activity activity, int newState) {
switch (newState) {
case ActivityState.DESTROYED:
if (activity == mActivity) destroy();
break;
case ActivityState.PAUSED:
if (activity == mActivity) onPause();
// Other activities should only pause while we're paused due to Android lifecycle.
assert mPaused;
break;
case ActivityState.STOPPED:
if (activity == mActivity) onStop();
break;
case ActivityState.STARTED:
if (activity == mActivity) onStart();
break;
case ActivityState.RESUMED:
if (!activitySupportsPresentation(activity)) return;
if (!(activity instanceof ChromeActivity)) return;
swapHostActivity((ChromeActivity) activity, true /* disableVrMode */);
onResume();
break;
default:
break;
}
}
// Called when an activity that supports VR is resumed, and attaches VrShellDelegate to that
// activity.
private void swapHostActivity(ChromeActivity activity, boolean disableVrMode) {
assert mActivity != null;
if (mActivity == activity) return;
if (mInVr) shutdownVr(disableVrMode, false /* stayingInChrome */);
mActivity = activity;
}
private void maybeUpdateVrSupportLevel() {
// If we're on Daydream support level, Chrome will get restarted by Android in response to
// VrCore being updated/downgraded, so we don't need to check.
if (getVrSupportLevel() == VrSupportLevel.VR_DAYDREAM) return;
int version = getVrCorePackageVersion();
// If VrCore package hasn't changed, no need to update.
if (version == mCachedVrCorePackageVersion
&& !(mShowVrServicesUpdatePrompt != null && mShowVrServicesUpdatePrompt)) {
return;
}
updateVrSupportLevel(version);
}
private int getVrCorePackageVersion() {
return PackageUtils.getPackageVersion(
ContextUtils.getApplicationContext(), VrCoreVersionChecker.VR_CORE_PACKAGE_ID);
}
private int getGvrKeyboardPackageVersion() {
return PackageUtils.getPackageVersion(
ContextUtils.getApplicationContext(), GVR_KEYBOARD_PACKAGE_ID);
}
/**
* Updates sVrSupportLevel to the correct value as VR support can change over time.
*/
private void updateVrSupportLevel(Integer vrCorePackageVersion) {
sVrSupportLevel = null;
if (getVrSupportLevel() != VrSupportLevel.VR_NEEDS_UPDATE) return;
if (vrCorePackageVersion == null) vrCorePackageVersion = getVrCorePackageVersion();
mCachedVrCorePackageVersion = vrCorePackageVersion;
promptToUpdateVrServices();
}
private void promptToUpdateVrServices() {
assert getVrSupportLevel() == VrSupportLevel.VR_NEEDS_UPDATE;
Tab tab = mActivity.getActivityTab();
if (tab == null) return;
int vrCoreCompatibility = getVrCoreVersionChecker().getVrCoreCompatibility();
String infobarText;
String buttonText;
if (vrCoreCompatibility == VrCoreCompatibility.VR_NOT_AVAILABLE) {
// Supported, but not installed. Ask user to install instead of upgrade.
infobarText = mActivity.getString(R.string.vr_services_check_infobar_install_text);
buttonText = mActivity.getString(R.string.vr_services_check_infobar_install_button);
} else if (vrCoreCompatibility == VrCoreCompatibility.VR_OUT_OF_DATE) {
infobarText = mActivity.getString(R.string.vr_services_check_infobar_update_text);
buttonText = mActivity.getString(R.string.vr_services_check_infobar_update_button);
} else {
Log.e(TAG, "Unknown VrCore compatibility: " + vrCoreCompatibility);
return;
}
SimpleConfirmInfoBarBuilder.Listener listener = new SimpleConfirmInfoBarBuilder.Listener() {
@Override
public void onInfoBarDismissed() {}
@Override
public boolean onInfoBarButtonClicked(boolean isPrimary) {
mActivity.startActivityForResult(
new Intent(Intent.ACTION_VIEW, Uri.parse(VR_CORE_MARKET_URI)),
VR_SERVICES_UPDATE_RESULT);
return false;
}
};
SimpleConfirmInfoBarBuilder.create(tab, listener,
InfoBarIdentifier.VR_SERVICES_UPGRADE_ANDROID, R.drawable.vr_services, infobarText,
buttonText, null, true);
}
@VisibleForTesting
protected boolean isVrBrowsingEnabled() {
return isVrBrowsingEnabled(mActivity, getVrSupportLevel());
}
private void onVrServicesMaybeUpdated() {
if (mCachedVrCorePackageVersion == getVrCorePackageVersion()) return;
ApplicationLifetime.terminate(true);
}
private void onGvrKeyboardMaybeUpdated() {
if (mCachedGvrKeyboardPackageVersion == getGvrKeyboardPackageVersion()) return;
ApplicationLifetime.terminate(true);
}
/**
* Returns whether the device has support for Daydream.
*/
/* package */ boolean hasDaydreamSupport() {
return getVrSupportLevel() == VrSupportLevel.VR_DAYDREAM;
}
private void maybeSetPresentResult(boolean result) {
if (mNativeVrShellDelegate == 0 || !mRequestedWebVr) return;
nativeSetPresentResult(mNativeVrShellDelegate, result);
mRequestedWebVr = false;
}
/**
* Handle a successful VR DON flow, entering VR in the process unless we're unable to.
* @return False if VR entry failed.
*/
private boolean enterVrAfterDon() {
if (mNativeVrShellDelegate == 0) return false;
if (!canEnterVr()) return false;
// If headset insertion was performed while a page was listening for vrdisplayactivate,
// we assume it wants to request presentation. Go into WebVR mode tentatively. If the page
// doesn't request presentation in the vrdisplayactivate handler we will exit presentation
// later.
if (mActivateFromHeadsetInsertion) {
assert !mRequestedWebVr;
assert !mStartedFromVrIntent;
}
enterVr(mActivateFromHeadsetInsertion);
if (mActivateFromHeadsetInsertion && mListeningForWebVrActivate) {
nativeDisplayActivate(mNativeVrShellDelegate);
mActivateFromHeadsetInsertion = false;
}
// The user has successfully completed a DON flow.
RecordUserAction.record("VR.DON");
return true;
}
private void enterVr(final boolean tentativeWebVrMode) {
// We should only enter VR when we're the resumed Activity or our changes to things like
// system UI flags might get lost.
assert !mPaused;
assert mNativeVrShellDelegate != 0;
if (mInVr) return;
mInVr = true;
setVrModeEnabled(mActivity, true);
setWindowModeForVr();
// We assume that we triggered the DON flow already for Daydream viewers. If that changes,
// we need to make sure not to report success/fail to WebXR until after the DON flow runs.
assert mDonSucceeded || !isDaydreamCurrentViewerInternal();
mDonSucceeded = false;
if (!createVrShell()) {
cancelPendingVrEntry();
mInVr = false;
getVrDaydreamApi().launchVrHomescreen();
return;
}
mExitedDueToUnsupportedMode = false;
addVrViews();
// Make sure that assets component is registered when creating native VR shell.
if (!sRegisteredVrAssetsComponent) {
registerVrAssetsComponentIfDaydreamUser(isDaydreamCurrentViewer());
}
boolean webVrMode = mRequestedWebVr || tentativeWebVrMode;
mVrShell.initializeNative(webVrMode, VrModuleProvider.getDelegate().bootsToVr());
mVrShell.setWebVrModeEnabled(webVrMode);
// We're entering VR, but not in WebVr mode.
mVrBrowserUsed = !webVrMode;
// resume needs to be called on GvrLayout after initialization to make sure DON flow works
// properly.
if (mVisible) mVrShell.resume();
mVrShell.getContainer().setOnSystemUiVisibilityChangeListener(this);
maybeSetPresentResult(true);
VrModuleProvider.onEnterVr();
}
private void onVrIntentUnsupported() {
// If entering VR is unsupported for some reason, clean up what we did in
// maybeHandleVrIntentPreNative.
assert !mInVr;
mStartedFromVrIntent = false;
cancelPendingVrEntry();
// Some Samsung devices change the screen density after exiting VR mode which causes
// us to restart Chrome with the VR intent that originally started it. We don't want to
// enable VR mode when the user opens Chrome again in 2D mode, so we remove VR specific
// extras.
VrModuleProvider.getIntentDelegate().removeVrExtras(mActivity.getIntent());
// We may still be showing the STAY_HIDDEN animation, so cancel it if necessary.
cancelStartupAnimationIfNeeded();
}
private void onNewVrIntent() {
// We set the the system UI in maybeHandleVrIntentPreNative, so make sure we restore it when
// we exit VR, or cancel VR entry.
mRestoreSystemUiVisibility = true;
// Nothing to do if we were launched by an internal intent.
if (mInternalIntentUsedToStartVr) {
mInternalIntentUsedToStartVr = false;
// TODO(mthiesse): This shouldn't be necessary. This is another instance of b/65681875,
// where the intent is received after we're resumed.
if (mInVr) return;
// This is extremely unlikely in practice. Some code must have called shutdownVR() while
// we were entering VR through NFC insertion.
if (!mDonSucceeded) cancelPendingVrEntry();
return;
}
if (VrDelegate.USE_HIDE_ANIMATION) mNeedsAnimationCancel = true;
if (!isVrBrowsingSupported(mActivity)) {
onVrIntentUnsupported();
return;
}
if (!mInVr) nativeRecordVrStartAction(mNativeVrShellDelegate, VrStartAction.INTENT_LAUNCH);
mStartedFromVrIntent = true;
// Setting DON succeeded will cause us to enter VR when resuming.
mDonSucceeded = true;
if (!mPaused) {
// Note that canceling the animation below is what causes us to enter VR mode. We start
// an intermediate activity to cancel the animation which causes onPause and onResume to
// be called and we enter VR mode in onResume (because we set the mEnterVrOnStartup bit
// above). If Chrome is already running, onResume which will be called after
// VrShellDelegate#onNewIntentWithNative which will cancel the animation and enter VR
// after that.
if (!cancelStartupAnimationIfNeeded()) {
// If we didn't cancel the startup animation, we won't be getting another onResume
// call, so enter VR here.
handleDonFlowSuccess();
runPendingExitVrTask();
}
}
}
private void runPendingExitVrTask() {
if (mPendingExitVrRequest == null) return;
new Handler().post(mPendingExitVrRequest);
mPendingExitVrRequest = null;
}
@Override
public void onSystemUiVisibilityChange(int visibility) {
if (mInVr && !isWindowModeCorrectForVr()) {
setWindowModeForVr();
}
}
@Override
public boolean canUnlockOrientation(Activity activity, int defaultOrientation) {
if (mActivity == activity && mRestoreOrientation != null) {
mRestoreOrientation = defaultOrientation;
return false;
}
return true;
}
@Override
public boolean canLockOrientation() {
return false;
}
public boolean hasAudioPermission() {
return mActivity.getWindowAndroid().hasPermission(android.Manifest.permission.RECORD_AUDIO);
}
private boolean isWindowModeCorrectForVr() {
int flags = mActivity.getWindow().getDecorView().getSystemUiVisibility();
int orientation = mActivity.getResources().getConfiguration().orientation;
// Mask the flags to only those that we care about.
return (flags & VrDelegate.VR_SYSTEM_UI_FLAGS) == VrDelegate.VR_SYSTEM_UI_FLAGS
&& orientation == Configuration.ORIENTATION_LANDSCAPE;
}
@TargetApi(Build.VERSION_CODES.KITKAT)
private void setWindowModeForVr() {
// Decouple the compositor size from the view size, or we'll get an unnecessary resize due
// to the orientation change when entering VR, then another resize once VR has settled on
// the content size.
if (mActivity.getCompositorViewHolder() != null) {
mActivity.getCompositorViewHolder().onEnterVr();
}
ScreenOrientationProvider.setOrientationDelegate(this);
// Hide system UI.
VrModuleProvider.getDelegate().setSystemUiVisibilityForVr(mActivity);
// Set correct orientation.
if (mRestoreOrientation == null) {
mRestoreOrientation = mActivity.getRequestedOrientation();
}
mRestoreSystemUiVisibility = true;
mActivity.getWindow().getAttributes().rotationAnimation =
WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT;
mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
}
@TargetApi(Build.VERSION_CODES.KITKAT)
private void restoreWindowMode() {
ScreenOrientationProvider.setOrientationDelegate(null);
mActivity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
// Restore orientation.
if (mRestoreOrientation != null) mActivity.setRequestedOrientation(mRestoreOrientation);
mRestoreOrientation = null;
// Restore system UI visibility.
if (mRestoreSystemUiVisibility) {
int flags = mActivity.getWindow().getDecorView().getSystemUiVisibility();
mActivity.getWindow().getDecorView().setSystemUiVisibility(
flags & ~VrDelegate.VR_SYSTEM_UI_FLAGS);
}
mRestoreSystemUiVisibility = false;
if (mActivity.getCompositorViewHolder() != null) {
mActivity.getCompositorViewHolder().onExitVr();
}
mActivity.getWindow().getAttributes().rotationAnimation =
WindowManager.LayoutParams.ROTATION_ANIMATION_ROTATE;
}
/* package */ boolean canEnterVr() {
if (getVrSupportLevel() <= VrSupportLevel.VR_NEEDS_UPDATE) return false;
// If VR browsing is not enabled and this is not a WebXR request, then return false.
boolean presenting = mRequestedWebVr || mActivateFromHeadsetInsertion;
if (!isVrBrowsingEnabled() && !presenting) return false;
return true;
}
@CalledByNative
private void presentRequested() {
if (VrDelegate.DEBUG_LOGS) Log.i(TAG, "WebVR page requested presentation");
mRequestedWebVr = true;
if (VrModuleProvider.getDelegate().bootsToVr() && !mInVr) {
maybeSetPresentResult(false);
return;
}
switch (enterVrInternal()) {
case EnterVRResult.NOT_NECESSARY:
mVrShell.setWebVrModeEnabled(true);
maybeSetPresentResult(true);
break;
case EnterVRResult.CANCELLED:
maybeSetPresentResult(false);
break;
case EnterVRResult.REQUESTED:
break;
case EnterVRResult.SUCCEEDED:
maybeSetPresentResult(true);
break;
default:
Log.e(TAG, "Unexpected enum.");
}
}
/**
* Enters VR Shell if necessary, displaying browser UI and tab contents in VR.
*/
@EnterVRResult
private int enterVrInternal() {
if (mPaused) return EnterVRResult.CANCELLED;
if (mInVr) return EnterVRResult.NOT_NECESSARY;
// Update VR support level as it can change at runtime
maybeUpdateVrSupportLevel();
if (!canEnterVr()) return EnterVRResult.CANCELLED;
if (getVrSupportLevel() == VrSupportLevel.VR_DAYDREAM
&& isDaydreamCurrentViewerInternal()) {
// TODO(mthiesse): This is a workaround for b/66486878 (see also crbug.com/767594).
// We have to trigger the DON flow before setting VR mode enabled to prevent the DON
// flow from failing on the S8/S8+.
// Due to b/66493165, we also can't create our VR UI before the density has changed,
// so we can't trigger the DON flow by resuming the GvrLayout. This basically means that
// calling launchInVr on ourself is the only viable option for getting into VR on the
// S8/S8+.
// This also fixes the issue tracked in crbug.com/767944, so this should not be removed
// until the root cause of that has been found and fixed.
getVrDaydreamApi().launchInVr(getEnterVrPendingIntent(mActivity));
mProbablyInDon = true;
} else {
enterVr(false);
}
return EnterVRResult.REQUESTED;
}
private void requestToExitVrInternal(OnExitVrRequestListener listener,
@UiUnsupportedMode int reason, boolean showExitPromptBeforeDoff) {
assert listener != null;
if (VrModuleProvider.getDelegate().bootsToVr()) {
setVrModeEnabled(mActivity, false);
listener.onSucceeded();
return;
}
// If we are currently processing another request, deny the request.
if (mOnExitVrRequestListener != null) {
listener.onDenied();
return;
}
mOnExitVrRequestListener = listener;
mShowingExitVrPrompt = showExitPromptBeforeDoff;
mVrShell.requestToExitVr(reason, showExitPromptBeforeDoff);
}
private void exitWebVRAndClearState() {
exitWebVRPresent();
mRequestedWebVr = false;
}
@CalledByNative
/* package */ void exitWebVRPresent() {
if (!mInVr) return;
if (!isVrBrowsingEnabled()) {
if (isDaydreamCurrentViewerInternal()) {
getVrDaydreamApi().launchVrHomescreen();
} else {
shutdownVr(true /* disableVrMode */, true /* stayingInChrome */);
}
} else {
mVrBrowserUsed = true;
mVrShell.setWebVrModeEnabled(false);
}
}
private boolean cancelStartupAnimationIfNeeded() {
if (!mNeedsAnimationCancel) return false;
if (VrDelegate.DEBUG_LOGS) Log.e(TAG, "canceling startup animation");
mCancellingEntryAnimation = true;
Bundle options = ActivityOptions.makeCustomAnimation(mActivity, 0, 0).toBundle();
Intent intent = VrModuleProvider.getIntentDelegate().setupVrIntent(
new Intent(mActivity, VrCancelAnimationActivity.class));
// We don't want this to run in a new task stack, or we may end up resuming the wrong
// Activity when the VrCancelAnimationActivity finishes.
intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_NEW_TASK);
mActivity.startActivity(intent, options);
mNeedsAnimationCancel = false;
return true;
}
private boolean maybeExitVrToUpdateVrServices() {
if (!mDonSucceeded || getVrSupportLevel() != VrSupportLevel.VR_NEEDS_UPDATE) {
return false;
}
// This means that we were started in VR mode but the vr services are out of date. We should
// exit VR and prompt the user to update.
if (VrDelegate.DEBUG_LOGS) Log.i(TAG, "VR services update needed");
mShowingDoffForGvrUpdate = true;
showDoff(false /* optional */);
mDonSucceeded = false;
return true;
}
@VisibleForTesting
protected void onResume() {
if (VrDelegate.DEBUG_LOGS) Log.i(TAG, "onResume");
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) return;
if (!mTestWorkaroundDontCancelVrEntryOnResume) {
mMaybeActivateAfterHeadsetInsertion = false;
}
if (mNeedsAnimationCancel) {
// At least on some devices, like the Samsung S8+, a Window animation is run after our
// Activity is shown that fades between a stale screenshot from before pausing to the
// currently rendered content. It's impossible to cancel window animations, and in order
// to modify the animation we would need to set up the desired animations before
// calling setContentView, which we can't do because it would affect non-VR usage.
// To work around this, we keep the stay_hidden animation active until the window
// animation of the stale screenshot finishes and our black overlay is shown. We then
// cancel the stay_hidden animation, revealing our black overlay, which we then replace
// with VR UI.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return;
// Just in case any platforms/users modify the window animation scale, we'll multiply
// our wait time by that scale value.
float scale = Settings.Global.getFloat(
mActivity.getContentResolver(), Settings.Global.WINDOW_ANIMATION_SCALE, 1.0f);
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
cancelStartupAnimationIfNeeded();
}
}, (long) (WINDOW_FADE_ANIMATION_DURATION_MS * scale));
return;
}
mPaused = false;
mCancellingEntryAnimation = false;
// We call resume here to be symmetric with onPause in case we get paused/resumed without
// being hidden/shown. However, we still don't want to resume if we're not visible to avoid
// doing VR rendering that won't be seen.
if (mInVr && mVisible) mVrShell.resume();
maybeUpdateVrSupportLevel();
// Shouldn't handle VR Intents pre-Daydream.
assert(getVrSupportLevel() == VrSupportLevel.VR_DAYDREAM || !mStartedFromVrIntent);
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
try {
if (mNativeVrShellDelegate != 0) nativeOnResume(mNativeVrShellDelegate);
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
if (isVrBrowsingEnabled()) {
// Perform slow initialization asynchronously.
new Handler().post(new Runnable() {
@Override
public void run() {
if (!mPaused) registerDaydreamIntent(mActivity);
if (!sRegisteredVrAssetsComponent) {
registerVrAssetsComponentIfDaydreamUser(isDaydreamCurrentViewerInternal());
}
}
});
}
if (mDonSucceeded) {
handleDonFlowSuccess();
} else {
if (mProbablyInDon && !mTestWorkaroundDontCancelVrEntryOnResume) {
// This means the user backed out of the DON flow, and we won't be entering VR.
maybeSetPresentResult(false);
shutdownVr(true, false);
}
// If we were resumed at the wrong density, we need to trigger activity recreation.
if (!mInVr && mExpectedDensityChange != 0
&& (mActivity.getResources().getConfiguration().densityDpi
!= mExpectedDensityChange)) {
mActivity.recreate();
}
}
mProbablyInDon = false;
mShowVrServicesUpdatePrompt = null;
runPendingExitVrTask();
}
private void handleDonFlowSuccess() {
setWindowModeForVr();
if (mInVr) {
maybeSetPresentResult(true);
mDonSucceeded = false;
assert !mActivateFromHeadsetInsertion;
return;
}
if (maybeExitVrToUpdateVrServices()) return;
// If we fail to enter VR when we should have entered VR, return to the home screen.
if (!enterVrAfterDon()) {
cancelPendingVrEntry();
getVrDaydreamApi().launchVrHomescreen();
}
}
// Android lifecycle doesn't guarantee that this will be called after onResume (though it
// will usually be), so make sure anything we do here can happen before or after
// onResume.
private void onActivityShown() {
mVisible = true;
// Only resume VrShell once we're visible so that we don't start rendering before being
// visible and delaying startup.
if (mInVr && !mPaused) mVrShell.resume();
}
private void onActivityHidden() {
mVisible = false;
// In case we're hidden before onPause is called, we pause here. Duplicate calls to pause
// are safe.
if (mInVr) mVrShell.pause();
}
private void onPause() {
if (VrDelegate.DEBUG_LOGS) Log.i(TAG, "onPause");
mPaused = true;
if (mCancellingEntryAnimation) return;
unregisterDaydreamIntent();
if (getVrSupportLevel() <= VrSupportLevel.VR_NEEDS_UPDATE) return;
if (mMaybeActivateAfterHeadsetInsertion) {
mClearMaybeActivateHandler.removeCallbacksAndMessages(null);
}
if (mInVr) mVrShell.pause();
if (mNativeVrShellDelegate != 0) nativeOnPause(mNativeVrShellDelegate);
mIsDaydreamCurrentViewer = null;
}
private void onStart() {
mMaybeActivateAfterHeadsetInsertion = false;
if (mDonSucceeded) setWindowModeForVr();
// This handles the case where Chrome was paused in VR (ie the user navigated to DD home or
// something), then exited VR and resumed Chrome in 2D. Chrome is still showing VR UI but
// the user is no longer in a VR session.
if (mInVr && !isInVrSession()) {
shutdownVr(true, false);
}
// Note that we do not turn VR mode on here for two reasons.
// 1. If we're in VR, it should already be on and won't get turned off until we explicitly
// turn it off for this Activity.
// 2. Turning VR mode on breaks popup showing code, which relies on VR mode sometimes being
// off while in VR.
}
private void onStop() {
if (VrDelegate.DEBUG_LOGS) Log.i(TAG, "onStop");
assert !mCancellingEntryAnimation;
}
private boolean onBackPressedInternal() {
if (getVrSupportLevel() <= VrSupportLevel.VR_NEEDS_UPDATE) return false;
cancelPendingVrEntry();
if (!mInVr) return false;
// Back button should be handled the same way as the close button.
getVrCloseButtonListener().run();
return true;
}
/**
* @return Whether the user is currently seeing the DOFF screen.
*/
/* package */ boolean showDoff(boolean optional) {
assert !mShowingDaydreamDoff;
if (!isDaydreamCurrentViewerInternal()) return false;
if (supports2dInVr()) {
setVrModeEnabled(mActivity, false);
callOnExitVrRequestListener(true);
return true;
}
try {
if (getVrDaydreamApi().exitFromVr(mActivity, EXIT_VR_RESULT, new Intent())) {
mShowingDaydreamDoff = true;
mDoffOptional = optional;
return true;
}
} catch (IllegalArgumentException | SecurityException e) {
// DOFF calls can unpredictably throw exceptions if VrCore doesn't think Chrome is
// the active component, for example.
}
if (!optional) getVrDaydreamApi().launchVrHomescreen();
return false;
}
private void onExitVrResult(boolean success) {
if (VrDelegate.DEBUG_LOGS) Log.i(TAG, "returned from DOFF, success: " + success);
// We may have manually handled the exit early by swapping to another Chrome activity that
// supports VR while in the DOFF activity. If that happens we want to exit early when the
// real DOFF flow calls us back.
if (!mShowingDaydreamDoff) return;
// If Doff is not optional and user backed out, launch DD home. We can't re-trigger doff
// here because we're not yet the active VR component and Daydream will throw a Security
// Exception.
if (!mDoffOptional && !success) getVrDaydreamApi().launchVrHomescreen();
mShowingDaydreamDoff = false;
if (mShowingDoffForGvrUpdate) mShowVrServicesUpdatePrompt = success;
if (success) shutdownVr(true /* disableVrMode */, true /* stayingInChrome */);
callOnExitVrRequestListener(success);
mShowingDoffForGvrUpdate = false;
}
// Caches whether the current viewer is Daydream for performance.
private boolean isDaydreamCurrentViewerInternal() {
if (mIsDaydreamCurrentViewer == null) {
mIsDaydreamCurrentViewer = getVrDaydreamApi().isDaydreamCurrentViewer();
}
return mIsDaydreamCurrentViewer;
}
@CalledByNative
private void setListeningForWebVrActivate(boolean listening) {
if (VrDelegate.DEBUG_LOGS)
Log.i(TAG, "WebVR page listening for vrdisplayactivate: " + listening);
// Non-Daydream devices do not have the concept of activation.
if (getVrSupportLevel() != VrSupportLevel.VR_DAYDREAM) return;
if (mListeningForWebVrActivate == listening) return;
mListeningForWebVrActivate = listening;
if (mListeningForWebVrActivate) {
registerDaydreamIntent(mActivity);
if (mNeedsAnimationCancel || mCancellingEntryAnimation) return;
if (mActivateFromHeadsetInsertion) {
// Dispatch vrdisplayactivate so that the WebVr page can call requestPresent
// to start presentation.
nativeDisplayActivate(mNativeVrShellDelegate);
mActivateFromHeadsetInsertion = false;
}
} else {
if (!canEnterVr()) unregisterDaydreamIntent();
// When the active web page has a vrdisplayactivate event handler, and the phone is
// inserted into the headset, a vrdisplayactive event should be fired once DON flow
// is finished. However, the DON flow will pause our activity, which makes the active
// page lose focus and report that it can't handle displayActivate.
// Because of the order of onPause events running, we can't check for
// mListeningForWebVrActivate in onPause() as we may or may not already have lost focus.
// We need to remember that
mMaybeActivateAfterHeadsetInsertion = !mInVr && !mRequestedWebVr;
if (!mPaused) {
mClearMaybeActivateHandler.post(
() -> { mMaybeActivateAfterHeadsetInsertion = false; });
}
}
}
private void cancelPendingVrEntry() {
if (VrDelegate.DEBUG_LOGS) Log.i(TAG, "cancelPendingVrEntry");
VrModuleProvider.getDelegate().removeBlackOverlayView(mActivity, false /* animate */);
mDonSucceeded = false;
mActivateFromHeadsetInsertion = false;
maybeSetPresentResult(false);
if (!mShowingDaydreamDoff) {
setVrModeEnabled(mActivity, false);
restoreWindowMode();
}
}
/**
* Exits VR Shell, performing all necessary cleanup.
*/
private void shutdownVr(boolean disableVrMode, boolean stayingInChrome) {
if (VrDelegate.DEBUG_LOGS) Log.i(TAG, "shuttdown VR");
cancelPendingVrEntry();
if (!mInVr) return;
if (mShowingDaydreamDoff) {
onExitVrResult(true);
return;
}
mInVr = false;
// Some Samsung devices change the screen density after exiting VR mode which causes
// us to restart Chrome with the VR intent that originally started it. We don't want to
// enable VR mode again, so we remove VR specific extras.
VrModuleProvider.getIntentDelegate().removeVrExtras(mActivity.getIntent());
// The user has exited VR.
RecordUserAction.record("VR.DOFF");
if (disableVrMode) setVrModeEnabled(mActivity, false);
// We get crashes on Android K related to surfaces if we manipulate the view hierarchy while
// finishing.
if (mActivity.isFinishing()) return;
restoreWindowMode();
mVrShell.pause();
removeVrViews();
destroyVrShell();
promptForFeedbackIfNeeded(stayingInChrome);
// User exited VR (via something like the system back button) while looking at the exit VR
// prompt.
if (mShowingExitVrPrompt) callOnExitVrRequestListener(true);
VrModuleProvider.onExitVr();
}
private void callOnExitVrRequestListener(boolean success) {
if (mOnExitVrRequestListener != null) {
if (success) {
mOnExitVrRequestListener.onSucceeded();
} else {
mOnExitVrRequestListener.onDenied();
}
}
mOnExitVrRequestListener = null;
}
/* package */ void onExitVrRequestResult(boolean shouldExit) {
assert mOnExitVrRequestListener != null;
mShowingExitVrPrompt = false;
if (shouldExit) {
mExitedDueToUnsupportedMode = true;
if (!showDoff(true /* optional */)) callOnExitVrRequestListener(false);
} else {
callOnExitVrRequestListener(false);
}
}
/**
* Returns the callback for the user-triggered close button to exit VR mode.
*/
/* package */ Runnable getVrCloseButtonListener() {
if (mCloseButtonListener != null) return mCloseButtonListener;
mCloseButtonListener = new Runnable() {
@Override
public void run() {
shutdownVr(true /* disableVrMode */, true /* stayingInChrome */);
}
};
return mCloseButtonListener;
}
/**
* Returns the callback for the user-triggered close button to exit VR mode.
*/
/* package */ Runnable getVrSettingsButtonListener() {
if (mSettingsButtonListener != null) return mSettingsButtonListener;
mSettingsButtonListener = new Runnable() {
@Override
public void run() {
shutdownVr(true /* disableVrMode */, false /* stayingInChrome */);
// Launch Daydream settings.
GvrUiLayout.launchOrInstallGvrApp(mActivity);
}
};
return mSettingsButtonListener;
}
/**
* Prompts the user to enter feedback for their VR Browsing experience.
*/
private void promptForFeedbackIfNeeded(boolean stayingInChrome) {
// We only prompt for feedback if:
// 1) The user hasn't explicitly opted-out of it in the past
// 2) The user has performed VR browsing
// 3) The user is exiting VR and going back into 2D Chrome
// 4) We're not exiting to complete an unsupported VR action in 2D (e.g. viewing PageInfo)
// 5) Every n'th visit (where n = mFeedbackFrequency)
if (!activitySupportsExitFeedback(mActivity)) return;
if (!stayingInChrome) return;
if (VrFeedbackStatus.getFeedbackOptOut()) return;
if (!mVrBrowserUsed) return;
if (mExitedDueToUnsupportedMode) return;
int exitCount = VrFeedbackStatus.getUserExitedAndEntered2DCount();
VrFeedbackStatus.setUserExitedAndEntered2DCount((exitCount + 1) % mFeedbackFrequency);
if (exitCount > 0) return;
promptForFeedback(mActivity.getActivityTab());
}
/* package */ void promptForKeyboardUpdate() {
mCachedGvrKeyboardPackageVersion = getGvrKeyboardPackageVersion();
mActivity.startActivityForResult(
new Intent(Intent.ACTION_VIEW, Uri.parse(GVR_KEYBOARD_MARKET_URI)),
GVR_KEYBOARD_UPDATE_RESULT);
}
@VisibleForTesting
protected boolean canLaunch2DIntentsInternal() {
return supports2dInVr() && !sVrModeEnabledActivitys.contains(sInstance.mActivity);
}
@VisibleForTesting
protected boolean createVrShell() {
assert mVrShell == null;
if (mActivity.getCompositorViewHolder() == null) return false;
TabModelSelector tabModelSelector = mActivity.getTabModelSelector();
if (tabModelSelector == null) return false;
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
try {
mVrShell = new VrShell(mActivity, this, tabModelSelector);
} catch (VrUnsupportedException e) {
return false;
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
return true;
}
private void addVrViews() {
FrameLayout decor = (FrameLayout) mActivity.getWindow().getDecorView();
LayoutParams params = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
decor.addView(mVrShell.getContainer(), params);
// If the overlay exists, make sure to hide the GvrLayout behind it.
View overlay = mActivity.getWindow().findViewById(R.id.vr_overlay_view);
if (overlay != null) overlay.bringToFront();
mActivity.onEnterVr();
}
@VisibleForTesting
protected boolean isBlackOverlayVisible() {
View overlay = mActivity.getWindow().findViewById(R.id.vr_overlay_view);
return overlay != null;
}
private void removeVrViews() {
mActivity.onExitVr();
FrameLayout decor = (FrameLayout) mActivity.getWindow().getDecorView();
decor.removeView(mVrShell.getContainer());
}
/**
* Clean up VrShell, and associated native objects.
*/
private void destroyVrShell() {
if (mVrShell != null) {
mVrShell.getContainer().setOnSystemUiVisibilityChangeListener(null);
mVrShell.teardown();
mVrShell = null;
}
}
/**
* @param api The VrDaydreamApi object this delegate will use instead of the default one
*/
@VisibleForTesting
protected void overrideDaydreamApi(VrDaydreamApi api) {
sVrDaydreamApi = api;
}
/**
* @return The VrShell for the VrShellDelegate instance
*/
@VisibleForTesting
protected VrShell getVrShell() {
return mVrShell;
}
/**
* @param versionChecker The VrCoreVersionChecker object this delegate will use
*/
@VisibleForTesting
protected void overrideVrCoreVersionChecker(VrCoreVersionChecker versionChecker) {
sVrCoreVersionChecker = versionChecker;
updateVrSupportLevel(null);
}
/**
* @param frequency Sets how often to show the feedback prompt.
*/
@VisibleForTesting
protected void setFeedbackFrequency(int frequency) {
mFeedbackFrequency = frequency;
}
@VisibleForTesting
protected boolean isListeningForWebVrActivate() {
return mListeningForWebVrActivate;
}
@VisibleForTesting
protected boolean isVrEntryComplete() {
return mInVr && !mProbablyInDon && getVrShell().hasUiFinishedLoading();
}
@VisibleForTesting
protected boolean isShowingDoff() {
return mShowingDaydreamDoff;
}
@VisibleForTesting
protected void onBroadcastReceived() {}
@VisibleForTesting
protected void setExpectingIntent(boolean expectingIntent) {}
/**
* @return Pointer to the native VrShellDelegate object.
*/
@CalledByNative
private long getNativePointer() {
return mNativeVrShellDelegate;
}
@CalledByNative
private long getVrCoreInfo() {
assert getVrCoreVersionChecker() != null;
return getVrCoreVersionChecker().makeNativeVrCoreInfo();
}
private void destroy() {
if (sInstance == null) return;
shutdownVr(false /* disableVrMode */, false /* stayingInChrome */);
if (mNativeVrShellDelegate != 0) nativeDestroy(mNativeVrShellDelegate);
mNativeVrShellDelegate = 0;
sInstance = null;
}
private native long nativeInit();
private static native void nativeOnLibraryAvailable();
private native void nativeSetPresentResult(long nativeVrShellDelegate, boolean result);
private native void nativeRecordVrStartAction(long nativeVrShellDelegate, int startAction);
private native void nativeDisplayActivate(long nativeVrShellDelegate);
private native void nativeOnPause(long nativeVrShellDelegate);
private native void nativeOnResume(long nativeVrShellDelegate);
private native void nativeDestroy(long nativeVrShellDelegate);
private static native void nativeRegisterVrAssetsComponent();
}