chromium/components/gcm_driver/android/java/src/org/chromium/components/gcm_driver/LazySubscriptionsManager.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.components.gcm_driver;

import android.content.Context;
import android.content.SharedPreferences;

import androidx.annotation.VisibleForTesting;

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

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.StrictModeContext;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * This class is responsible for managing lazy subscriptions. It provides API to change and query
 * whether a subscription is lazy, and toto persist and retrieve persisted messages.
 */
public class LazySubscriptionsManager {
    private static final String TAG = "LazySubscriptions";
    private static final String FCM_LAZY_SUBSCRIPTIONS = "fcm_lazy_subscriptions";
    static final String LEGACY_HAS_PERSISTED_MESSAGES_KEY = "has_persisted_messages";
    private static final String SUBSCRIPTIONS_WITH_PERSISTED_MESSAGES_KEY =
            "subscriptions_with_persisted_messages";
    private static final String PREF_PACKAGE =
            "org.chromium.components.gcm_driver.lazy_subscriptions";

    // The max number of most recent messages queued per lazy subscription until
    // Chrome is foregrounded.
    @VisibleForTesting public static final int MESSAGES_QUEUE_SIZE = 3;

    // Private constructor because all methods in this class are static, and it
    // shouldn't be instantiated.
    private LazySubscriptionsManager() {}

    /**
     * A one time migration from the deprecated "has persisted messages" boolean
     * flag to a set of subscription ids that have persisted messages. If the
     * global flag is set, it add all lazy subscription ids have persisted
     * messages and then clears the global flag.
     */
    public static void migrateHasPersistedMessagesPref() {
        SharedPreferences sharedPrefs = ContextUtils.getAppSharedPreferences();
        boolean hasPersistedMessages =
                sharedPrefs.getBoolean(LEGACY_HAS_PERSISTED_MESSAGES_KEY, false);
        if (!hasPersistedMessages) {
            return;
        }
        Set<String> lazySubscriptionIds = getLazySubscriptionIds();
        sharedPrefs
                .edit()
                .putStringSet(SUBSCRIPTIONS_WITH_PERSISTED_MESSAGES_KEY, lazySubscriptionIds)
                .apply();
        sharedPrefs.edit().remove(LEGACY_HAS_PERSISTED_MESSAGES_KEY).apply();
    }

    /**
     * Adds/Removes the |subscriptionId| to indicate whether there are any
     * persisted messages to read for this |subscriptionId|. This information
     * could be read using hasPersistedMessagesForSubscription().
     * @param subscriptionId
     * @param hasPersistedMessages
     */
    public static void storeHasPersistedMessagesForSubscription(
            final String subscriptionId, boolean hasPersistedMessages) {
        // Stores the information in the default preferences instead of special
        // one for the GCM messages. The reason is the default preferences file
        // is used in many places in Chrome and should be already cached in
        // memory by the time this method is called. Therefore, it should
        // provide a cheap way that (most probably) doesn't require disk access
        // to read that flag.
        SharedPreferences sharedPrefs = ContextUtils.getAppSharedPreferences();
        Set<String> subscriptionsWithPersistedMessages =
                new HashSet<>(
                        sharedPrefs.getStringSet(
                                SUBSCRIPTIONS_WITH_PERSISTED_MESSAGES_KEY, Collections.emptySet()));
        if (subscriptionsWithPersistedMessages.contains(subscriptionId) == hasPersistedMessages) {
            // Correct information are already stored, nothing to do.
            return;
        }
        if (hasPersistedMessages) {
            subscriptionsWithPersistedMessages.add(subscriptionId);
        } else {
            subscriptionsWithPersistedMessages.remove(subscriptionId);
        }
        sharedPrefs
                .edit()
                .putStringSet(
                        SUBSCRIPTIONS_WITH_PERSISTED_MESSAGES_KEY,
                        subscriptionsWithPersistedMessages)
                .apply();
    }

