chromium/chrome/browser/optimization_guide/android/java/src/org/chromium/chrome/browser/optimization_guide/OptimizationGuidePushNotificationManager.java

// Copyright 2020 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.optimization_guide;

import android.util.Base64;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.cached_flags.IntCachedFieldTrialParameter;
import org.chromium.base.library_loader.LibraryLoader;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.components.optimization_guide.proto.HintsProto.OptimizationType;
import org.chromium.components.optimization_guide.proto.PushNotificationProto.HintNotificationPayload;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

/**
 * Manages incoming push notifications to the Optimization Guide. Each notification needs to be
 * forwarded to the native code, but they may arrive at any time. Notifications received while
 * native is down are persisted to prefs, up to an experimentally controlled limit. Such overflows
 * are detected and also forwarded to native.
 */
public class OptimizationGuidePushNotificationManager {
    private static Boolean sNativeIsInitialized;

    private static final String TAG = "OGPNotificationMngr";

    private static final String READ_CACHE_RESULT_HISTOGRAM =
            "OptimizationGuide.PushNotifications.ReadCacheResult";

    // Should be in sync with the enum "OptimizationGuideReadCacheResult" in
    // tools/metrics/histograms/enums.xml.
    @SuppressWarnings("unused")
    @IntDef({
        ReadCacheResult.UNKNOWN,
        ReadCacheResult.SUCCESS,
        ReadCacheResult.INVALID_PROTO_ERROR,
        ReadCacheResult.BASE64_ERROR
    })
    @Retention(RetentionPolicy.SOURCE)
    private @interface ReadCacheResult {
        int UNKNOWN = 0;
        int SUCCESS = 1;
        int INVALID_PROTO_ERROR = 2;
        int BASE64_ERROR = 3;

        int NUM_ENTRIES = 4;
    }

    // All logic here is static, so no instances of this class are needed.
    private OptimizationGuidePushNotificationManager() {}

    /** A sentinel Set that is set when the pref for a specific OptimizationType overflows. */
    private static final Set<String> OVERFLOW_SENTINEL_SET = Set.of("__overflow");

    /** The default cache size in Java for push notification. */
    public static final IntCachedFieldTrialParameter MAX_CACHE_SIZE =
            ChromeFeatureList.newIntCachedFieldTrialParameter(
                    ChromeFeatureList.OPTIMIZATION_GUIDE_PUSH_NOTIFICATIONS, "max_cache_size", 100);

    /**
     * Called when a new push notification is received.
     * @param payload the incoming payload.
     */
    public static void onPushNotification(HintNotificationPayload payload) {
        if (!ChromeFeatureList.sOptimizationGuidePushNotifications.isEnabled()) {
            // In case the feature has become disabled after once being enabled, clear everything.
            clearCacheForAllTypes();
            return;
        }

        if (nativeIsInitialized()) {
            OptimizationGuideBridgeFactory.getForProfile(ProfileManager.getLastUsedRegularProfile())
                    .onNewPushNotification(payload);
            return;
        }

        // Persist the notification until the next time native is awake.
        persistNotificationPayload(payload);
    }

    /**
     * Called when the native code isn't able to handle a push notification that was previously
     * sent. The given notification will be cached instead, until the next time that the cached
     * notifications are requested.
     */
    public static void onPushNotificationNotHandledByNative(HintNotificationPayload payload) {
        persistNotificationPayload(payload);
    }

