chromium/ios/chrome/browser/variations/model/ios_chrome_variations_seed_fetcher.mm

// Copyright 2022 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/variations/model/ios_chrome_variations_seed_fetcher.h"
#import "ios/chrome/browser/variations/model/ios_chrome_variations_seed_fetcher+testing.h"

#import "base/metrics/histogram_functions.h"
#import "base/notreached.h"
#import "base/strings/string_util.h"
#import "base/strings/sys_string_conversions.h"
#import "base/time/time.h"
#import "build/branding_buildflags.h"
#import "components/variations/seed_response.h"
#import "components/variations/variations_switches.h"
#import "components/variations/variations_url_constants.h"
#import "components/version_info/version_info.h"
#import "ios/chrome/browser/variations/model/constants.h"
#import "ios/chrome/browser/variations/model/ios_chrome_variations_seed_store.h"
#import "ios/chrome/common/channel_info.h"
#import "net/http/http_status_code.h"

#import "ios/chrome/browser/variations/model/ios_chrome_variations_seed_store+fetcher.h"

namespace {

// Maximum time allowed to fetch the seed before the request is cancelled.
const base::TimeDelta kRequestTimeout = base::Seconds(1.5);
// Histogram names for seed fetch time and result.
const char kSeedFetchResultHistogram[] =
    "IOS.Variations.FirstRun.SeedFetchResult";
const char kSeedFetchTimeHistogram[] = "IOS.Variations.FirstRun.SeedFetchTime";

// Whether a current request for variations seed is being made.
//
// This variable exists so that only one instance of the manager updates the
// global seed at one time. It is access in the static serial queue
// "*.first_run_variations_seed_manager" at the start of each task in the queue.
// If the value is NO, it's set to YES and keep executing the task; otherwise,
// it aborts the task to make sure the fetch result won't be overriden.
static BOOL g_seed_fetching_in_progress = NO;

}  // namespace

@implementation IOSChromeVariationsSeedFetcher {
  // Whether the current binary should fetch Finch seed for experiment purpose.
  // Accessed on the main thread.
  BOOL _fetchingEnabled;

  // The variations server domain name. Accessed on the main thread.
  std::string _variationsDomain;

  // The forced channel string retrieved from the command line. Accessed on the
  // main thread.
  std::string _forcedChannel;

  // The timestamp when the current seed request starts. This is used for metric
  // reporting, and will be reset to null value when the request finishes.
  // Accessed in the static serial queue "*.first_run_variations_seed_manager".
  base::Time _startTimeOfOngoingSeedRequest;
}

#pragma mark - Public

- (instancetype)initWithArguments:(NSArray<NSString*>*)arguments {
  self = [super init];
  if (self) {
#if BUILDFLAG(GOOGLE_CHROME_BRANDING)
    _fetchingEnabled = YES;
#else
    _fetchingEnabled = NO;
#endif
    _variationsDomain = variations::kDefaultServerUrl;
    _forcedChannel = std::string();

    std::string url_switch =
        "--" + std::string(variations::switches::kVariationsServerURL) + "=";
    std::string channel_switch =
        "--" + std::string(variations::switches::kFakeVariationsChannel) + "=";
    for (NSString* a in arguments) {
      std::string arg = base::SysNSStringToUTF8(a);

      if (base::StartsWith(arg, url_switch)) {
        _variationsDomain = arg.substr(url_switch.size());
        if (!_fetchingEnabled && !_variationsDomain.empty()) {
          _fetchingEnabled = YES;
        }
      } else if (base::StartsWith(arg, channel_switch)) {
        _forcedChannel = arg.substr(channel_switch.size());
      }
    }
  }
  return self;
}

- (instancetype)init {
  return [self initWithArguments:[[NSProcessInfo processInfo] arguments]];
}