    /**
     * Whether some messages are persisted for |subscriptionIdPrefix| and should be
     * replayed next time Chrome is running. It should be cheaper to call than
     * actually reading the stored messages. Call this method to decide whether
     * there is a need to read any persisted messages for that subscription.
     * @param subscriptionIdPrefix
     * @return whether some messages are persisted for that subscription.
     */
    public static Set<String> getSubscriptionIdsWithPersistedMessages(
            final String subscriptionIdPrefix) {
        SharedPreferences sharedPrefs = ContextUtils.getAppSharedPreferences();
        Set<String> subscriptionsWithPersistedMessages =
                new HashSet<>(
                        sharedPrefs.getStringSet(
                                SUBSCRIPTIONS_WITH_PERSISTED_MESSAGES_KEY, Collections.emptySet()));
        Set<String> subscriptionsWithPersistedMessagesWithPrefix = new HashSet<String>();
        for (String subscriptionWithPersistedMessages : subscriptionsWithPersistedMessages) {
            if (subscriptionWithPersistedMessages.startsWith(subscriptionIdPrefix)) {
                subscriptionsWithPersistedMessagesWithPrefix.add(subscriptionWithPersistedMessages);
            }
        }
        return subscriptionsWithPersistedMessagesWithPrefix;
    }

    /**
     * Given an appId and a senderId, this methods builds a unique identifier for a subscription.
     * Currently implementation concatenates both senderId and appId.
     * @param appId
     * @param senderId
     * @return The unique identifier for the subscription.
     */
    public static String buildSubscriptionUniqueId(final String appId, final String senderId) {
        return appId + senderId;
    }

    /** Stores the information about lazy subscriptions in SharedPreferences. */
    public static void storeLazinessInformation(final String subscriptionId, boolean isLazy) {
        boolean isAlreadyLazy = isSubscriptionLazy(subscriptionId);
        if (isAlreadyLazy == isLazy) {
            return;
        }
        if (isAlreadyLazy) {
            // Switching from lazy to unlazy.
            // Delete any queued messages.
            deletePersistedMessagesForSubscriptionId(subscriptionId);
        }
        Context context = ContextUtils.getApplicationContext();
        SharedPreferences sharedPrefs =
                context.getSharedPreferences(PREF_PACKAGE, Context.MODE_PRIVATE);
        Set<String> lazyIds =
                new HashSet<>(
                        sharedPrefs.getStringSet(FCM_LAZY_SUBSCRIPTIONS, Collections.emptySet()));
        if (isAlreadyLazy) {
            lazyIds.remove(subscriptionId);
        } else { // Switching from unlazy to lazy.
            lazyIds.add(subscriptionId);
        }
        sharedPrefs.edit().putStringSet(FCM_LAZY_SUBSCRIPTIONS, lazyIds).apply();
    }

    /** Returns whether the subscription with the |appId| and |senderId| is lazy. */
    public static boolean isSubscriptionLazy(final String subscriptionId) {
        try (StrictModeContext ignored = StrictModeContext.allowDiskReads()) {
            Context context = ContextUtils.getApplicationContext();
            SharedPreferences sharedPrefs =
                    context.getSharedPreferences(PREF_PACKAGE, Context.MODE_PRIVATE);
            Set<String> lazyIds =
                    new HashSet<>(
                            sharedPrefs.getStringSet(
                                    FCM_LAZY_SUBSCRIPTIONS, Collections.emptySet()));
            return lazyIds.contains(subscriptionId);
        }
    }

    /**
     * Returns the ids of all lazy subscriptions.
     * @return Set of subscriptions ids.
     */
    public static Set<String> getLazySubscriptionIds() {
        try (StrictModeContext ignored = StrictModeContext.allowDiskReads()) {
            Context context = ContextUtils.getApplicationContext();
            SharedPreferences sharedPrefs =
                    context.getSharedPreferences(PREF_PACKAGE, Context.MODE_PRIVATE);
            return new HashSet<>(
                    sharedPrefs.getStringSet(FCM_LAZY_SUBSCRIPTIONS, Collections.emptySet()));
        }
    }

