chromium/ios/chrome/browser/web/model/font_size/font_size_tab_helper.mm

// 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.

#import "ios/chrome/browser/web/model/font_size/font_size_tab_helper.h"

#import <UIKit/UIKit.h>

#import "base/containers/adapters.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/strings/stringprintf.h"
#import "base/strings/sys_string_conversions.h"
#import "base/values.h"
#import "components/google/core/common/google_util.h"
#import "components/pref_registry/pref_registry_syncable.h"
#import "components/prefs/pref_service.h"
#import "components/prefs/scoped_user_pref_update.h"
#import "components/ukm/ios/ukm_url_recorder.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/web/model/features.h"
#import "ios/chrome/browser/web/model/font_size/font_size_java_script_feature.h"
#import "ios/components/ui_util/dynamic_type_util.h"
#import "ios/public/provider/chrome/browser/text_zoom/text_zoom_api.h"
#import "services/metrics/public/cpp/ukm_builders.h"

namespace {

// Content size category to report UMA metrics.
// These values are persisted to logs. Entries should not be renumbered and
// numeric values should never be reused.
enum class IOSContentSizeCategory {
  kUnspecified = 0,
  kExtraSmall = 1,
  kSmall = 2,
  kMedium = 3,
  kLarge = 4,
  kExtraLarge = 5,
  kExtraExtraLarge = 6,
  kExtraExtraExtraLarge = 7,
  kAccessibilityMedium = 8,
  kAccessibilityLarge = 9,
  kAccessibilityExtraLarge = 10,
  kAccessibilityExtraExtraLarge = 11,
  kAccessibilityExtraExtraExtraLarge = 12,
  kMaxValue = kAccessibilityExtraExtraExtraLarge,
};

// Converts a UIKit content size category to a content size category for
// reporting.
IOSContentSizeCategory IOSContentSizeCategoryForCurrentUIContentSizeCategory() {
  UIContentSizeCategory size =
      UIApplication.sharedApplication.preferredContentSizeCategory;
  if ([size isEqual:UIContentSizeCategoryUnspecified]) {
    return IOSContentSizeCategory::kUnspecified;
  }
  if ([size isEqual:UIContentSizeCategoryExtraSmall]) {
    return IOSContentSizeCategory::kExtraSmall;
  }
  if ([size isEqual:UIContentSizeCategorySmall]) {
    return IOSContentSizeCategory::kSmall;
  }
  if ([size isEqual:UIContentSizeCategoryMedium]) {
    return IOSContentSizeCategory::kMedium;
  }
  if ([size isEqual:UIContentSizeCategoryLarge]) {
    return IOSContentSizeCategory::kLarge;
  }
  if ([size isEqual:UIContentSizeCategoryExtraLarge]) {
    return IOSContentSizeCategory::kExtraLarge;
  }
  if ([size isEqual:UIContentSizeCategoryExtraExtraLarge]) {
    return IOSContentSizeCategory::kExtraExtraLarge;
  }
  if ([size isEqual:UIContentSizeCategoryExtraExtraExtraLarge]) {
    return IOSContentSizeCategory::kExtraExtraExtraLarge;
  }
  if ([size isEqual:UIContentSizeCategoryAccessibilityMedium]) {
    return IOSContentSizeCategory::kAccessibilityMedium;
  }
  if ([size isEqual:UIContentSizeCategoryAccessibilityLarge]) {
    return IOSContentSizeCategory::kAccessibilityLarge;
  }
  if ([size isEqual:UIContentSizeCategoryAccessibilityExtraLarge]) {
    return IOSContentSizeCategory::kAccessibilityExtraLarge;
  }
  if ([size isEqual:UIContentSizeCategoryAccessibilityExtraExtraLarge]) {
    return IOSContentSizeCategory::kAccessibilityExtraExtraLarge;
  }
  if ([size isEqual:UIContentSizeCategoryAccessibilityExtraExtraExtraLarge]) {
    return IOSContentSizeCategory::kAccessibilityExtraExtraExtraLarge;
  }

  return IOSContentSizeCategory::kUnspecified;
}

}  // namespace

FontSizeTabHelper::FontSizeTabHelper(web::WebState* web_state)
    : web_state_(web_state), weak_factory_(this) {
  DCHECK(ios::provider::IsTextZoomEnabled());
  web_state->AddObserver(this);

  if (web_state->IsRealized()) {
    CreateNotificationObserver();
  }
}

FontSizeTabHelper::~FontSizeTabHelper() {}

// static
void FontSizeTabHelper::RegisterBrowserStatePrefs(
    user_prefs::PrefRegistrySyncable* registry) {
  registry->RegisterDictionaryPref(prefs::kIosUserZoomMultipliers);
}

void FontSizeTabHelper::ClearUserZoomPrefs(PrefService* pref_service) {
  pref_service->ClearPref(prefs::kIosUserZoomMultipliers);
}

