chromium/chrome/android/javatests/src/org/chromium/chrome/browser/sync/OpenTabsTest.java

// Copyright 2015 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.chrome.browser.sync;

import android.os.Build;
import android.util.Pair;

import androidx.test.filters.LargeTest;

import org.hamcrest.Matchers;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaNotSatisfiedException;
import org.chromium.base.test.util.DisableIf;
import org.chromium.base.test.util.Feature;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelUtils;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.util.browser.sync.SyncTestUtil;
import org.chromium.components.sync.protocol.EntitySpecifics;
import org.chromium.components.sync.protocol.SessionHeader;
import org.chromium.components.sync.protocol.SessionSpecifics;
import org.chromium.components.sync.protocol.SessionTab;
import org.chromium.components.sync.protocol.SessionWindow;
import org.chromium.components.sync.protocol.SyncEnums;
import org.chromium.components.sync.protocol.TabNavigation;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/** Test suite for the open tabs (sessions) sync data type. */
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE})
public class OpenTabsTest {
    @Rule public SyncTestRule mSyncTestRule = new SyncTestRule();

    private static final String OPEN_TABS_TYPE = "Sessions";

    // EmbeddedTestServer is preferred here but it can't be used. The test server
    // serves pages on localhost and Chrome doesn't sync localhost URLs as typed URLs.
    // This type of URL requires no external data connection or resources.
    private static final String URL = "data:text,OpenTabsTestURL";
    private static final String URL2 = "data:text,OpenTabsTestURL2";
    private static final String URL3 = "data:text,OpenTabsTestURL3";

    private static final String SESSION_TAG_PREFIX = "FakeSessionTag";
    private static final String FAKE_CLIENT = "FakeClient";

    // The client name for tabs generated locally will vary based on the device the test is
    // running on, so it is determined once in the setUp() method and cached here.
    private String mClientName;

    // A counter used for generating unique session tags. Resets to 0 in setUp().
    private int mSessionTagCounter;

    // A container to store OpenTabs information for data verification.
    private static class OpenTabs {
        public final String headerServerId;
        public final String headerClientTagHash;
        public final ArrayList<String> tabServerIds;
        public final ArrayList<String> tabClientTagHashes;
        public final ArrayList<String> urls;

        private OpenTabs(
                String headerServerId,
                String headerClientTagHash,
                ArrayList<String> tabServerIds,
                ArrayList<String> tabClientTagHashes,
                ArrayList<String> urls) {
            this.headerServerId = headerServerId;
            this.headerClientTagHash = headerClientTagHash;
            this.tabServerIds = tabServerIds;
            this.tabClientTagHashes = tabClientTagHashes;
            this.urls = urls;
        }
    }

    @Before
    public void setUp() throws Exception {
        mSyncTestRule.setUpAccountAndEnableSyncForTesting();
        mClientName = getClientName();
        mSessionTagCounter = 0;
    }

    // Test syncing an open tab from client to server.
    @Test
    @LargeTest
    @Feature({"Sync"})
    public void testUploadOpenTab() {
        mSyncTestRule.loadUrl(URL);
        waitForLocalTabsForClient(mClientName, URL);
        waitForServerTabs(URL);
    }

    // Test syncing multiple open tabs from client to server.
    @Test
    @LargeTest
    @Feature({"Sync"})
    public void testUploadMultipleOpenTabs() {
        mSyncTestRule.loadUrl(URL);
        mSyncTestRule.loadUrlInNewTab(URL2);
        mSyncTestRule.loadUrlInNewTab(URL3);
        waitForLocalTabsForClient(mClientName, URL, URL2, URL3);
        waitForServerTabs(URL, URL2, URL3);
    }

