chromium/components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeController.java

// Copyright 2021 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.components.messages;

import androidx.annotation.Nullable;

import org.chromium.base.ActivityState;
import org.chromium.components.messages.MessageScopeChange.ChangeType;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.content_public.browser.Visibility;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.WebContentsObserver;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.base.WindowAndroid.ActivityStateObserver;
import org.chromium.url.GURL;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

/**
 * Observe the webContents to notify queue manager of proper scope changes of {@link
 * MessageScopeType#NAVIGATION} and {@link MessageScopeType#WEB_CONTENTS}. Observe the windowAndroid
 * to notify queue manager of proper scope changes of {@link MessageScopeType#WINDOW}.
 */
class ScopeChangeController {
    /** A delegate which can handle the scope change. */
    public interface Delegate {
        void onScopeChange(MessageScopeChange change);
    }

    interface ScopeObserver {
        void destroy();

        boolean isActive();
    }

    private final Delegate mDelegate;
    private final Map<ScopeKey, ScopeObserver> mObservers;

    public ScopeChangeController(Delegate delegate) {
        mDelegate = delegate;
        mObservers = new HashMap<>();
    }

    /**
     * Notify every time a message is enqueued to a scope whose queue was previously empty.
     * @param scopeKey The scope key of the scope which the first message is enqueued.
     */
    void firstMessageEnqueued(ScopeKey scopeKey) {
        assert !mObservers.containsKey(scopeKey) : "This scope key has already been observed.";
        ScopeObserver observer =
                scopeKey.scopeType == MessageScopeType.WINDOW
                        ? new WindowScopeObserver(mDelegate, scopeKey)
                        : new NavigationWebContentsScopeObserver(mDelegate, scopeKey);
        mObservers.put(scopeKey, observer);
    }

    /**
     * Called when all Messages for the given {@code scopeKey} have been dismissed or removed.
     * @param scopeKey The scope key of the scope which the last message is dismissed.
     */
    void lastMessageDismissed(ScopeKey scopeKey) {
        ScopeObserver observer = mObservers.remove(scopeKey);
        observer.destroy();
    }

    boolean isActive(ScopeKey scopeKey) {
        if (!mObservers.containsKey(scopeKey)) return false;
        var scopeObserver = mObservers.get(scopeKey);
        return scopeObserver.isActive();
    }

    /**
     * This handles both navigation type and webContents type. Only navigation type
     * will destroy scopes on page navigation.
     */
    static class NavigationWebContentsScopeObserver extends WebContentsObserver
            implements ScopeObserver {
        private final Delegate mDelegate;
        private final ScopeKey mScopeKey;
        // TODO(crbug.com/40230391): Replace GURL with Origin.
        private GURL mLastVisitedUrl;
        private boolean mIsActive;

        public NavigationWebContentsScopeObserver(Delegate delegate, ScopeKey scopeKey) {
            super(scopeKey.webContents);
            mDelegate = delegate;
            mScopeKey = scopeKey;
            WebContents webContents = scopeKey.webContents;
            int changeType =
                    webContents != null && webContents.getVisibility() == Visibility.VISIBLE
                            ? ChangeType.ACTIVE
                            : ChangeType.INACTIVE;
            mDelegate.onScopeChange(
                    new MessageScopeChange(mScopeKey.scopeType, scopeKey, changeType));
            mIsActive = changeType == ChangeType.ACTIVE;
        }

        @Override
        public void wasShown() {
            mDelegate.onScopeChange(
                    new MessageScopeChange(mScopeKey.scopeType, mScopeKey, ChangeType.ACTIVE));
            mIsActive = true;
        }

        @Override
        public void wasHidden() {
            mDelegate.onScopeChange(
                    new MessageScopeChange(mScopeKey.scopeType, mScopeKey, ChangeType.INACTIVE));
            mIsActive = false;
        }

        @Override
        public void didFinishNavigationInPrimaryMainFrame(NavigationHandle navigationHandle) {
            if (mScopeKey.scopeType != MessageScopeType.NAVIGATION
                    && mScopeKey.scopeType != MessageScopeType.ORIGIN) {
                return;
            }

            if (navigationHandle.isSameDocument()
                    || !navigationHandle.hasCommitted()
                    || navigationHandle.isReload()) {
                return;
            }

            if (mScopeKey.scopeType == MessageScopeType.ORIGIN) {
                if (mLastVisitedUrl == null
                        || originEquals(mLastVisitedUrl, navigationHandle.getUrl())) {
                    mLastVisitedUrl = navigationHandle.getUrl();
                    return;
                }
                mLastVisitedUrl = navigationHandle.getUrl();
            }
            destroy();
        }

        @Override
        public void destroy() {
            super.destroy();
            // #destroy will remove the observers.
            mDelegate.onScopeChange(
                    new MessageScopeChange(mScopeKey.scopeType, mScopeKey, ChangeType.DESTROY));
            mIsActive = false;
        }

        @Override
        public boolean isActive() {
            return mIsActive;
        }

        @Override
        public void onTopLevelNativeWindowChanged(@Nullable WindowAndroid windowAndroid) {
            super.onTopLevelNativeWindowChanged(windowAndroid);
            // Dismiss the message if it is moved to another window.
            // TODO(crbug.com/40764577): This is a temporary solution; remove this when
            // tab-reparent is fully supported.
            destroy();
        }

        private boolean originEquals(GURL url1, GURL url2) {
            if (url1 == null || url2 == null) return false;
            return Objects.equals(url1.getScheme(), url2.getScheme())
                    && Objects.equals(url1.getHost(), url2.getHost())
                    && Objects.equals(url1.getPort(), url2.getPort());
        }
    }

    static class WindowScopeObserver implements ScopeObserver, ActivityStateObserver {
        private final Delegate mDelegate;
        private final ScopeKey mScopeKey;
        private boolean mIsActive;

        public WindowScopeObserver(Delegate delegate, ScopeKey scopeKey) {
            mDelegate = delegate;
            mScopeKey = scopeKey;
            assert scopeKey.scopeType == MessageScopeType.WINDOW
                    : "WindowScopeObserver should only monitor window scope events.";
            WindowAndroid windowAndroid = scopeKey.windowAndroid;
            windowAndroid.addActivityStateObserver(this);
            @ChangeType
            int changeType =
                    windowAndroid.getActivityState() == ActivityState.RESUMED
                            ? ChangeType.ACTIVE
                            : ChangeType.INACTIVE;
            mDelegate.onScopeChange(
                    new MessageScopeChange(scopeKey.scopeType, scopeKey, changeType));
            mIsActive = changeType == ChangeType.ACTIVE;
        }

        @Override
        public void onActivityPaused() {
            mDelegate.onScopeChange(
                    new MessageScopeChange(mScopeKey.scopeType, mScopeKey, ChangeType.INACTIVE));
            mIsActive = false;
        }

        @Override
        public void onActivityResumed() {
            mDelegate.onScopeChange(
                    new MessageScopeChange(mScopeKey.scopeType, mScopeKey, ChangeType.ACTIVE));
            mIsActive = true;
        }

        @Override
        public void onActivityDestroyed() {
            mDelegate.onScopeChange(
                    new MessageScopeChange(mScopeKey.scopeType, mScopeKey, ChangeType.DESTROY));
            mIsActive = false;
        }

        @Override
        public void destroy() {
            mScopeKey.windowAndroid.removeActivityStateObserver(this);
        }

        @Override
        public boolean isActive() {
            return mIsActive;
        }
    }
}