chromium/chrome/test/android/javatests/src/org/chromium/chrome/test/util/browser/sync/SyncTestUtil.java

// Copyright 2013 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.test.util.browser.sync;

import android.content.Context;
import android.util.Pair;

import org.hamcrest.Matchers;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.chrome.browser.sync.SyncServiceFactory;
import org.chromium.components.sync.SyncService;
import org.chromium.components.sync.UserSelectableType;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

/** Utility class for shared sync test functionality. */
public final class SyncTestUtil {
    private static final String TAG = "SyncTestUtil";

    public static final long TIMEOUT_MS = 20000L;
    public static final int INTERVAL_MS = 250;

    private SyncTestUtil() {}

    /**
     * Return the {@link SyncService} for the {@link ProfileManager#getLastUsedRegularProfile()}.
     */
    public static SyncService getSyncServiceForLastUsedProfile() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    return SyncServiceFactory.getForProfile(
                            ProfileManager.getLastUsedRegularProfile());
                });
    }

    /**
     * Returns whether the user has sync consent.
     *
     * <p>TODO(crbug.com/40066949): Remove once kSync becomes unreachable or is deleted from the
     * codebase. See ConsentLevel::kSync documentation for details.
     */
    public static boolean hasSyncConsent() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> getSyncServiceForLastUsedProfile().hasSyncConsent());
    }

    /** Returns whether sync-the-feature is enabled. */
    public static boolean isSyncFeatureEnabled() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> getSyncServiceForLastUsedProfile().isSyncFeatureEnabled());
    }

    /** Returns whether sync-the-feature is active. */
    public static boolean isSyncFeatureActive() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> getSyncServiceForLastUsedProfile().isSyncFeatureActive());
    }

    /**
     * Waits for sync-the-feature to become enabled.
     * WARNING: This is does not wait for the feature to be active, see the distinction in
     * components/sync/service/sync_service.h. If the FakeServer isn't running - e.g. because of
     * SyncTestRule - this is all you can hope for. For tests that don't rely on sync data this
     * might just be enough.
     */
    public static void waitForSyncFeatureEnabled() {
        CriteriaHelper.pollUiThread(
                () -> getSyncServiceForLastUsedProfile().isSyncFeatureEnabled(),
                "Timed out waiting for sync to become enabled.",
                TIMEOUT_MS,
                INTERVAL_MS);
    }

    /** Waits for sync-the-feature to become active. */
    public static void waitForSyncFeatureActive() {
        CriteriaHelper.pollUiThread(
                () -> getSyncServiceForLastUsedProfile().isSyncFeatureActive(),
                "Timed out waiting for sync to become active.",
                TIMEOUT_MS,
                INTERVAL_MS);
    }

    /**
     * Waits for hasSyncConsent() to return true.
     *
     * <p>TODO(crbug.com/40066949): Remove once kSync becomes unreachable or is deleted from the
     * codebase. See ConsentLevel::kSync documentation for details.
     */
    public static void waitForSyncConsent() {
        CriteriaHelper.pollUiThread(
                () -> getSyncServiceForLastUsedProfile().hasSyncConsent(),
                "Timed out waiting for sync consent.",
                TIMEOUT_MS,
                INTERVAL_MS);
    }

    /** Waits for sync machinery to become active. */
    public static void waitForSyncTransportActive() {
        CriteriaHelper.pollUiThread(
                () -> getSyncServiceForLastUsedProfile().isTransportStateActive(),
                "Timed out waiting for sync transport state to become active.",
                TIMEOUT_MS,
                INTERVAL_MS);
    }

    /** Waits for sync's engine to be initialized. */
    public static void waitForEngineInitialized() {
        CriteriaHelper.pollUiThread(
                () -> getSyncServiceForLastUsedProfile().isEngineInitialized(),
                "Timed out waiting for sync's engine to initialize.",
                TIMEOUT_MS,
                INTERVAL_MS);
    }

    /** Waits for sync being in the desired TrustedVaultKeyRequired state. */
    public static void waitForTrustedVaultKeyRequired(boolean desiredValue) {
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            getSyncServiceForLastUsedProfile().isTrustedVaultKeyRequired(),
                            Matchers.is(desiredValue));
                },
                TIMEOUT_MS,
                INTERVAL_MS);
    }

    /** Waits for sync being in the desired value for isTrustedVaultRecoverabilityDegraded(). */
    public static void waitForTrustedVaultRecoverabilityDegraded(boolean desiredValue) {
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            getSyncServiceForLastUsedProfile()
                                    .isTrustedVaultRecoverabilityDegraded(),
                            Matchers.is(desiredValue));
                },
                TIMEOUT_MS,
                INTERVAL_MS);
    }

    /** Returns whether history sync is active. */
    public static boolean isHistorySyncEnabled() {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        getSyncServiceForLastUsedProfile()
                                .getSelectedTypes()
                                .containsAll(
                                        Set.of(
                                                UserSelectableType.HISTORY,
                                                UserSelectableType.TABS)));
    }

    /** Waits for history and tabs sync to be active. */
    public static void waitForHistorySyncEnabled() {
        CriteriaHelper.pollUiThread(
                () ->
                        getSyncServiceForLastUsedProfile()
                                .getSelectedTypes()
                                .containsAll(
                                        Set.of(
                                                UserSelectableType.HISTORY,
                                                UserSelectableType.TABS)));
    }

    /** Returns whether bookmarks and reading list are active. */
    public static boolean isBookmarksAndReadingListEnabled() {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        getSyncServiceForLastUsedProfile()
                                .getSelectedTypes()
                                .containsAll(
                                        Set.of(
                                                UserSelectableType.BOOKMARKS,
                                                UserSelectableType.READING_LIST)));
    }

    /** Waits for bookmarks and reading list to be active. */
    public static void waitForBookmarksAndReadingListEnabled() {
        CriteriaHelper.pollUiThread(
                () ->
                        getSyncServiceForLastUsedProfile()
                                .getSelectedTypes()
                                .containsAll(
                                        Set.of(
                                                UserSelectableType.BOOKMARKS,
                                                UserSelectableType.READING_LIST)));
    }

    /** Triggers a sync cycle. */
    public static void triggerSync() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    getSyncServiceForLastUsedProfile().triggerRefresh();
                });
    }

    /**
     * Triggers a sync and tries to wait until it is complete.
     *
     * This method polls until *a* sync cycle completes, but there is no guarantee it is the same
     * one that it triggered. Therefore this method should only be used where it can result in
     * false positives (e.g. checking that something doesn't sync), not false negatives.
     */
    public static void triggerSyncAndWaitForCompletion() {
        final long oldSyncTime = getCurrentSyncTime();
        triggerSync();
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    Criteria.checkThat(getCurrentSyncTime(), Matchers.greaterThan(oldSyncTime));
                },
                TIMEOUT_MS,
                INTERVAL_MS);
    }

    private static long getCurrentSyncTime() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> getSyncServiceForLastUsedProfile().getLastSyncedTimeForDebugging());
    }

    /**
     * Retrieves the local Sync data as a JSONArray via SyncService.
     *
     * This method blocks until the data is available or until it times out.
     */
    private static JSONArray getAllNodesAsJsonArray() {
        class NodesCallbackHelper extends CallbackHelper {
            public JSONArray nodes;
        }
        NodesCallbackHelper callbackHelper = new NodesCallbackHelper();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    getSyncServiceForLastUsedProfile()
                            .getAllNodes(
                                    (nodes) -> {
                                        callbackHelper.nodes = nodes;
                                        callbackHelper.notifyCalled();
                                    });
                });

        try {
            callbackHelper.waitForNext(TIMEOUT_MS, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            assert false : "Timed out waiting for SyncService.getAllNodes()";
        }

        return callbackHelper.nodes;
    }

    /**
     * Extracts datatype-specific information from the given JSONObject. The returned JSONObject
     * contains the same data as a specifics protocol buffer (e.g., ReadingListSpecifics).
     */
    private static JSONObject extractSpecifics(JSONObject node) throws JSONException {
        JSONObject specifics = node.getJSONObject("SPECIFICS");
        // The key name here is type-specific (e.g., "typed_url" for Typed URLs), so we
        // can't hard code a value.
        Iterator<String> keysIterator = specifics.keys();
        String key = null;
        if (!keysIterator.hasNext()) {
            throw new JSONException("Specifics object has 0 keys.");
        }
        key = keysIterator.next();

        if (keysIterator.hasNext()) {
            throw new JSONException("Specifics object has more than 1 key.");
        }

        if (key.equals("bookmark")) {
            JSONObject bookmarkSpecifics = specifics.getJSONObject(key);
            bookmarkSpecifics.put("parent_id", node.getString("PARENT_ID"));
            return bookmarkSpecifics;
        }

        JSONObject specificsWithMetadata = specifics.getJSONObject(key);
        if (node.has("metadata")) {
            specificsWithMetadata.put("metadata", node.getJSONObject("metadata"));
        }
        return specificsWithMetadata;
    }

    /**
     * Converts the given ID to the format stored by the server.
     *
     * <p>See the SyncableId (C++) class for more information about ID encoding. To paraphrase, the
     * client prepends "s" or "c" to the server's ID depending on the commit state of the data. IDs
     * can also be "r" to indicate the root node, but that entity is not supported here.
     *
     * @param clientId the ID to be converted
     * @return the converted ID
     */
    private static String convertToServerId(String clientId) {
        if (clientId == null) {
            throw new IllegalArgumentException("Client entity ID cannot be null.");
        } else if (clientId.isEmpty()) {
            throw new IllegalArgumentException("Client ID cannot be empty.");
        } else if (!clientId.startsWith("s") && !clientId.startsWith("c")) {
            throw new IllegalArgumentException(
                    String.format("Client ID (%s) must start with c or s.", clientId));
        }

        return clientId.substring(1);
    }

    /**
     * Returns the local Sync data present for a single datatype.
     *
     * <p>For each data entity, a Pair is returned. The first piece of data is the entity's server
     * ID. This is useful for activities like deleting an entity on the server. The second piece of
     * data is a JSONObject representing the datatype-specific information for the entity. This data
     * is the same as the data stored in a specifics protocol buffer (e.g., ReadingListSpecifics).
     *
     * @param context the Context used to retreive the correct SyncService
     * @param typeString a String representing a specific datatype.
     *     <p>TODO(pvalenzuela): Replace typeString with the native DataType enum or something else
     *     that will avoid callers needing to specify the native string version.
     * @return a List of Pair<String, JSONObject> representing the local Sync data
     */
    public static List<Pair<String, JSONObject>> getLocalData(Context context, String typeString)
            throws JSONException {
        JSONArray localData = getAllNodesAsJsonArray();
        JSONArray datatypeNodes = new JSONArray();
        for (int i = 0; i < localData.length(); i++) {
            JSONObject datatypeObject = localData.getJSONObject(i);
            if (datatypeObject.getString("type").equals(typeString)) {
                datatypeNodes = datatypeObject.getJSONArray("nodes");
                break;
            }
        }

        List<Pair<String, JSONObject>> localDataForDatatype =
                new ArrayList<Pair<String, JSONObject>>(datatypeNodes.length());
        for (int i = 0; i < datatypeNodes.length(); i++) {
            JSONObject entity = datatypeNodes.getJSONObject(i);
            if (entity.has("UNIQUE_SERVER_TAG")
                    && !entity.getString("UNIQUE_SERVER_TAG").isEmpty()) {
                // Ignore permanent items (e.g., root datatype folders).
                continue;
            }
            String id = convertToServerId(entity.getString("ID"));
            localDataForDatatype.add(Pair.create(id, extractSpecifics(entity)));
        }
        return localDataForDatatype;
    }

    /**
     * Encrypts the profile with the input |passphrase|. It will then block until the sync server is
     * successfully using the passphrase.
     */
    public static void encryptWithPassphrase(final String passphrase) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> getSyncServiceForLastUsedProfile().setEncryptionPassphrase(passphrase));
        // Make sure the new encryption settings make it to the server.
        SyncTestUtil.triggerSyncAndWaitForCompletion();
    }

    /** Decrypts the profile using the input |passphrase|. */
    public static void decryptWithPassphrase(final String passphrase) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    getSyncServiceForLastUsedProfile().setDecryptionPassphrase(passphrase);
                });
    }
}