- (void)startSeedFetch {
  if (!_fetchingEnabled) {
    // Stops executing if seed fetching is disabled.
    [self notifyDelegateSeedFetchResult:NO];
    return;
  }
  // Set up a serial queue to to avoid concurrent read/write to static data.
  static dispatch_once_t onceToken;
  static dispatch_queue_t queue = nil;
#if BUILDFLAG(GOOGLE_CHROME_BRANDING)
  const char* label = "com.google.chrome.first_run_variations_seed_manager";
#else
  const char* label = "org.chromium.first_run_variations_seed_manager";
#endif
  dispatch_once(&onceToken, ^{
    queue = dispatch_queue_create(label, DISPATCH_QUEUE_SERIAL);
  });

  // Adds the task of fetching the seed to the static serial queue, and return
  // from `doActualFetch` immediately. Note that the block will retain `self`.
  dispatch_async(queue, ^{
    if (g_seed_fetching_in_progress) {
      NOTREACHED_IN_MIGRATION()
          << "SeedFetch started while already in progress";
      [self notifyDelegateSeedFetchResult:NO];
    } else {
      [self doActualFetch];
    }
  });
}

#pragma mark - Private

// The URL of the variations server, including query parameters that identifies
// the request initiator. Accessed in the static serial queue
// "*.first_run_variations_seed_manager".
- (NSURL*)variationsURL {
  // Setting "osname", "milestone" and "channel" as parameters. Dogfood
  // experimenting is not supported on Chrome iOS, therefore we do not need the
  // "restrict" parameter.
  std::string queryString =
      "?osname=ios&milestone=" + version_info::GetMajorVersionNumber();
  std::string channel = _forcedChannel;
  if (channel.empty() && GetChannel() != version_info::Channel::UNKNOWN) {
    channel = GetChannelString();
  }
  if (!channel.empty()) {
    queryString += "&channel=" + channel;
  }
  return [NSURL
      URLWithString:base::SysUTF8ToNSString(_variationsDomain + queryString)];
}

// Helper method for `startSeedFetch` that initiates an HTTPS request to the
// Finch server in the static serial queue
// "*.first_run_variations_seed_manager".
//
// Note that if this method is invoked, the seed would be fetched regardless of
// `_fetchingEnabled`, so please use with caution.
- (void)doActualFetch {
  g_seed_fetching_in_progress = YES;
  NSMutableURLRequest* request = [NSMutableURLRequest
       requestWithURL:[self variationsURL]
          cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
      timeoutInterval:kRequestTimeout.InSecondsF()];
  // Pass only "gzip" as an accepted format. Do not pass delta compression
  // ("x-bm"), as it is not applicable on first run (since there is no
  // existing seed).
  [request setValue:@"gzip" forHTTPHeaderField:@"A-IM"];
  NSURLSessionDataTask* task = [[NSURLSession sharedSession]
      dataTaskWithRequest:request
        completionHandler:^(NSData* data, NSURLResponse* response,
                            NSError* error) {
          [self seedRequestDidCompleteWithData:data
                                      response:(NSHTTPURLResponse*)response
                                         error:error];
        }];
  _startTimeOfOngoingSeedRequest = base::Time::Now();
  [task resume];
}

