blob: 725bf0596c18ed2b8ba62a0cc7fabf4f4e4a8d30 [file] [log] [blame]
// Copyright 2017 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.
#include "components/ntp_snippets/breaking_news/breaking_news_gcm_app_handler.h"
#include "base/json/json_writer.h"
#include "base/strings/string_util.h"
#include "base/task/post_task.h"
#include "build/build_config.h"
#include "components/gcm_driver/gcm_driver.h"
#include "components/gcm_driver/gcm_profile_service.h"
#include "components/gcm_driver/instance_id/instance_id.h"
#include "components/gcm_driver/instance_id/instance_id_driver.h"
#include "components/ntp_snippets/breaking_news/breaking_news_metrics.h"
#include "components/ntp_snippets/features.h"
#include "components/ntp_snippets/pref_names.h"
#include "components/ntp_snippets/time_serialization.h"
#include "components/variations/variations_associated_data.h"
using instance_id::InstanceID;
namespace ntp_snippets {
namespace {
const char kBreakingNewsGCMAppID[] = "com.google.breakingnews.gcm";
// The sender ID is used in the registration process.
// See: https://developers.google.com/cloud-messaging/gcm#senderid
const char kBreakingNewsGCMSenderId[] = "667617379155";
// OAuth2 Scope passed to getToken to obtain GCM registration tokens.
// Must match Java GoogleCloudMessaging.INSTANCE_ID_SCOPE.
const char kGCMScope[] = "GCM";
// The action key in pushed GCM message.
const char kPushedActionKey[] = "action";
// Allowed action key values:
const char kPushToRefreshAction[] = "push-to-refresh";
const char kPushByValueAction[] = "push-by-value";
// Key of the news json in the data in the pushed breaking news.
const char kPushedNewsKey[] = "payload";
// Lower bound time between two token validations when listening.
const int kTokenValidationPeriodMinutesDefault = 60 * 24;
const char kTokenValidationPeriodMinutesParamName[] =
"token_validation_period_minutes";
base::TimeDelta GetTokenValidationPeriod() {
return base::TimeDelta::FromMinutes(
variations::GetVariationParamByFeatureAsInt(
kBreakingNewsPushFeature, kTokenValidationPeriodMinutesParamName,
kTokenValidationPeriodMinutesDefault));
}
const bool kEnableTokenValidationDefault = true;
const char kEnableTokenValidationParamName[] = "enable_token_validation";
bool IsTokenValidationEnabled() {
return variations::GetVariationParamByFeatureAsBool(
kBreakingNewsPushFeature, kEnableTokenValidationParamName,
kEnableTokenValidationDefault);
}
// Lower bound time between two forced subscriptions when listening. A
// forced subscription is a normal subscription to the content
// suggestions server, which cannot be omitted.
const int kForcedSubscriptionPeriodMinutesDefault = 60 * 24 * 7;
const char kForcedSubscriptionPeriodMinutesParamName[] =
"forced_subscription_period_minutes";
base::TimeDelta GetForcedSubscriptionPeriod() {
return base::TimeDelta::FromMinutes(
variations::GetVariationParamByFeatureAsInt(
kBreakingNewsPushFeature, kForcedSubscriptionPeriodMinutesParamName,
kForcedSubscriptionPeriodMinutesDefault));
}
const bool kEnableForcedSubscriptionDefault = true;
const char kEnableForcedSubscriptionParamName[] = "enable_forced_subscription";
bool IsForcedSubscriptionEnabled() {
return variations::GetVariationParamByFeatureAsBool(
kBreakingNewsPushFeature, kEnableForcedSubscriptionParamName,
kEnableForcedSubscriptionDefault);
}
} // namespace
BreakingNewsGCMAppHandler::BreakingNewsGCMAppHandler(
gcm::GCMDriver* gcm_driver,
instance_id::InstanceIDDriver* instance_id_driver,
PrefService* pref_service,
std::unique_ptr<SubscriptionManager> subscription_manager,
const ParseJSONCallback& parse_json_callback,
const base::Clock* clock,
std::unique_ptr<base::OneShotTimer> token_validation_timer,
std::unique_ptr<base::OneShotTimer> forced_subscription_timer)
: gcm_driver_(gcm_driver),
instance_id_driver_(instance_id_driver),
pref_service_(pref_service),
subscription_manager_(std::move(subscription_manager)),
parse_json_callback_(parse_json_callback),
clock_(clock),
token_validation_timer_(std::move(token_validation_timer)),
forced_subscription_timer_(std::move(forced_subscription_timer)),
weak_ptr_factory_(this) {
#if !defined(OS_ANDROID)
#error The BreakingNewsGCMAppHandler should only be used on Android.
#endif // !OS_ANDROID
DCHECK(token_validation_timer_);
DCHECK(!token_validation_timer_->IsRunning());
DCHECK(forced_subscription_timer_);
DCHECK(!forced_subscription_timer_->IsRunning());
}
BreakingNewsGCMAppHandler::~BreakingNewsGCMAppHandler() {
if (IsListening()) {
StopListening();
}
}
void BreakingNewsGCMAppHandler::StartListening(
OnNewRemoteSuggestionCallback on_new_remote_suggestion_callback,
OnRefreshRequestedCallback on_refresh_requested_callback) {
DCHECK(!IsListening());
DCHECK(!on_new_remote_suggestion_callback.is_null());
on_new_remote_suggestion_callback_ =
std::move(on_new_remote_suggestion_callback);
DCHECK(!on_refresh_requested_callback.is_null());
on_refresh_requested_callback_ = std::move(on_refresh_requested_callback);
Subscribe(/*force_token_retrieval=*/false);
gcm_driver_->AddAppHandler(kBreakingNewsGCMAppID, this);
if (IsTokenValidationEnabled()) {
ScheduleNextTokenValidation();
}
if (IsForcedSubscriptionEnabled()) {
ScheduleNextForcedSubscription();
}
}
void BreakingNewsGCMAppHandler::StopListening() {
DCHECK(IsListening());
token_validation_timer_->Stop();
forced_subscription_timer_->Stop();
DCHECK_EQ(gcm_driver_->GetAppHandler(kBreakingNewsGCMAppID), this);
gcm_driver_->RemoveAppHandler(kBreakingNewsGCMAppID);
on_new_remote_suggestion_callback_ = OnNewRemoteSuggestionCallback();
subscription_manager_->Unsubscribe();
}
bool BreakingNewsGCMAppHandler::IsListening() const {
return !on_new_remote_suggestion_callback_.is_null();
}
void BreakingNewsGCMAppHandler::Subscribe(bool force_token_retrieval) {
// TODO(mamir): "Whether to subscribe to content suggestions server" logic
// should be moved to the SubscriptionManager.
std::string token =
pref_service_->GetString(prefs::kBreakingNewsGCMSubscriptionTokenCache);
// If a token has been already obtained, subscribe directly at the content
// suggestions server. Otherwise, obtain a GCM token first.
if (!token.empty() && !force_token_retrieval) {
if (!subscription_manager_->IsSubscribed() ||
subscription_manager_->NeedsToResubscribe()) {
subscription_manager_->Subscribe(token);
}
return;
}
// TODO(vitaliii): Use |BindOnce| instead of |Bind|, because the callback is
// meant to be run only once.
instance_id_driver_->GetInstanceID(kBreakingNewsGCMAppID)
->GetToken(kBreakingNewsGCMSenderId, kGCMScope,
/*options=*/std::map<std::string, std::string>(),
/*is_lazy=*/false,
base::Bind(&BreakingNewsGCMAppHandler::DidRetrieveToken,
weak_ptr_factory_.GetWeakPtr()));
}
void BreakingNewsGCMAppHandler::DidRetrieveToken(
const std::string& subscription_token,
InstanceID::Result result) {
if (!IsListening()) {
// After we requested the token, |StopListening| has been called. Thus,
// ignore the token.
return;
}
metrics::OnTokenRetrieved(result);
switch (result) {
case InstanceID::SUCCESS:
// The received token is assumed to be valid, therefore, we reschedule
// validation.
pref_service_->SetInt64(prefs::kBreakingNewsGCMLastTokenValidationTime,
SerializeTime(clock_->Now()));
if (IsTokenValidationEnabled()) {
ScheduleNextTokenValidation();
}
pref_service_->SetString(prefs::kBreakingNewsGCMSubscriptionTokenCache,
subscription_token);
subscription_manager_->Subscribe(subscription_token);
return;
case InstanceID::INVALID_PARAMETER:
case InstanceID::DISABLED:
case InstanceID::ASYNC_OPERATION_PENDING:
case InstanceID::SERVER_ERROR:
case InstanceID::UNKNOWN_ERROR:
DLOG(WARNING)
<< "Push messaging subscription failed; InstanceID::Result = "
<< result;
break;
case InstanceID::NETWORK_ERROR:
break;
}
}
void BreakingNewsGCMAppHandler::ResubscribeIfInvalidToken() {
DCHECK(IsListening());
DCHECK(IsTokenValidationEnabled());
// InstanceIDAndroid::ValidateToken just returns |true| on Android. Instead it
// is ok to retrieve a token, because it is cached.
// TODO(vitaliii): Use |BindOnce| instead of |Bind|, because the callback is
// meant to be run only once.
instance_id_driver_->GetInstanceID(kBreakingNewsGCMAppID)
->GetToken(
kBreakingNewsGCMSenderId, kGCMScope,
/*options=*/std::map<std::string, std::string>(), /*is_lazy=*/false,
base::Bind(&BreakingNewsGCMAppHandler::DidReceiveTokenForValidation,
weak_ptr_factory_.GetWeakPtr()));
}
void BreakingNewsGCMAppHandler::DidReceiveTokenForValidation(
const std::string& new_token,
InstanceID::Result result) {
if (!IsListening()) {
// After we requested the token, |StopListening| has been called. Thus,
// ignore the token.
return;
}
metrics::OnTokenRetrieved(result);
base::Optional<base::TimeDelta> time_since_last_validation;
if (pref_service_->HasPrefPath(
prefs::kBreakingNewsGCMLastTokenValidationTime)) {
const base::Time last_validation_time =
DeserializeTime(pref_service_->GetInt64(
prefs::kBreakingNewsGCMLastTokenValidationTime));
time_since_last_validation = clock_->Now() - last_validation_time;
}
// We intentionally reschedule as normal even if we don't get a token.
pref_service_->SetInt64(prefs::kBreakingNewsGCMLastTokenValidationTime,
SerializeTime(clock_->Now()));
ScheduleNextTokenValidation();
base::Optional<bool> was_token_valid;
if (result == InstanceID::SUCCESS) {
const std::string old_token =
pref_service_->GetString(prefs::kBreakingNewsGCMSubscriptionTokenCache);
was_token_valid = old_token == new_token;
if (!*was_token_valid) {
subscription_manager_->Resubscribe(new_token);
}
}
metrics::OnTokenValidationAttempted(time_since_last_validation,
was_token_valid);
}
void BreakingNewsGCMAppHandler::ScheduleNextTokenValidation() {
DCHECK(IsListening());
DCHECK(IsTokenValidationEnabled());
const base::Time last_validation_time = DeserializeTime(
pref_service_->GetInt64(prefs::kBreakingNewsGCMLastTokenValidationTime));
// Timer runs the task immediately if delay is <= 0.
token_validation_timer_->Start(
FROM_HERE,
/*delay=*/last_validation_time + GetTokenValidationPeriod() -
clock_->Now(),
base::Bind(&BreakingNewsGCMAppHandler::ResubscribeIfInvalidToken,
weak_ptr_factory_.GetWeakPtr()));
}
void BreakingNewsGCMAppHandler::ForceSubscribe() {
DCHECK(IsForcedSubscriptionEnabled());
// We intentionally reschedule as normal even if there is no token or
// subscription fails.
pref_service_->SetInt64(prefs::kBreakingNewsGCMLastForcedSubscriptionTime,
SerializeTime(clock_->Now()));
ScheduleNextForcedSubscription();
const std::string token =
pref_service_->GetString(prefs::kBreakingNewsGCMSubscriptionTokenCache);
if (!token.empty()) {
subscription_manager_->Subscribe(token);
}
}
void BreakingNewsGCMAppHandler::ScheduleNextForcedSubscription() {
DCHECK(IsListening());
DCHECK(IsForcedSubscriptionEnabled());
const base::Time last_forced_subscription_time =
DeserializeTime(pref_service_->GetInt64(
prefs::kBreakingNewsGCMLastForcedSubscriptionTime));
// Timer runs the task immediately if delay is <= 0.
forced_subscription_timer_->Start(
FROM_HERE,
/*delay=*/last_forced_subscription_time + GetForcedSubscriptionPeriod() -
clock_->Now(),
base::Bind(&BreakingNewsGCMAppHandler::ForceSubscribe,
weak_ptr_factory_.GetWeakPtr()));
}
void BreakingNewsGCMAppHandler::ShutdownHandler() {}
void BreakingNewsGCMAppHandler::OnStoreReset() {
pref_service_->ClearPref(prefs::kBreakingNewsGCMSubscriptionTokenCache);
}
void BreakingNewsGCMAppHandler::OnMessage(const std::string& app_id,
const gcm::IncomingMessage& message) {
DCHECK_EQ(app_id, kBreakingNewsGCMAppID);
if (!IsListening()) {
// The content suggestions server may push a message right when the client
// unsubscribes leading to a race condition. Ignore such messages.
DLOG(WARNING) << "Received a pushed message while not listening.";
return;
}
gcm::MessageData::const_iterator it = message.data.find(kPushedActionKey);
bool contains_pushed_action = (it != message.data.end());
if (!contains_pushed_action) {
LOG(WARNING) << "Receiving pushed content failure: Action is missing.";
metrics::OnMessageReceived(metrics::ReceivedMessageAction::NO_ACTION);
return;
}
const std::string& action = it->second;
if (action == kPushToRefreshAction) {
metrics::OnMessageReceived(metrics::ReceivedMessageAction::PUSH_TO_REFRESH);
OnPushToRefreshMessage();
return;
}
if (action == kPushByValueAction) {
metrics::OnMessageReceived(metrics::ReceivedMessageAction::PUSH_BY_VALUE);
OnPushByValueMessage(message);
return;
}
LOG(WARNING) << "Receiving pushed content failure: Invalid action.";
metrics::OnMessageReceived(metrics::ReceivedMessageAction::INVALID_ACTION);
}
void BreakingNewsGCMAppHandler::OnPushByValueMessage(
const gcm::IncomingMessage& message) {
gcm::MessageData::const_iterator it = message.data.find(kPushedNewsKey);
bool contains_pushed_news = (it != message.data.end());
if (!contains_pushed_news) {
LOG(WARNING)
<< "Receiving pushed content failure: Breaking News ID missing.";
}
const std::string& news = it->second;
parse_json_callback_.Run(news,
base::Bind(&BreakingNewsGCMAppHandler::OnJsonSuccess,
weak_ptr_factory_.GetWeakPtr()),
base::Bind(&BreakingNewsGCMAppHandler::OnJsonError,
weak_ptr_factory_.GetWeakPtr(), news));
}
void BreakingNewsGCMAppHandler::OnPushToRefreshMessage() {
on_refresh_requested_callback_.Run();
}
void BreakingNewsGCMAppHandler::OnMessagesDeleted(const std::string& app_id) {
// Messages don't get deleted.
NOTREACHED() << "BreakingNewsGCMAppHandler messages don't get deleted.";
}
void BreakingNewsGCMAppHandler::OnSendError(
const std::string& app_id,
const gcm::GCMClient::SendErrorDetails& details) {
// Should never be called because we don't send GCM messages to
// the server.
NOTREACHED() << "BreakingNewsGCMAppHandler doesn't send GCM messages.";
}
void BreakingNewsGCMAppHandler::OnSendAcknowledged(
const std::string& app_id,
const std::string& message_id) {
// Should never be called because we don't send GCM messages to
// the server.
NOTREACHED() << "BreakingNewsGCMAppHandler doesn't send GCM messages.";
}
// static
void BreakingNewsGCMAppHandler::RegisterProfilePrefs(
PrefRegistrySimple* registry) {
registry->RegisterStringPref(prefs::kBreakingNewsGCMSubscriptionTokenCache,
/*default_value=*/std::string());
registry->RegisterInt64Pref(prefs::kBreakingNewsGCMLastTokenValidationTime,
/*default_value=*/0);
registry->RegisterInt64Pref(prefs::kBreakingNewsGCMLastForcedSubscriptionTime,
/*default_value=*/0);
}
// TODO(vitaliii): Add a test to ensure that this clears everything.
// static
void BreakingNewsGCMAppHandler::ClearProfilePrefs(PrefService* pref_service) {
pref_service->ClearPref(prefs::kBreakingNewsGCMSubscriptionTokenCache);
pref_service->ClearPref(prefs::kBreakingNewsGCMLastTokenValidationTime);
pref_service->ClearPref(prefs::kBreakingNewsGCMSubscriptionTokenCache);
}
void BreakingNewsGCMAppHandler::OnJsonSuccess(
std::unique_ptr<base::Value> content) {
DCHECK(content);
if (!IsListening()) {
// |StopListening| might be called after JSON parse request is submitted,
// but the request cannot be canceled, so we just ignore the parsed JSON.
return;
}
std::vector<FetchedCategory> fetched_categories;
if (!JsonToCategories(*content, &fetched_categories,
/*fetch_time=*/base::Time::Now())) {
std::string content_json;
base::JSONWriter::Write(*content, &content_json);
LOG(WARNING)
<< "Received invalid breaking news: can't interpret value, json is "
<< content_json;
return;
}
if (fetched_categories.size() != 1) {
LOG(WARNING)
<< "Received invalid breaking news: expected 1 category, but got "
<< fetched_categories.size();
return;
}
if (fetched_categories[0].suggestions.size() != 1) {
LOG(WARNING)
<< "Received invalid breaking news: expected 1 suggestion, but got "
<< fetched_categories[0].suggestions.size();
return;
}
on_new_remote_suggestion_callback_.Run(
std::move(fetched_categories[0].suggestions[0]));
}
void BreakingNewsGCMAppHandler::OnJsonError(const std::string& json_str,
const std::string& error) {
LOG(WARNING) << "Error parsing JSON:" << error
<< " when parsing:" << json_str;
}
} // namespace ntp_snippets