    /**
     * Stores |message| on disk. Stored Messages for a subscription id will be
     * returned by readMessages(). Only the most recent |MESSAGES_QUEUE_SIZE|
     * messages with distinct collapse keys are kept.
     * @param subscriptionId id of the subscription.
     * @param message The message to be persisted.
     */
    public static void persistMessage(String subscriptionId, GCMMessage message) {
        // Messages are stored as a JSONArray in SharedPreferences. The key is
        // |subscriptionId|. The value is a string representing a JSONArray that
        // contains messages serialized as a JSONObject.

        // Load the persisted messages for this subscription.
        Context context = ContextUtils.getApplicationContext();
        SharedPreferences sharedPrefs =
                context.getSharedPreferences(PREF_PACKAGE, Context.MODE_PRIVATE);
        // Default is an empty queue if no messages are queued for this subscription.
        String queueString = sharedPrefs.getString(subscriptionId, "[]");
        try {
            JSONArray queueJSON = new JSONArray(queueString);
            if (message.getCollapseKey() != null) {
                queueJSON = filterMessageBasedOnCollapseKey(queueJSON, message.getCollapseKey());
            }

            // If the queue is full remove the oldest message.
            if (queueJSON.length() == MESSAGES_QUEUE_SIZE) {
                Log.w(
                        TAG,
                        "Dropping GCM Message due queue size limit. Sender id:"
                                + GCMMessage.peekSenderId(queueJSON.getJSONObject(0)));
                JSONArray newQueue = new JSONArray();
                // Copy all messages except the first one.
                for (int i = 1; i < MESSAGES_QUEUE_SIZE; i++) {
                    newQueue.put(queueJSON.get(i));
                }
                queueJSON = newQueue;
            }
            // Add the new message to the end.
            queueJSON.put(message.toJSON());
            sharedPrefs.edit().putString(subscriptionId, queueJSON.toString()).apply();
            storeHasPersistedMessagesForSubscription(
                    subscriptionId, /* hasPersistedMessages= */ true);
        } catch (JSONException e) {
            Log.e(
                    TAG,
                    "Error when parsing the persisted message queue for subscriber:"
                            + subscriptionId
                            + ":"
                            + e.getMessage());
        }
    }

    /**
     *  Reads messages stored using persistMessage() for |subscriptionId|. No
     *  more than |MESSAGES_QUEUE_SIZE| are returned.
     *  @param subscriptionId The subscription id of the stored messages.
     *  @return The messages stored. Returns an empty list in case of failure.
     */
    public static GCMMessage[] readMessages(String subscriptionId) {
        Context context = ContextUtils.getApplicationContext();
        SharedPreferences sharedPrefs =
                context.getSharedPreferences(PREF_PACKAGE, Context.MODE_PRIVATE);

        // Default is an empty queue if no messages are queued for this subscription.
        String queueString = sharedPrefs.getString(subscriptionId, "[]");
        try {
            JSONArray queueJSON = new JSONArray(queueString);
            List<GCMMessage> messages = new ArrayList<>();
            for (int i = 0; i < queueJSON.length(); i++) {
                try {
                    GCMMessage persistedMessage =
                            GCMMessage.createFromJSON(queueJSON.getJSONObject(i));
                    if (persistedMessage == null) {
                        Log.e(
                                TAG,
                                "Persisted GCM Message is invalid. Sender id:"
                                        + GCMMessage.peekSenderId(queueJSON.getJSONObject(i)));
                        continue;
                    }
                    messages.add(persistedMessage);
                } catch (JSONException e) {
                    Log.e(
                            TAG,
                            "Error when creating a GCMMessage from a JSONObject:" + e.getMessage());
                }
            }
            return messages.toArray(new GCMMessage[messages.size()]);
        } catch (JSONException e) {
            Log.e(
                    TAG,
                    "Error when parsing the persisted message queue for subscriber:"
                            + subscriptionId);
        }
        return new GCMMessage[0];
    }

    /**
     * Deletes all persisted messages for the given subscription id.
     * @param subscriptionId
     */
    public static void deletePersistedMessagesForSubscriptionId(String subscriptionId) {
        Context context = ContextUtils.getApplicationContext();
        SharedPreferences sharedPrefs =
                context.getSharedPreferences(PREF_PACKAGE, Context.MODE_PRIVATE);
        sharedPrefs.edit().remove(subscriptionId).apply();
        LazySubscriptionsManager.storeHasPersistedMessagesForSubscription(
                subscriptionId, /* hasPersistedMessages= */ false);
    }

    /**
     * Filters out any messages in |messagesJSON| with the given collpase key. It returns the
     * filtered list.
     */
    private static JSONArray filterMessageBasedOnCollapseKey(JSONArray messages, String collapseKey)
            throws JSONException {
        JSONArray filteredMessages = new JSONArray();
        for (int i = 0; i < messages.length(); i++) {
            JSONObject message = messages.getJSONObject(i);
            if (GCMMessage.peekCollapseKey(message).equals(collapseKey)) {
                Log.i(
                        TAG,
                        "Dropping GCM Message due to collapse key collision. Sender id:"
                                + GCMMessage.peekSenderId(message));
                continue;
            }
            filteredMessages.put(message);
        }
        return filteredMessages;
    }
}