void FontSizeTabHelper::SetPageFontSize(int size) {
  if (!CurrentPageSupportsTextZoom()) {
    return;
  }
  tab_helper_has_zoomed_ = true;

  ios::provider::SetTextZoomForWebState(web_state_, size);
}

void FontSizeTabHelper::UserZoom(Zoom zoom) {
  DCHECK(CurrentPageSupportsTextZoom());
  double new_zoom_multiplier = NewMultiplierAfterZoom(zoom).value_or(1);
  StoreCurrentUserZoomMultiplier(new_zoom_multiplier);

  LogZoomEvent(zoom);

  SetPageFontSize(GetFontSize());
}

void FontSizeTabHelper::LogZoomEvent(Zoom zoom) const {
  // Log when the user zooms to see if there are certain websites that are
  // broken when zooming.
  IOSContentSizeCategory content_size_category =
      IOSContentSizeCategoryForCurrentUIContentSizeCategory();
  ukm::UkmRecorder* ukm_recorder = GetApplicationContext()->GetUkmRecorder();
  ukm::SourceId source_id = ukm::GetSourceIdForWebStateDocument(web_state_);
  ukm::builders::IOS_PageZoomChanged(source_id)
      .SetContentSizeCategory(static_cast<int>(content_size_category))
      .SetUserZoomLevel(GetCurrentUserZoomMultiplier() * 100)
      .SetOverallZoomLevel(GetFontSize())
      .Record(ukm_recorder);

  // Log a UserMetricsAction as well so the zoom events appear in breadcrumbs.
  switch (zoom) {
    case ZOOM_OUT:
      base::RecordAction(base::UserMetricsAction("IOS.PageZoom.ZoomOut"));
      break;
    case ZOOM_IN:
      base::RecordAction(base::UserMetricsAction("IOS.PageZoom.ZoomIn"));
      break;
    case ZOOM_RESET:
      base::RecordAction(base::UserMetricsAction("IOS.PageZoom.ZoomReset"));
      break;
  }
}

std::optional<double> FontSizeTabHelper::NewMultiplierAfterZoom(
    Zoom zoom) const {
  static const std::vector<double> kZoomMultipliers = {
      0.5, 2.0 / 3.0, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0,
  };
  switch (zoom) {
    case ZOOM_RESET:
      return 1;
    case ZOOM_IN: {
      double current_multiplier = GetCurrentUserZoomMultiplier();
      // Find first multiplier greater than current.
      for (double multiplier : kZoomMultipliers) {
        if (multiplier > current_multiplier) {
          return multiplier;
        }
      }
      return std::nullopt;
    }
    case ZOOM_OUT: {
      double current_multiplier = GetCurrentUserZoomMultiplier();
      // Find first multiplier less than current.
      for (double multiplier : base::Reversed(kZoomMultipliers)) {
        if (multiplier < current_multiplier) {
          return multiplier;
        }
      }
      return std::nullopt;
    }
  }
}

bool FontSizeTabHelper::CanUserZoomIn() const {
  return NewMultiplierAfterZoom(ZOOM_IN).has_value();
}

bool FontSizeTabHelper::CanUserZoomOut() const {
  return NewMultiplierAfterZoom(ZOOM_OUT).has_value();
}

bool FontSizeTabHelper::CanUserResetZoom() const {
  std::optional<double> new_multiplier = NewMultiplierAfterZoom(ZOOM_RESET);
  return new_multiplier.has_value() &&
         new_multiplier.value() != GetCurrentUserZoomMultiplier();
}

bool FontSizeTabHelper::IsTextZoomUIActive() const {
  return text_zoom_ui_active_;
}

void FontSizeTabHelper::SetTextZoomUIActive(bool active) {
  text_zoom_ui_active_ = active;
}

bool FontSizeTabHelper::CurrentPageSupportsTextZoom() const {
  return web_state_->ContentIsHTML();
}

int FontSizeTabHelper::GetFontSize() const {
  // Only add in the dynamic type multiplier if the flag is enabled.
  double dynamic_type_multiplier =
      base::FeatureList::IsEnabled(web::kWebPageDefaultZoomFromDynamicType)
          ? ui_util::SystemSuggestedFontSizeMultiplier()
          : 1;
  // Multiply by 100 as the web property needs a percentage.
  return dynamic_type_multiplier * GetCurrentUserZoomMultiplier() * 100;
}

void FontSizeTabHelper::OnContentSizeCategoryChanged() {
  SetPageFontSize(GetFontSize());
}

void FontSizeTabHelper::WebStateDestroyed(web::WebState* web_state) {
  web_state->RemoveObserver(this);
  FontSizeJavaScriptFeature* feature = FontSizeJavaScriptFeature::GetInstance();
  feature->GetWebFramesManager(web_state)->RemoveObserver(this);
}

void FontSizeTabHelper::PageLoaded(
    web::WebState* web_state,
    web::PageLoadCompletionStatus load_completion_status) {
  DCHECK_EQ(web_state, web_state_);
  NewPageZoom();
}

