blob: 84ba336d74df98906a4276052d06c33061571971 [file] [log] [blame]
// Copyright 2021 The Chromium Authors. All rights reserved.
// 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.subscriptions;
import android.os.Build;
import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.Callback;
import org.chromium.base.ObserverList;
import org.chromium.chrome.browser.price_tracking.PriceDropNotificationManager;
import org.chromium.chrome.browser.profiles.Profile;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
/**
* Implementation of {@link SubscriptionsManager} to manage price drop related subscriptions.
* TODO(crbug.com/1186450): Pull subscription type specific code into respective handlers to
* simplify this class.
*/
public class SubscriptionsManagerImpl implements SubscriptionsManager {
@IntDef({Operation.SUBSCRIBE, Operation.UNSUBSCRIBE})
@Retention(RetentionPolicy.SOURCE)
public @interface Operation {
int SUBSCRIBE = 0;
int UNSUBSCRIBE = 1;
}
private final CommerceSubscriptionsStorage mStorage;
private final CommerceSubscriptionsServiceProxy mServiceProxy;
private static List<CommerceSubscription> sRemoteSubscriptionsForTesting;
private boolean mCanHandleRequests;
private Queue<DeferredSubscriptionOperation> mDeferredTasks;
private final ObserverList<SubscriptionObserver> mObservers;
private static class DeferredSubscriptionOperation {
private final @Operation int mOperation;
private final List<CommerceSubscription> mSubscriptions;
private final Callback<Integer> mCallback;
public DeferredSubscriptionOperation(@Operation int operation,
List<CommerceSubscription> subscriptions, Callback<Integer> callback) {
mOperation = operation;
mSubscriptions = subscriptions;
mCallback = callback;
}
public @Operation int getOperation() {
return mOperation;
}
public List<CommerceSubscription> getSubscriptions() {
return mSubscriptions;
}
public Callback<Integer> getCallback() {
return mCallback;
}
}
public SubscriptionsManagerImpl(Profile profile) {
this(profile, new CommerceSubscriptionsStorage(profile),
new CommerceSubscriptionsServiceProxy(profile));
}
@VisibleForTesting
SubscriptionsManagerImpl(Profile profile, CommerceSubscriptionsStorage storage,
CommerceSubscriptionsServiceProxy proxy) {
mStorage = storage;
mServiceProxy = proxy;
mDeferredTasks = new LinkedList<>();
mCanHandleRequests = false;
initTypes(this::onInitComplete);
mObservers = new ObserverList<>();
}
@Override
public void addObserver(SubscriptionObserver observer) {
mObservers.addObserver(observer);
}
@Override
public void removeObserver(SubscriptionObserver observer) {
mObservers.removeObserver(observer);
}
/**
* Creates a new subscription on the server-side and refreshes the local storage of
* subscriptions.
* @param subscription The {@link CommerceSubscription} to add.
* @param callback indicates whether or not the operation was successful.
*/
@Override
public void subscribe(CommerceSubscription subscription, Callback<Integer> callback) {
if (subscription == null || !isSubscriptionTypeSupported(subscription.getType())) {
callback.onResult(SubscriptionsManager.StatusCode.INVALID_ARGUMENT);
return;
}
subscribe(new ArrayList<CommerceSubscription>() {
{ add(subscription); };
}, callback);
}
/**
* Creates new subscriptions in batch if needed.
* @param subscriptions The list of {@link CommerceSubscription} to add.
* @param callback indicates whether or not the operation was successful.
*/
@Override
public void subscribe(List<CommerceSubscription> subscriptions, Callback<Integer> callback) {
if (subscriptions.size() == 0) {
callback.onResult(SubscriptionsManager.StatusCode.OK);
return;
}
// Wrap the callback in one that allows us to trigger the observers.
Callback<Integer> wrappedCallback = (status) -> {
if (status == StatusCode.OK) {
for (SubscriptionObserver o : mObservers) {
o.onSubscribe(subscriptions);
}
}
callback.onResult(status);
};
String type = subscriptions.get(0).getType();
if (!isSubscriptionTypeSupported(type)) {
wrappedCallback.onResult(SubscriptionsManager.StatusCode.INVALID_ARGUMENT);
return;
}
// Make sure the notification channel is initialized if there is a user-managed PRICE_TRACK
// subscription. For chrome-managed subscriptions, channel will be initialized via message
// card in tab switcher.
if (CommerceSubscription.CommerceSubscriptionType.PRICE_TRACK.equals(type)
&& CommerceSubscription.SubscriptionManagementType.USER_MANAGED.equals(
subscriptions.get(0).getManagementType())
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
(new PriceDropNotificationManager()).createNotificationChannel();
}
if (!mCanHandleRequests) {
mDeferredTasks.add(new DeferredSubscriptionOperation(
Operation.SUBSCRIBE, subscriptions, wrappedCallback));
return;
}
getUniqueSubscriptions(subscriptions, (list) -> {
if (list.size() == 0) {
wrappedCallback.onResult(SubscriptionsManager.StatusCode.OK);
} else {
mServiceProxy.create(list,
(didSucceed)
-> handleUpdateSubscriptionsResponse(
didSucceed, type, wrappedCallback));
}
});
}
/**
* Destroys a subscription on the server-side and refreshes the local storage of subscriptions.
* @param subscription The {@link CommerceSubscription} to destroy.
* @param callback indicates whether or not the operation was successful.
*/
@Override
public void unsubscribe(CommerceSubscription subscription, Callback<Integer> callback) {
String type = subscription.getType();
if (subscription == null || !isSubscriptionTypeSupported(type)) {
callback.onResult(SubscriptionsManager.StatusCode.INVALID_ARGUMENT);
return;
}
unsubscribe(new ArrayList<CommerceSubscription>() {
{ add(subscription); };
}, callback);
}
/**
* Returns all subscriptions that match the provided type.
* @param type The {@link CommerceSubscription.CommerceSubscriptionType} to query.
* @param forceFetch Whether to fetch from server. If no, fetch from local storage.
* @param callback returns the list of subscriptions.
*/
@Override
public void getSubscriptions(@CommerceSubscription.CommerceSubscriptionType String type,
boolean forceFetch, Callback<List<CommerceSubscription>> callback) {
if (sRemoteSubscriptionsForTesting != null) {
callback.onResult(sRemoteSubscriptionsForTesting);
return;
}
if (forceFetch) {
mServiceProxy.get(type, callback);
} else {
mStorage.loadWithPrefix(String.valueOf(type),
localSubscriptions -> callback.onResult(localSubscriptions));
}
}
/**
* Checks if the given subscription matches any subscriptions in local storage.
*
* @param subscription The subscription to check.
* @param callback The callback to receive the result.
*/
@Override
public void isSubscribed(CommerceSubscription subscription, Callback<Boolean> callback) {
if (subscription == null) {
callback.onResult(false);
return;
}
// Searching by prefix instead of loading by key to handle cases of duplicates.
String targetKey = CommerceSubscriptionsStorage.getKey(subscription);
mStorage.loadWithPrefix(targetKey, localSubscriptions -> {
// TODO: (crbug/1279519) CommerceSubscriptionsStorage should support full key matching
// and we shouldn't need to perform this additional check.
for (CommerceSubscription current : localSubscriptions) {
if (targetKey.equals(CommerceSubscriptionsStorage.getKey(current))) {
callback.onResult(true);
return;
}
}
callback.onResult(false);
});
}
private void unsubscribe(List<CommerceSubscription> subscriptions, Callback<Integer> callback) {
String type = subscriptions.get(0).getType();
if (subscriptions == null || !isSubscriptionTypeSupported(type)) {
callback.onResult(SubscriptionsManager.StatusCode.INVALID_ARGUMENT);
return;
}
if (subscriptions.size() == 0) {
callback.onResult(SubscriptionsManager.StatusCode.OK);
return;
}
// Wrap the callback in one that allows us to trigger the observers.
Callback<Integer> wrappedCallback = (status) -> {
if (status == StatusCode.OK) {
for (SubscriptionObserver o : mObservers) {
o.onUnsubscribe(subscriptions);
}
}
callback.onResult(status);
};
if (!mCanHandleRequests) {
mDeferredTasks.add(new DeferredSubscriptionOperation(
Operation.UNSUBSCRIBE, subscriptions, wrappedCallback));
return;
}
Map<String, CommerceSubscription> subscriptionsMap = getSubscriptionsMap(subscriptions);
mStorage.loadWithPrefix(String.valueOf(type), localSubscriptions -> {
if (localSubscriptions.size() == 0) {
wrappedCallback.onResult(SubscriptionsManager.StatusCode.OK);
return;
}
List<CommerceSubscription> subscriptionsToDelete =
new ArrayList<CommerceSubscription>();
for (CommerceSubscription current : localSubscriptions) {
String key = CommerceSubscriptionsStorage.getKey(current);
if (subscriptionsMap.containsKey(key)) {
subscriptionsToDelete.add(current);
}
}
if (subscriptionsToDelete.size() == 0) {
wrappedCallback.onResult(SubscriptionsManager.StatusCode.OK);
return;
}
mServiceProxy.delete(subscriptionsToDelete,
(didSucceed)
-> handleUpdateSubscriptionsResponse(
didSucceed, type, wrappedCallback));
});
}
// Calls the backend for known types and updates the local cache.
private void initTypes(Callback<Integer> callback) {
String type = CommerceSubscription.CommerceSubscriptionType.PRICE_TRACK;
getSubscriptions(type, true,
remoteSubscriptions
-> updateStorageWithSubscriptions(type, remoteSubscriptions, callback));
}
// Updates the local cache + the state of whether or not the object can start handling requests
// based on the initial response form the server.
private void onInitComplete(@SubscriptionsManager.StatusCode Integer result) {
mCanHandleRequests = true;
if (result == SubscriptionsManager.StatusCode.OK) {
for (DeferredSubscriptionOperation item : mDeferredTasks) {
if (Operation.SUBSCRIBE == item.getOperation()) {
subscribe(item.getSubscriptions(), item.getCallback());
} else if (Operation.UNSUBSCRIBE == item.getOperation()) {
unsubscribe(item.getSubscriptions(), item.getCallback());
}
}
} else {
// Resolve all pending callbacks with an internal error and clear the queue.
// TODO: add a retry in case of a network failure.
for (DeferredSubscriptionOperation item : mDeferredTasks) {
item.getCallback().onResult(SubscriptionsManager.StatusCode.INTERNAL_ERROR);
}
}
mDeferredTasks.clear();
}
private boolean isSubscriptionTypeSupported(
@CommerceSubscription.CommerceSubscriptionType String type) {
return CommerceSubscription.CommerceSubscriptionType.PRICE_TRACK.equals(type);
}
private void updateStorageWithSubscriptions(
@CommerceSubscription.CommerceSubscriptionType String type,
List<CommerceSubscription> remoteSubscriptions, Callback<Integer> callback) {
mStorage.loadWithPrefix(String.valueOf(type), localSubscriptions -> {
for (CommerceSubscription subscription : localSubscriptions) {
if (!remoteSubscriptions.contains(subscription)) {
mStorage.delete(subscription);
}
}
for (CommerceSubscription subscription : remoteSubscriptions) {
if (!localSubscriptions.contains(subscription)) {
mStorage.save(subscription);
}
}
callback.onResult(SubscriptionsManager.StatusCode.OK);
});
}
private void handleUpdateSubscriptionsResponse(Boolean didSucceed,
@CommerceSubscription.CommerceSubscriptionType String type,
Callback<Integer> callback) {
if (!didSucceed) {
callback.onResult(SubscriptionsManager.StatusCode.NETWORK_ERROR);
return;
} else {
getSubscriptions(type, true,
remoteSubscriptions
-> updateStorageWithSubscriptions(type, remoteSubscriptions, callback));
}
}
// Creates a Key-Subscription map where key is generated using {@link
// CommerceSubscriptionsStorage#getKey}.
private Map<String, CommerceSubscription> getSubscriptionsMap(
List<CommerceSubscription> subscriptions) {
Map<String, CommerceSubscription> subscriptionsMap =
new HashMap<String, CommerceSubscription>();
for (CommerceSubscription current : subscriptions) {
subscriptionsMap.put(CommerceSubscriptionsStorage.getKey(current), current);
}
return subscriptionsMap;
}
// Compares the provided subscriptions list against the local cache and only returns the ones
// that are not in the local cache.
private void getUniqueSubscriptions(List<CommerceSubscription> subscriptions,
Callback<List<CommerceSubscription>> callback) {
String type = subscriptions.get(0).getType();
mStorage.loadWithPrefix(String.valueOf(type), localSubscriptions -> {
if (localSubscriptions.size() == 0) {
callback.onResult(subscriptions);
return;
}
List<CommerceSubscription> result = new ArrayList<CommerceSubscription>();
Map<String, CommerceSubscription> localSubscriptionsMap =
getSubscriptionsMap(localSubscriptions);
for (CommerceSubscription subscription : subscriptions) {
String key = CommerceSubscriptionsStorage.getKey(subscription);
if (!localSubscriptionsMap.containsKey(key)) {
result.add(subscription);
}
}
callback.onResult(result);
});
}
@VisibleForTesting
public void setRemoteSubscriptionsForTesting(List<CommerceSubscription> subscriptions) {
sRemoteSubscriptionsForTesting = subscriptions;
}
@VisibleForTesting
public void setCanHandlerequests(boolean value) {
mCanHandleRequests = value;
}
}