chromium/chromecast/browser/android/apk/src/org/chromium/chromecast/shell/CastWebContentsSurfaceHelper.java

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chromecast.shell;

import android.content.Intent;
import android.content.IntentFilter;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;

import org.chromium.base.Log;
import org.chromium.chromecast.base.Both;
import org.chromium.chromecast.base.Controller;
import org.chromium.chromecast.base.Observable;
import org.chromium.chromecast.base.Observer;
import org.chromium.chromecast.base.Unit;
import org.chromium.content_public.browser.MediaSession;
import org.chromium.content_public.browser.WebContents;

import java.util.function.Consumer;

/**
 * A util class for CastWebContentsActivity to show WebContents on its views.
 * <p>
 * This class is to help the activity class to work with CastContentWindowAndroid, which will start
 * a new instance of the activity. If the CastContentWindowAndroid is destroyed,
 * CastWebContentsActivity should be stopped.
 * <p>
 * Similarly, if CastWebContentsActivity is stopped, eg. the user goes "back" or "home" via the
 * remote (or a gesture on touch-compatible devices), CastContentWindowAndroid should be notified
 * by intent.
 */
class CastWebContentsSurfaceHelper {
    private static final String TAG = "CastWebContents";

    private static final int TEARDOWN_GRACE_PERIOD_TIMEOUT_MILLIS = 300;

    // Activated between constructor and onDestroy().
    private final Controller<Unit> mCreatedState = new Controller<>();
    // Activated when we have WebContents to display.
    private final Controller<StartParams> mStartParamsState = new Controller<>();

    private String mSessionId;
    private MediaSessionGetter mMediaSessionGetter;

    // TODO(vincentli) interrupt touch event from Fragment's root view when it's false.
    private boolean mTouchInputEnabled;

    public static class StartParams {
        public final Uri uri;
        public final WebContents webContents;
        public final boolean shouldRequestAudioFocus;
        public final boolean touchInputEnabled;

        public StartParams(Uri uri, WebContents webContents, boolean shouldRequestAudioFocus,
                boolean touchInputEnabled) {
            this.uri = uri;
            this.webContents = webContents;
            this.shouldRequestAudioFocus = shouldRequestAudioFocus;
            this.touchInputEnabled = touchInputEnabled;
        }

        @Override
        public boolean equals(Object other) {
            if (other instanceof StartParams) {
                StartParams that = (StartParams) other;
                return this.uri.equals(that.uri) && this.webContents.equals(that.webContents)
                        && this.shouldRequestAudioFocus == that.shouldRequestAudioFocus
                        && this.touchInputEnabled == that.touchInputEnabled;
            }
            return false;
        }

        public static StartParams fromBundle(Bundle bundle) {
            final String uriString = CastWebContentsIntentUtils.getUriString(bundle);
            if (uriString == null) {
                Log.i(TAG, "Intent without uri received!");
                return null;
            }
            final Uri uri = Uri.parse(uriString);
            if (uri == null) {
                Log.i(TAG, "Invalid URI string: %s", uriString);
                return null;
            }
            bundle.setClassLoader(WebContents.class.getClassLoader());
            final WebContents webContents = CastWebContentsIntentUtils.getWebContents(bundle);
            if (webContents == null) {
                Log.e(TAG, "Received null WebContents in bundle.");
                return null;
            }

            final boolean shouldRequestAudioFocus =
                    CastWebContentsIntentUtils.shouldRequestAudioFocus(bundle);
            final boolean touchInputEnabled = CastWebContentsIntentUtils.isTouchable(bundle);
            return new StartParams(uri, webContents, shouldRequestAudioFocus, touchInputEnabled);
        }
    }