    /**
     * Clears the cache for the given optimization type.
     * @param optimizationType the optimization type to clear
     */
    public static void clearCacheForOptimizationType(OptimizationType optimizationType) {
        ChromeSharedPreferences.getInstance().removeKey(cacheKey(optimizationType));
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static void clearCacheForAllTypes() {
        for (OptimizationType type : OptimizationType.values()) {
            clearCacheForOptimizationType(type);
        }
    }

    /**
     * Returns all cached notifications for the given Optimization Type. A null return value is
     * returned for overflowed caches. An empty array means that no notifications were cached.
     * @param optimizationType the optimization type to get cached notifications for
     * @return a possibly null array of persisted notifications
     */
    @Nullable
    public static HintNotificationPayload[] getNotificationCacheForOptimizationType(
            OptimizationType optimizationType) {
        Set<String> cache = getStringCacheForOptimizationType(optimizationType);
        if (checkForOverflow(cache)) return null;

        Iterator<String> cache_iter = cache.iterator();

        List<HintNotificationPayload> notifications = new ArrayList<HintNotificationPayload>();
        for (int i = 0; i < cache.size(); i++) {
            try {
                HintNotificationPayload payload =
                        HintNotificationPayload.parseFrom(
                                Base64.decode(cache_iter.next(), Base64.DEFAULT));
                notifications.add(payload);
                RecordHistogram.recordEnumeratedHistogram(
                        READ_CACHE_RESULT_HISTOGRAM,
                        ReadCacheResult.SUCCESS,
                        ReadCacheResult.NUM_ENTRIES);
            } catch (com.google.protobuf.InvalidProtocolBufferException e) {
                RecordHistogram.recordEnumeratedHistogram(
                        READ_CACHE_RESULT_HISTOGRAM,
                        ReadCacheResult.INVALID_PROTO_ERROR,
                        ReadCacheResult.NUM_ENTRIES);
                Log.e(TAG, Log.getStackTraceString(e));
            } catch (IllegalArgumentException e) {
                RecordHistogram.recordEnumeratedHistogram(
                        READ_CACHE_RESULT_HISTOGRAM,
                        ReadCacheResult.BASE64_ERROR,
                        ReadCacheResult.NUM_ENTRIES);
                Log.e(TAG, Log.getStackTraceString(e));
            }
        }

        HintNotificationPayload[] notificationsArray =
                new HintNotificationPayload[notifications.size()];
        notifications.toArray(notificationsArray);
        return notificationsArray;
    }

    private static Set<String> getStringCacheForOptimizationType(
            OptimizationType optimizationType) {
        return ChromeSharedPreferences.getInstance().readStringSet(cacheKey(optimizationType));
    }

    /**
     * Returns a list of all the optimization types that have push notification cached. Optimization
     * types with overflowed caches are not included.
     */
    public static List<OptimizationType> getOptTypesWithPushNotifications() {
        List<OptimizationType> types = new ArrayList<OptimizationType>();
        for (OptimizationType type : OptimizationType.values()) {
            Set<String> cache = ChromeSharedPreferences.getInstance().readStringSet(cacheKey(type));
            if (cache != null && cache.size() > 0 && !checkForOverflow(cache)) {
                types.add(type);
            }
        }
        return types;
    }

    /**
     * Returns a list of all the optimization types that overflowed their push notification caches.
     */
    public static List<OptimizationType> getOptTypesThatOverflowedPushNotifications() {
        List<OptimizationType> overflows = new ArrayList<OptimizationType>();
        for (OptimizationType type : OptimizationType.values()) {
            if (checkForOverflow(getStringCacheForOptimizationType(type))) {
                overflows.add(type);
            }
        }
        return overflows;
    }

    @VisibleForTesting
    public static String cacheKey(OptimizationType optimizationType) {
        return ChromePreferenceKeys.OPTIMIZATION_GUIDE_PUSH_NOTIFICATION_CACHE.createKey(
                optimizationType.toString());
    }

    public static void setNativeIsInitializedForTesting(Boolean nativeIsInitialized) {
        var oldValue = sNativeIsInitialized;
        sNativeIsInitialized = nativeIsInitialized;
        ResettersForTesting.register(() -> sNativeIsInitialized = oldValue);
    }

    private static boolean nativeIsInitialized() {
        if (sNativeIsInitialized != null) return sNativeIsInitialized;
        return LibraryLoader.getInstance().isInitialized();
    }

    private static boolean checkForOverflow(Set<String> cache) {
        return cache != null && cache.equals(OVERFLOW_SENTINEL_SET);
    }

    private static void persistNotificationPayload(HintNotificationPayload payload) {
        if (!payload.hasOptimizationType()) return;
        if (!payload.hasKeyRepresentation()) return;
        if (!payload.hasHintKey()) return;

        Set<String> cache = getStringCacheForOptimizationType(payload.getOptimizationType());

        // Check if the cache is already overflowed, in which case we should do nothing.
        if (checkForOverflow(cache)) return;

        // Check if we would overflow the cache by writing the new element.
        if (cache.size() >= MAX_CACHE_SIZE.getValue() - 1) {
            ChromeSharedPreferences.getInstance()
                    .writeStringSet(cacheKey(payload.getOptimizationType()), OVERFLOW_SENTINEL_SET);
            return;
        }

        // The notification's payload isn't used so it can be stripped to preserve memory space.
        HintNotificationPayload slim_payload =
                HintNotificationPayload.newBuilder(payload).clearPayload().build();
        ChromeSharedPreferences.getInstance()
                .addToStringSet(
                        cacheKey(slim_payload.getOptimizationType()),
                        Base64.encodeToString(slim_payload.toByteArray(), Base64.DEFAULT));
    }
}