chromium/ios/chrome/browser/commerce/model/shopping_persisted_data_tab_helper.mm

// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "ios/chrome/browser/commerce/model/shopping_persisted_data_tab_helper.h"

#import "base/metrics/histogram_functions.h"
#import "base/strings/string_number_conversions.h"
#import "base/strings/stringprintf.h"
#import "base/strings/sys_string_conversions.h"
#import "base/strings/utf_string_conversions.h"
#import "components/optimization_guide/core/optimization_metadata.h"
#import "ios/chrome/browser/commerce/model/price_alert_util.h"
#import "ios/chrome/browser/optimization_guide/model/optimization_guide_service.h"
#import "ios/chrome/browser/optimization_guide/model/optimization_guide_service_factory.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/web/public/navigation/navigation_context.h"
#import "ios/web/public/navigation/navigation_item.h"
#import "ios/web/public/navigation/navigation_manager.h"

namespace {
const int kUnitsToMicros = 1000000;
const int kMinimumDropThresholdAbsolute = 2 * kUnitsToMicros;
const int kMinimumDropThresholdRelative = 10;
const int kMicrosToTwoDecimalPlaces = 10000;
const int kTwoDecimalPlacesMaximumThreshold = 10 * kUnitsToMicros;
const int kStaleThresholdHours = 1;
const base::TimeDelta kStaleDuration = base::Hours(kStaleThresholdHours);
const base::TimeDelta kActiveTabThreshold = base::Days(1);
const char kTabSwitcherMetricsString[] = "EnterTabSwitcher";
const char kFinishNavigationMetricsString[] = "NavigationComplete";
const char kActiveTabMetricsString[] = "ActiveTab";
const char kStaleTabMetricsString[] = "StaleTab";

// Returns true if a cached price drop has gone stale and should be
// re-fetched from OptimizationGuide.
BOOL IsPriceDropStale(base::Time price_drop_timestamp) {
  return base::Time::Now() - price_drop_timestamp > kStaleDuration;
}

const char* GetLogIdString(PriceDropLogId& log_id) {
  switch (log_id) {
    case TAB_SWITCHER:
      return kTabSwitcherMetricsString;
    case NAVIGATION_COMPLETE:
      return kFinishNavigationMetricsString;
  }
  NOTREACHED_IN_MIGRATION() << "Unknown PriceDropLogId " << log_id;
  return "";
}

const char* GetTabStatusString(base::Time time_last_accessed) {
  if (base::Time::Now() - time_last_accessed < kActiveTabThreshold) {
    return kActiveTabMetricsString;
  } else {
    return kStaleTabMetricsString;
  }
}

}  // namespace

ShoppingPersistedDataTabHelper::~ShoppingPersistedDataTabHelper() {
  if (web_state_) {
    web_state_->RemoveObserver(this);
    web_state_ = nullptr;
  }
}

ShoppingPersistedDataTabHelper::PriceDrop::PriceDrop()
    : current_price(nil),
      previous_price(nil),
      offer_id(std::nullopt),
      url(GURL(std::string())),
      timestamp(base::Time::UnixEpoch()) {}

ShoppingPersistedDataTabHelper::PriceDrop::~PriceDrop() = default;

const ShoppingPersistedDataTabHelper::PriceDrop*
ShoppingPersistedDataTabHelper::GetPriceDrop() {
  if (!IsPriceAlertsEligible(web_state_->GetBrowserState())) {
    return nullptr;
  }
  const GURL& url = web_state_->GetLastCommittedURL().is_valid()
                        ? web_state_->GetLastCommittedURL()
                        : web_state_->GetVisibleURL();
  if (!price_drop_ || price_drop_->url != url ||
      IsPriceDropStale(price_drop_->timestamp)) {
    ResetPriceDrop();
    OptimizationGuideService* optimization_guide_service =
        OptimizationGuideServiceFactory::GetForBrowserState(
            ChromeBrowserState::FromBrowserState(
                web_state_->GetBrowserState()));
    if (!optimization_guide_service) {
      return nullptr;
    }
    optimization_guide::OptimizationMetadata metadata;
    if (optimization_guide_service->CanApplyOptimization(
            url, optimization_guide::proto::PRICE_TRACKING, &metadata) !=
        optimization_guide::OptimizationGuideDecision::kTrue) {
      return nullptr;
    }
    ParseProto(url, metadata.ParsedMetadata<commerce::PriceTrackingData>());
  }
  if (price_drop_) {
    return price_drop_.get();
  }
  return nullptr;
}

void ShoppingPersistedDataTabHelper::LogMetrics(PriceDropLogId log_id) {
  const char* tab_status = GetTabStatusString(web_state_->GetLastActiveTime());
  const char* log_id_string = GetLogIdString(log_id);
  base::UmaHistogramBoolean(
      base::StringPrintf("Commerce.PriceDrops.%s%s.ContainsPrice", tab_status,
                         log_id_string),
      price_drop_ && price_drop_->current_price);
  base::UmaHistogramBoolean(
      base::StringPrintf("Commerce.PriceDrops.%s%s.ContainsPriceDrop",
                         tab_status, log_id_string),
      price_drop_ && price_drop_->current_price && price_drop_->previous_price);
  base::UmaHistogramBoolean(
      base::StringPrintf("Commerce.PriceDrops.%s%s.IsProductDetailPage",
                         tab_status, log_id_string),
      price_drop_ && price_drop_->offer_id);
}