    // Test syncing an open tab from client to server.
    @Test
    @LargeTest
    @Feature({"Sync"})
    @DisableIf.Build(
            sdk_is_greater_than = Build.VERSION_CODES.N,
            message = "https://crbug.com/1515319")
    public void testUploadAndCloseOpenTab() {
        mSyncTestRule.loadUrl(URL);
        // Can't have zero tabs, so we have to open two to test closing one.
        mSyncTestRule.loadUrlInNewTab(URL2);
        waitForLocalTabsForClient(mClientName, URL, URL2);
        waitForServerTabs(URL, URL2);

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    TabModelSelector selector = mSyncTestRule.getActivity().getTabModelSelector();
                    Assert.assertTrue(TabModelUtils.closeCurrentTab(selector.getCurrentModel()));
                });

        waitForLocalTabsForClient(mClientName, URL);
        waitForServerTabs(URL);
    }

    // Test syncing an open tab from server to client.
    @Test
    @LargeTest
    @Feature({"Sync"})
    public void testDownloadOpenTab() {
        addFakeServerTabs(FAKE_CLIENT, URL);
        SyncTestUtil.triggerSync();
        waitForLocalTabsForClient(FAKE_CLIENT, URL);
    }

    // Test syncing multiple open tabs from server to client.
    @Test
    @LargeTest
    @Feature({"Sync"})
    public void testDownloadMultipleOpenTabs() {
        addFakeServerTabs(FAKE_CLIENT, URL, URL2, URL3);
        SyncTestUtil.triggerSync();
        waitForLocalTabsForClient(FAKE_CLIENT, URL, URL2, URL3);
    }

    // Test syncing a tab deletion from server to client.
    @Test
    @LargeTest
    @Feature({"Sync"})
    public void testDownloadDeletedOpenTab() throws Exception {
        // Add the entity to test deleting.
        addFakeServerTabs(FAKE_CLIENT, URL);
        SyncTestUtil.triggerSync();
        waitForLocalTabsForClient(FAKE_CLIENT, URL);

        // Delete on server, sync, and verify deleted locally.
        deleteServerTabsForClient(FAKE_CLIENT);
        SyncTestUtil.triggerSync();
        waitForLocalTabsForClient(FAKE_CLIENT);
    }

    // Test syncing multiple tab deletions from server to client.
    @Test
    @LargeTest
    @Feature({"Sync"})
    public void testDownloadMultipleDeletedOpenTabs() throws Exception {
        // Add the entity to test deleting.
        addFakeServerTabs(FAKE_CLIENT, URL, URL2, URL3);
        SyncTestUtil.triggerSync();
        waitForLocalTabsForClient(FAKE_CLIENT, URL, URL2, URL3);

        // Delete on server, sync, and verify deleted locally.
        deleteServerTabsForClient(FAKE_CLIENT);
        SyncTestUtil.triggerSync();
        waitForLocalTabsForClient(FAKE_CLIENT);
    }

    private String makeSessionTag() {
        return SESSION_TAG_PREFIX + (mSessionTagCounter++);
    }

    private void addFakeServerTabs(String clientName, String... urls) {
        String tag = makeSessionTag();
        EntitySpecifics header = makeSessionEntity(tag, clientName, urls.length);
        mSyncTestRule
                .getFakeServerHelper()
                .injectUniqueClientEntity(/* nonUniqueName= */ "", /* clientTag= */ tag, header);
        for (int i = 0; i < urls.length; i++) {
            EntitySpecifics tab = makeTabEntity(tag, urls[i], i);
            mSyncTestRule
                    .getFakeServerHelper()
                    .injectUniqueClientEntity(
                            /* nonUniqueName= */ "", /* clientTag= */ tag + " " + i, tab);
        }
    }

    private SessionWindow makeSessionWindow(int numTabs) {
        SessionWindow.Builder windowBuilder =
                SessionWindow.newBuilder().setWindowId(1).setSelectedTabIndex(0);
        for (int i = 0; i < numTabs; i++) {
            windowBuilder.addTab(i + 1); // Updates |windowBuilder| internal state.
        }
        return windowBuilder.build();
    }

    private EntitySpecifics makeSessionEntity(String tag, String clientName, int numTabs) {
        SessionSpecifics session =
                SessionSpecifics.newBuilder()
                        .setSessionTag(tag)
                        .setHeader(
                                SessionHeader.newBuilder()
                                        .setClientName(clientName)
                                        .setDeviceType(SyncEnums.DeviceType.TYPE_PHONE)
                                        .addWindow(makeSessionWindow(numTabs))
                                        .build())
                        .build();
        return EntitySpecifics.newBuilder().setSession(session).build();
    }

    private EntitySpecifics makeTabEntity(String tag, String url, int id) {
        SessionSpecifics session =
                SessionSpecifics.newBuilder()
                        .setSessionTag(tag)
                        .setTabNodeId(id)
                        .setTab(
                                SessionTab.newBuilder()
                                        .setTabId(id + 1)
                                        .setCurrentNavigationIndex(0)
                                        .addNavigation(
                                                TabNavigation.newBuilder()
                                                        .setVirtualUrl(url)
                                                        .build())
                                        .build())
                        .build();
        return EntitySpecifics.newBuilder().setSession(session).build();
    }

    private void deleteServerTabsForClient(String clientName) throws JSONException {
        OpenTabs openTabs = getLocalTabsForClient(clientName);
        mSyncTestRule
                .getFakeServerHelper()
                .deleteEntity(openTabs.headerServerId, openTabs.headerClientTagHash);
        for (int i = 0; i < openTabs.tabServerIds.size(); i++) {
            mSyncTestRule
                    .getFakeServerHelper()
                    .deleteEntity(openTabs.tabServerIds.get(i), openTabs.tabClientTagHashes.get(i));
        }
    }

    private void waitForLocalTabsForClient(final String clientName, String... urls) {
        final List<String> urlList = new ArrayList<>(urls.length);
        for (String url : urls) urlList.add(url);
        mSyncTestRule.pollInstrumentationThread(
                () -> {
                    try {
                        Criteria.checkThat(
                                getLocalTabsForClient(clientName).urls, Matchers.is(urlList));
                    } catch (JSONException ex) {
                        throw new CriteriaNotSatisfiedException(ex);
                    }
                });
    }

    private void waitForServerTabs(final String... urls) {
        mSyncTestRule.pollInstrumentationThread(
                () -> mSyncTestRule.getFakeServerHelper().verifySessions(urls),
                "Expected server open tabs: " + Arrays.toString(urls));
    }

    private String getClientName() throws Exception {
        mSyncTestRule.pollInstrumentationThread(
                () -> {
                    try {
                        int size =
                                SyncTestUtil.getLocalData(
                                                mSyncTestRule.getTargetContext(), OPEN_TABS_TYPE)
                                        .size();
                        Criteria.checkThat(
                                "Expected at least one tab entity to exist.",
                                size,
                                Matchers.greaterThan(0));
                    } catch (JSONException ex) {
                        throw new CriteriaNotSatisfiedException(ex);
                    }
                });
        List<Pair<String, JSONObject>> tabEntities =
                SyncTestUtil.getLocalData(mSyncTestRule.getTargetContext(), OPEN_TABS_TYPE);
        for (Pair<String, JSONObject> tabEntity : tabEntities) {
            if (tabEntity.second.has("header")) {
                return tabEntity.second.getJSONObject("header").getString("client_name");
            }
        }
        throw new IllegalStateException("No client name found.");
    }

    private static class HeaderInfo {
        public final String sessionTag;
        public final String headerServerId;
        public final String headerClientTagHash;
        public final List<String> tabIds;

        public HeaderInfo(
                String sessionTag,
                String headerServerId,
                String headerClientTagHash,
                List<String> tabIds) {
            this.sessionTag = sessionTag;
            this.headerServerId = headerServerId;
            this.headerClientTagHash = headerClientTagHash;
            this.tabIds = tabIds;
        }
    }

    // Distills the local session data into a simple data object for the given client.
    private OpenTabs getLocalTabsForClient(String clientName) throws JSONException {
        List<Pair<String, JSONObject>> tabEntities =
                SyncTestUtil.getLocalData(mSyncTestRule.getTargetContext(), OPEN_TABS_TYPE);
        // Output lists.
        ArrayList<String> urls = new ArrayList<>();
        ArrayList<String> tabServerIds = new ArrayList<>();
        ArrayList<String> tabClientTagHashes = new ArrayList<>();
        HeaderInfo info = findHeaderInfoForClient(clientName, tabEntities);
        if (info.sessionTag == null) {
            // No client was found. Here we still want to return an empty list of urls.
            return new OpenTabs("", "", tabServerIds, tabClientTagHashes, urls);
        }
        Map<String, String> tabIdsToUrls = new HashMap<>();
        Map<String, String> tabIdsToServerIds = new HashMap<>();
        Map<String, String> tabIdsToClientTagHashes = new HashMap<>();
        findTabMappings(
                info.sessionTag,
                tabEntities,
                tabIdsToUrls,
                tabIdsToServerIds,
                tabIdsToClientTagHashes);
        // Convert the tabId list to the url list.
        for (String tabId : info.tabIds) {
            urls.add(tabIdsToUrls.get(tabId));
            tabServerIds.add(tabIdsToServerIds.get(tabId));
            tabClientTagHashes.add(tabIdsToClientTagHashes.get(tabId));
        }
        return new OpenTabs(
                info.headerServerId,
                info.headerClientTagHash,
                tabServerIds,
                tabClientTagHashes,
                urls);
    }

    // Find the header entity for clientName and extract its sessionTag and tabId list.
    private HeaderInfo findHeaderInfoForClient(
            String clientName, List<Pair<String, JSONObject>> tabEntities) throws JSONException {
        String sessionTag = null;
        String headerServerId = null;
        String headerClientTagHash = null;
        List<String> tabIds = new ArrayList<>();
        for (Pair<String, JSONObject> tabEntity : tabEntities) {
            JSONObject header = tabEntity.second.optJSONObject("header");
            if (header != null && header.getString("client_name").equals(clientName)) {
                sessionTag = tabEntity.second.getString("session_tag");
                headerClientTagHash =
                        tabEntity.second.optJSONObject("metadata").getString("client_tag_hash");
                headerServerId = tabEntity.first;
                JSONArray windows = header.getJSONArray("window");
                if (windows.length() == 0) {
                    // The client was found but there are no tabs.
                    break;
                }
                Assert.assertEquals("Only single windows are supported.", 1, windows.length());
                JSONArray tabs = windows.getJSONObject(0).getJSONArray("tab");
                for (int i = 0; i < tabs.length(); i++) {
                    tabIds.add(tabs.getString(i));
                }
                break;
            }
        }
        return new HeaderInfo(sessionTag, headerServerId, headerClientTagHash, tabIds);
    }

    // Find the associated tabs and record their tabId -> url and entityId mappings.
    private void findTabMappings(
            String sessionTag,
            List<Pair<String, JSONObject>> tabEntities,
            // Populating these maps is the output of this function.
            Map<String, String> tabIdsToUrls,
            Map<String, String> tabIdsToServerIds,
            Map<String, String> tabIdsToClientTagHashes)
            throws JSONException {
        for (Pair<String, JSONObject> tabEntity : tabEntities) {
            JSONObject json = tabEntity.second;
            if (json.has("tab") && json.getString("session_tag").equals(sessionTag)) {
                String clientTagHash = json.optJSONObject("metadata").getString("client_tag_hash");
                JSONObject tab = json.getJSONObject("tab");
                int i = tab.getInt("current_navigation_index");
                String tabId = tab.getString("tab_id");
                String url =
                        tab.getJSONArray("navigation").getJSONObject(i).getString("virtual_url");
                tabIdsToUrls.put(tabId, url);
                tabIdsToServerIds.put(tabId, tabEntity.first);
                tabIdsToClientTagHashes.put(tabId, clientTagHash);
            }
        }
    }
}