// Completion handler of a URL session task. It generates the seed using the
// HTTPS response sent back from the Finch server, stores them in the shared
// seed, and records relevant metrics.
- (void)seedRequestDidCompleteWithData:(NSData*)data
                              response:(NSHTTPURLResponse*)httpResponse
                                 error:(NSError*)error {
  // Normally net::HTTP_NOT_MODIFIED should be considered as a
  // successful response, but it is not expected when the request does
  // not contain "If-None-Match" header.
  BOOL success = error == nil && httpResponse.statusCode == net::HTTP_OK;
  IOSSeedFetchException exception = IOSSeedFetchException::kNotApplicable;
  if (success) {
    DCHECK(!_startTimeOfOngoingSeedRequest.is_null());
    base::UmaHistogramTimes(kSeedFetchTimeHistogram,
                            base::Time::Now() - _startTimeOfOngoingSeedRequest);
    std::unique_ptr<variations::SeedResponse> seed =
        [self seedResponseForHTTPResponse:httpResponse data:data];
    if (seed) {
      [IOSChromeVariationsSeedStore updateSharedSeed:std::move(seed)];
    } else {
      // Currently, only the IM header is mandatory to create a first run seed,
      // and is the only possible reason that a seed is downloaded but not
      // created.
      exception = IOSSeedFetchException::kInvalidIMHeader;
      success = NO;
    }
  } else if (error.code == NSURLErrorTimedOut) {
    exception = IOSSeedFetchException::kHTTPSRequestTimeout;
  } else if (error.code == NSURLErrorBadURL ||
             error.code == NSURLErrorDNSLookupFailed ||
             error.code == NSURLErrorCannotFindHost) {
    exception = IOSSeedFetchException::kHTTPSRequestBadUrl;
  }
  _startTimeOfOngoingSeedRequest = base::Time();
  g_seed_fetching_in_progress = NO;

  // Log seed fetch result on UMA and notify delegate.
  int seedFetchResultValue = exception == IOSSeedFetchException::kNotApplicable
                                 ? static_cast<int>(httpResponse.statusCode)
                                 : static_cast<int>(exception);
  base::UmaHistogramSparse(kSeedFetchResultHistogram, seedFetchResultValue);
  [self notifyDelegateSeedFetchResult:success];
}

// Generates and returns the SeedResponse by parsing the HTTP response returned
// by the variations server. Returns `nil` if the HTTP response is invalid.
// Invoked in the completion handler of a URL session task.
- (std::unique_ptr<variations::SeedResponse>)
    seedResponseForHTTPResponse:(NSHTTPURLResponse*)httpResponse
                           data:(NSData*)data {
  NSString* signature =
      [httpResponse valueForHTTPHeaderField:@"X-Seed-Signature"];
  NSString* country = [httpResponse valueForHTTPHeaderField:@"X-Country"];

  // Returned seed should have been gzip compressed.
  NSCharacterSet* whitespace = [NSCharacterSet whitespaceCharacterSet];
  NSPredicate* nonEmpty = [NSPredicate
      predicateWithBlock:^BOOL(NSString* im, NSDictionary* bindings) {
        return [[im stringByTrimmingCharactersInSet:whitespace] length] > 0;
      }];
  NSArray<NSString*>* instanceManipulations = [[[httpResponse
      valueForHTTPHeaderField:@"IM"] componentsSeparatedByString:@","]
      filteredArrayUsingPredicate:nonEmpty];
  // Only gzip compressed data is supported on first run seed fetching with
  // "gzip" specified in the request.
  if ([instanceManipulations count] == 1 &&
      [[instanceManipulations[0] stringByTrimmingCharactersInSet:whitespace]
          isEqualToString:@"gzip"]) {
    auto seed = std::make_unique<variations::SeedResponse>();
    if (data) {
      // "data" is binary, for which protobuf uses strings.
      seed->data = std::string(reinterpret_cast<const char*>([data bytes]),
                               [data length]);
    }
    seed->signature = base::SysNSStringToUTF8(signature);
    seed->country = base::SysNSStringToUTF8(country);
    seed->date = base::Time::Now();
    seed->is_gzip_compressed = YES;
    return seed;
  }
  return nullptr;
}

// Notifies the delegate of the seed fetching result. Since the seed fetch
// request is sent on the background instead of the main queue, this method
// should explicitly dispatch the result back on the main queue.
- (void)notifyDelegateSeedFetchResult:(BOOL)result {
  __weak IOSChromeVariationsSeedFetcher* weakSelf = self;
  dispatch_async(dispatch_get_main_queue(), ^{
    [weakSelf.delegate variationsSeedFetcherDidCompleteFetchWithSuccess:result];
  });
}

// Invoked by the testing code to reset the fetching status after each test. DO
// NOT INVOKE IN PRODUCTION CODE.
+ (void)resetFetchingStatusForTesting {
  g_seed_fetching_in_progress = NO;
}

@end