    /**
     * @param webContentsView A Observer that displays incoming WebContents.
     * @param finishCallback Invoked to tell host to finish.
     */
    CastWebContentsSurfaceHelper(Observer<WebContents> webContentsView,
            Consumer<Uri> finishCallback, Observable<Unit> surfaceAvailable) {
        Handler handler = new Handler();

        mMediaSessionGetter = MediaSession::fromWebContents;

        Observable<Uri> uriState = mStartParamsState.map(params -> params.uri);
        Controller<WebContents> webContentsState = new Controller<>();
        mStartParamsState.map(params -> params.webContents)
                .subscribe(Observer.onOpen(webContentsState::set));
        mCreatedState.subscribe(Observer.onClose(x -> webContentsState.reset()));

        // Receive broadcasts indicating the screen turned off while we have active WebContents.
        uriState.subscribe((Uri uri) -> {
            IntentFilter filter = new IntentFilter();
            filter.addAction(CastIntents.ACTION_SCREEN_OFF);
            return new LocalBroadcastReceiverScope(filter, (Intent intent) -> {
                mStartParamsState.reset();
                webContentsState.reset();
                maybeFinishLater(handler, () -> finishCallback.accept(uri));
            });
        });

        // Receive broadcasts requesting to tear down this app while we have a valid URI.
        uriState.subscribe((Uri uri) -> {
            IntentFilter filter = new IntentFilter();
            filter.addAction(CastIntents.ACTION_STOP_WEB_CONTENT);
            return new LocalBroadcastReceiverScope(filter, (Intent intent) -> {
                String intentUri = CastWebContentsIntentUtils.getUriString(intent);
                Log.d(TAG, "Intent action=" + intent.getAction() + "; URI=" + intentUri);
                if (!uri.toString().equals(intentUri)) {
                    Log.d(TAG, "Current URI=" + uri + "; intent URI=" + intentUri);
                    return;
                }
                mStartParamsState.reset();
                webContentsState.reset();
                maybeFinishLater(handler, () -> finishCallback.accept(uri));
            });
        });

        // Receive broadcasts indicating that touch input should be enabled.
        // TODO(yyzhong) Handle this intent in an external activity hosting a cast fragment as well.
        uriState.subscribe((Uri uri) -> {
            IntentFilter filter = new IntentFilter();
            filter.addAction(CastWebContentsIntentUtils.ACTION_ENABLE_TOUCH_INPUT);
            return new LocalBroadcastReceiverScope(filter, (Intent intent) -> {
                String intentUri = CastWebContentsIntentUtils.getUriString(intent);
                Log.d(TAG, "Intent action=" + intent.getAction() + "; URI=" + intentUri);
                if (!uri.toString().equals(intentUri)) {
                    Log.d(TAG, "Current URI=" + uri + "; intent URI=" + intentUri);
                    return;
                }
                mTouchInputEnabled = CastWebContentsIntentUtils.isTouchable(intent);
            });
        });

        // webContentsView is responsible for displaying each new WebContents.
        webContentsState.subscribe(webContentsView);
        webContentsState.and(surfaceAvailable)
                .map(Both::getFirst)
                .subscribe(Observer.onClose(WebContents::tearDownDialogOverlays));

        // Take audio focus when receiving new WebContents if requested. In most cases, we do want
        // to take audio focus when starting the Cast UI, but there are some exceptions, such as
        // when launching a remote control app or when starting an app by voice request, when the
        // TTS may still be retaining audio focus.
        mStartParamsState
                .filter(params -> params.shouldRequestAudioFocus)
                .map(params -> mMediaSessionGetter.get(params.webContents))
                .subscribe(Observer.onOpen(MediaSession::requestSystemAudioFocus));

        // When onDestroy() is called after onNewStartParams(), log and reset StartParams states.
        uriState.andThen(Observable.not(mCreatedState))
                .map(Both::getFirst)
                .subscribe(Observer.onOpen((Uri uri) -> {
                    Log.d(TAG, "onDestroy: " + uri);
                    mStartParamsState.reset();
                }));

        // Cache relevant fields from StartParams in instance variables.
        mStartParamsState.subscribe(Observer.onOpen(params -> {
            mTouchInputEnabled = params.touchInputEnabled;
            mSessionId = params.uri.getPath();
        }));

        mCreatedState.set(Unit.unit());
    }

    void onNewStartParams(final StartParams params) {
        Log.d(TAG, "onNewStartParams: content_uri=" + params.uri);
        mStartParamsState.set(params);
    }

    // Closes this activity if a new WebContents is not being displayed.
    private void maybeFinishLater(Handler handler, Runnable callback) {
        final String currentSessionId = mSessionId;
        handler.postDelayed(() -> {
            if (currentSessionId != null && currentSessionId.equals(mSessionId)) {
                callback.run();
            }
        }, TEARDOWN_GRACE_PERIOD_TIMEOUT_MILLIS);
    }

    // Destroys all resources. After calling this method, this object must be dropped.
    void onDestroy() {
        mCreatedState.reset();
    }

    String getSessionId() {
        return mSessionId;
    }

    boolean isTouchInputEnabled() {
        return mTouchInputEnabled;
    }

    void setMediaSessionGetterForTesting(MediaSessionGetter mediaSessionGetter) {
        mMediaSessionGetter = mediaSessionGetter;
    }

    interface MediaSessionGetter {
        MediaSession get(WebContents webContents);
    }
}