void FontSizeTabHelper::DidFinishNavigation(web::WebState* web_state,
                                            web::NavigationContext* context) {
  // When navigating to a Google AMP Cached page, the navigation occurs via
  // Javascript, so handle that separately.
  if (IsGoogleCachedAMPPage()) {
    NewPageZoom();
  }
}

void FontSizeTabHelper::WebStateRealized(web::WebState* web_state) {
  CHECK(!notification_observer_, base::NotFatalUntil::M125);
  CreateNotificationObserver();
}

void FontSizeTabHelper::CreateNotificationObserver() {
  FontSizeJavaScriptFeature* feature = FontSizeJavaScriptFeature::GetInstance();
  feature->GetWebFramesManager(web_state_)->AddObserver(this);

  base::RepeatingCallback<void(NSNotification*)> callback =
      base::IgnoreArgs<NSNotification*>(
          base::BindRepeating(&FontSizeTabHelper::OnContentSizeCategoryChanged,
                              weak_factory_.GetWeakPtr()));

  notification_observer_ = [[NSNotificationCenter defaultCenter]
      addObserverForName:UIContentSizeCategoryDidChangeNotification
                  object:nil
                   queue:nil
              usingBlock:base::CallbackToBlock(callback)];
}

void FontSizeTabHelper::WebFrameBecameAvailable(
    web::WebFramesManager* web_frames_manager,
    web::WebFrame* web_frame) {
  // Make sure that any new web frame starts with the correct zoom level.
  int size = GetFontSize();
  // Prevent any zooming errors by only zooming when necessary. This is mostly
  // when size != 100, but if zooming has happened before, then zooming to 100
  // may be necessary to reset a previous page to the correct zoom level.
  if (tab_helper_has_zoomed_ || size != 100) {
    FontSizeJavaScriptFeature::GetInstance()->AdjustFontSize(web_frame, size);
  }
}

void FontSizeTabHelper::NewPageZoom() {
  int size = GetFontSize();
  // Prevent any zooming errors by only zooming when necessary. This is mostly
  // when size != 100, but if zooming has happened before, then zooming to 100
  // may be necessary to reset a previous page to the correct zoom level.
  if (tab_helper_has_zoomed_ || size != 100) {
    SetPageFontSize(size);
  }
}

PrefService* FontSizeTabHelper::GetPrefService() const {
  ChromeBrowserState* chrome_browser_state =
      ChromeBrowserState::FromBrowserState(web_state_->GetBrowserState());
  return chrome_browser_state->GetPrefs();
}

std::string FontSizeTabHelper::GetCurrentUserZoomMultiplierKey() const {
  UIContentSizeCategory content_size_category =
      base::FeatureList::IsEnabled(web::kWebPageDefaultZoomFromDynamicType)
          ? UIApplication.sharedApplication.preferredContentSizeCategory
          : UIContentSizeCategoryLarge;

  std::string content_size_category_key =
      base::SysNSStringToUTF8(content_size_category);
  return base::StringPrintf("%s.%s", content_size_category_key.c_str(),
                            GetUserZoomMultiplierKeyUrlPart().c_str());
}

std::string FontSizeTabHelper::GetUserZoomMultiplierKeyUrlPart() const {
  if (IsGoogleCachedAMPPage()) {
    return web_state_->GetLastCommittedURL().host().append("/amp");
  }

  return web_state_->GetLastCommittedURL().host();
}

double FontSizeTabHelper::GetCurrentUserZoomMultiplier() const {
  const base::Value::Dict& pref =
      GetPrefService()->GetDict(prefs::kIosUserZoomMultipliers);

  return pref.FindDoubleByDottedPath(GetCurrentUserZoomMultiplierKey())
      .value_or(1);
}

void FontSizeTabHelper::StoreCurrentUserZoomMultiplier(double multiplier) {
  ScopedDictPrefUpdate update(GetPrefService(), prefs::kIosUserZoomMultipliers);

  // Don't bother to store all the ones. This helps keep the pref dict clean.
  if (multiplier == 1) {
    update->RemoveByDottedPath(GetCurrentUserZoomMultiplierKey());
  } else {
    update->SetByDottedPath(GetCurrentUserZoomMultiplierKey(), multiplier);
  }
}

bool FontSizeTabHelper::IsGoogleCachedAMPPage() const {
  // All google AMP pages have URL in the form "https://google_domain/amp/..."
  // This method checks that this is strictly the case.
  const GURL& url = web_state_->GetLastCommittedURL();
  if (!url.is_valid() || !url.SchemeIs(url::kHttpsScheme)) {
    return false;
  }
  if (!google_util::IsGoogleDomainUrl(
          url, google_util::DISALLOW_SUBDOMAIN,
          google_util::DISALLOW_NON_STANDARD_PORTS) ||
      url.path().compare(0, 5, "/amp/") != 0) {
    return false;
  }

  return true;
}

WEB_STATE_USER_DATA_KEY_IMPL(FontSizeTabHelper)