ShoppingPersistedDataTabHelper::ShoppingPersistedDataTabHelper(
    web::WebState* web_state)
    : web_state_(web_state) {
  web_state_->AddObserver(this);

  OptimizationGuideService* optimization_guide_service =
      OptimizationGuideServiceFactory::GetForBrowserState(
          ChromeBrowserState::FromBrowserState(web_state_->GetBrowserState()));

  if (!optimization_guide_service) {
    return;
  }

  optimization_guide_service->RegisterOptimizationTypes(
      {optimization_guide::proto::PRICE_TRACKING});
}

// static
BOOL ShoppingPersistedDataTabHelper::IsQualifyingPriceDrop(
    int64_t current_price_micros,
    int64_t previous_price_micros) {
  if (previous_price_micros - current_price_micros <
      kMinimumDropThresholdAbsolute) {
    return false;
  }
  if ((100 * current_price_micros) / previous_price_micros >
      (100 - kMinimumDropThresholdRelative)) {
    return false;
  }
  return true;
}

// static
std::u16string ShoppingPersistedDataTabHelper::FormatPrice(
    payments::CurrencyFormatter* currency_formatter,
    long price_micros) {
  currency_formatter->SetMaxFractionalDigits(
      price_micros >= kTwoDecimalPlacesMaximumThreshold ? 0 : 2);
  long twoDecimalPlaces = price_micros / kMicrosToTwoDecimalPlaces;
  std::u16string result = currency_formatter->Format(base::StringPrintf(
      "%s.%s", base::NumberToString(twoDecimalPlaces / 100).c_str(),
      base::NumberToString(twoDecimalPlaces % 100).c_str()));
  return result;
}

void ShoppingPersistedDataTabHelper::DidFinishNavigation(
    web::WebState* web_state,
    web::NavigationContext* navigation_context) {
  if (!IsPriceAlertsEligible(web_state->GetBrowserState())) {
    return;
  }
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);

  if (!navigation_context->GetUrl().SchemeIsHTTPOrHTTPS()) {
    return;
  }

  ResetPriceDrop();
  OptimizationGuideService* optimization_guide_service =
      OptimizationGuideServiceFactory::GetForBrowserState(
          ChromeBrowserState::FromBrowserState(web_state->GetBrowserState()));
  if (!optimization_guide_service) {
    return;
  }

  optimization_guide_service->CanApplyOptimization(
      navigation_context->GetUrl(), optimization_guide::proto::PRICE_TRACKING,
      base::BindOnce(
          &ShoppingPersistedDataTabHelper::OnOptimizationGuideResultReceived,
          weak_factory_.GetWeakPtr(), navigation_context->GetUrl()));
}

void ShoppingPersistedDataTabHelper::WebStateDestroyed(
    web::WebState* web_state) {
  web_state->RemoveObserver(this);
  web_state_ = nullptr;
}

void ShoppingPersistedDataTabHelper::OnOptimizationGuideResultReceived(
    const GURL& url,
    optimization_guide::OptimizationGuideDecision decision,
    const optimization_guide::OptimizationMetadata& metadata) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  if (decision != optimization_guide::OptimizationGuideDecision::kTrue) {
    LogMetrics(NAVIGATION_COMPLETE);
    return;
  }

  ParseProto(url, metadata.ParsedMetadata<commerce::PriceTrackingData>());
  LogMetrics(NAVIGATION_COMPLETE);
}

payments::CurrencyFormatter*
ShoppingPersistedDataTabHelper::GetCurrencyFormatter(
    const std::string& currency_code,
    const std::string& locale_name) {
  // Create a currency formatter for `currency_code`, or if already created
  // return the cached version.
  std::pair<std::map<std::string, payments::CurrencyFormatter>::iterator, bool>
      emplace_result = currency_formatter_map_.emplace(
          std::piecewise_construct, std::forward_as_tuple(currency_code),
          std::forward_as_tuple(currency_code, locale_name));
  return &(emplace_result.first->second);
}

void ShoppingPersistedDataTabHelper::ParseProto(
    const GURL& url,
    const std::optional<commerce::PriceTrackingData>& price_metadata) {
  if (!price_metadata) {
    return;
  }
  // TODO(crbug.com/40205382) Change PriceDrop to PriceData.
  price_drop_ = std::make_unique<PriceDrop>();
  if (price_metadata->has_buyable_product() &&
      price_metadata->buyable_product().has_offer_id()) {
    price_drop_->offer_id = price_metadata->buyable_product().offer_id();
  }
  if (!price_metadata->has_product_update()) {
    return;
  }

  const auto& product_update = price_metadata->product_update();
  if (!product_update.has_old_price() || !product_update.has_new_price()) {
    return;
  }

  if (product_update.old_price().currency_code() !=
      product_update.new_price().currency_code()) {
    return;
  }

  if (!IsQualifyingPriceDrop(product_update.new_price().amount_micros(),
                             product_update.old_price().amount_micros())) {
    return;
  }

  // TODO(crbug.com/40794608) Filter out non-qualifying price drops (< 10% or
  // < 2 units).
  payments::CurrencyFormatter* currencyFormatter =
      GetCurrencyFormatter(product_update.old_price().currency_code(),
                           GetApplicationContext()->GetApplicationLocale());
  price_drop_->current_price = base::SysUTF16ToNSString(FormatPrice(
      currencyFormatter, product_update.new_price().amount_micros()));
  price_drop_->previous_price = base::SysUTF16ToNSString(FormatPrice(
      currencyFormatter, product_update.old_price().amount_micros()));
  price_drop_->url = url;
  price_drop_->timestamp = base::Time::Now();
  if (product_update.has_offer_id()) {
    price_drop_->offer_id = product_update.offer_id();
  }
}

void ShoppingPersistedDataTabHelper::ResetPriceDrop() {
  price_drop_ = nullptr;
}

WEB_STATE_USER_DATA_KEY_IMPL(ShoppingPersistedDataTabHelper)