| // 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. |
| |
| #include "chrome/browser/web_applications/components/url_handler_prefs.h" |
| |
| #include <algorithm> |
| |
| #include "base/check.h" |
| #include "base/files/file_path.h" |
| #include "base/ranges/algorithm.h" |
| #include "base/ranges/functional.h" |
| #include "base/strings/strcat.h" |
| #include "base/strings/string_piece.h" |
| #include "base/strings/string_util.h" |
| #include "base/util/values/values_util.h" |
| #include "base/values.h" |
| #include "chrome/common/pref_names.h" |
| #include "components/prefs/pref_registry_simple.h" |
| #include "components/prefs/pref_service.h" |
| #include "components/prefs/scoped_user_pref_update.h" |
| #include "third_party/abseil-cpp/absl/types/optional.h" |
| #include "url/gurl.h" |
| #include "url/url_constants.h" |
| |
| namespace web_app { |
| namespace url_handler_prefs { |
| namespace { |
| |
| constexpr const char kAppId[] = "app_id"; |
| constexpr const char kProfilePath[] = "profile_path"; |
| constexpr const char kIncludePaths[] = "include_paths"; |
| constexpr const char kExcludePaths[] = "exclude_paths"; |
| constexpr const char kHasOriginWildcard[] = "has_origin_wildcard"; |
| constexpr const char kDefaultPath[] = "/*"; |
| constexpr const char kPath[] = "path"; |
| constexpr const char kChoice[] = "choice"; |
| constexpr const char kTimestamp[] = "timestamp"; |
| |
| // Returns true if |url| has the same origin as origin_str. If |
| // |look_for_subdomains| is true, url must have an origin that extends |
| // |origin_str| by at least one sub-domain. |
| bool UrlMatchesOrigin(const GURL& url, |
| const std::string& origin_str, |
| const bool look_for_subdomains) { |
| url::Origin origin = url::Origin::Create(GURL(origin_str)); |
| url::Origin url_origin = url::Origin::Create(url); |
| if (origin.scheme() != url_origin.scheme() || |
| origin.port() != url_origin.port()) |
| return false; |
| |
| const std::string& origin_host = origin.host(); |
| const std::string& url_origin_host = url_origin.host(); |
| if (look_for_subdomains) { |
| size_t pos = url_origin_host.find(origin_host); |
| if (pos == std::string::npos || pos == 0) |
| return false; |
| |
| return url_origin_host.substr(pos) == origin_host; |
| } else { |
| return origin_host == url_origin_host; |
| } |
| } |
| |
| // Returns true if |url_path| matches |path_pattern|. A prefix match is used if |
| // |path_pattern| ends with a '*' wildcard character. An exact match is used |
| // otherwise. |url_path| is a URL path from a fully specified URL. |
| // |path_pattern| is a URL path that can contain a wildcard postfix. |
| bool PathMatchesPathPattern(const std::string& url_path, |
| base::StringPiece path_pattern) { |
| if (!path_pattern.empty() && path_pattern.back() == '*') { |
| // Remove the wildcard and check if it's the same as the first several |
| // characters of |url_path|. |
| path_pattern = path_pattern.substr(0, path_pattern.length() - 1); |
| if (base::StartsWith(url_path, path_pattern)) |
| return true; |
| } else { |
| // |path_pattern| doesn't contain a wildcard, check for an exact match. |
| if (path_pattern == url_path) |
| return true; |
| } |
| return false; |
| } |
| |
| // Return true if |url_path| matches any path in |include_paths|. A path in |
| // |include_paths| can contain one wildcard '*' at the end. |
| // If any path matches, returns the best UrlHandlerSavedChoice found and |
| // its associated timestamp through the |choice| and |time| output parameters. |
| // "Best" here is defined by this ordering: kInApp > kNone > kInBrowser. |
| // |url_path| always starts with a '/', as it's the result of GURL::path(). |
| bool FindBestMatchingIncludePathChoice(const std::string& url_path, |
| const base::Value& include_paths, |
| UrlHandlerSavedChoice* choice, |
| base::Time* time) { |
| if (!include_paths.is_list()) |
| return false; |
| |
| UrlHandlerSavedChoice best_choice = UrlHandlerSavedChoice::kInBrowser; |
| base::Time most_recent_timestamp; |
| bool found_match = false; |
| |
| for (const auto& include_path_dict : include_paths.GetList()) { |
| if (!include_path_dict.is_dict()) |
| continue; |
| const std::string* include_path = include_path_dict.FindStringKey(kPath); |
| if (!include_path) |
| continue; |
| const absl::optional<int> choice_opt = |
| include_path_dict.FindIntKey(kChoice); |
| if (!choice_opt) |
| continue; |
| // Check enum. bounds before casting. |
| if (*choice_opt < 0 || |
| *choice_opt > static_cast<int>(UrlHandlerSavedChoice::kMax)) |
| continue; |
| auto current_choice = static_cast<UrlHandlerSavedChoice>(*choice_opt); |
| absl::optional<base::Time> current_timestamp = |
| util::ValueToTime(include_path_dict.FindKey(kTimestamp)); |
| if (!current_timestamp) |
| continue; |
| |
| if (PathMatchesPathPattern(url_path, *include_path)) { |
| // If current_choice is better than best_choice, update best choice and |
| // timestamp. |
| bool update_best = current_choice > best_choice || |
| // If current_choice and best_choice are equal, choose |
| // the one with the latest timestamp. |
| (best_choice == current_choice && |
| current_timestamp > most_recent_timestamp); |
| |
| if (update_best) { |
| best_choice = current_choice; |
| most_recent_timestamp = *current_timestamp; |
| } |
| found_match = true; |
| } |
| } |
| |
| if (found_match) { |
| *choice = best_choice; |
| *time = most_recent_timestamp; |
| } |
| return found_match; |
| } |
| |
| // Return true if |url_path| matches any path in |exclude_paths|. A path in |
| // |exclude_paths| can contain one wildcard '*' at the end. |
| bool ExcludePathMatches(const std::string& url_path, |
| const base::Value& exclude_paths) { |
| if (!exclude_paths.is_list()) |
| return false; |
| |
| for (const auto& exclude_path : exclude_paths.GetList()) { |
| if (!exclude_path.is_string()) |
| continue; |
| if (PathMatchesPathPattern(url_path, exclude_path.GetString())) |
| return true; |
| } |
| return false; |
| } |
| |
| // Given a list of handlers that matched an origin, apply the rules in each |
| // handler against |url| and return only handlers that match |url| by appending |
| // to |matches|. |
| // |origin_trimmed| indicates if the input URL's origin had to be shortened to |
| // find a matching key. If true, filter out and matches that did not allow an |
| // origin prefix wildcard in their manifest. |
| void FilterAndAddMatches(const base::Value& all_handlers, |
| const GURL& url, |
| bool origin_trimmed, |
| std::vector<UrlHandlerLaunchParams>& matches) { |
| if (!all_handlers.is_list()) |
| return; |
| |
| for (auto& handler : all_handlers.GetList()) { |
| if (!handler.is_dict()) |
| continue; |
| |
| const std::string* const app_id = handler.FindStringKey(kAppId); |
| if (!app_id || app_id->empty()) |
| continue; |
| |
| absl::optional<base::FilePath> profile_path = |
| util::ValueToFilePath(handler.FindKey(kProfilePath)); |
| if (!profile_path || profile_path->empty()) |
| continue; |
| |
| if (origin_trimmed) { |
| absl::optional<bool> has_wildcard = |
| handler.FindBoolKey(kHasOriginWildcard); |
| if (!has_wildcard || !*has_wildcard) |
| continue; |
| } |
| |
| const std::string& url_path = url.path(); |
| const base::Value* const include_paths = handler.FindListKey(kIncludePaths); |
| |
| bool include_paths_exist = include_paths && include_paths->is_list() && |
| !include_paths->GetList().empty(); |
| |
| UrlHandlerSavedChoice best_choice = UrlHandlerSavedChoice::kNone; |
| base::Time latest_timestamp = base::Time::Min(); |
| if (include_paths_exist && |
| !FindBestMatchingIncludePathChoice(url_path, *include_paths, |
| &best_choice, &latest_timestamp)) { |
| continue; |
| } |
| |
| const base::Value* const exclude_paths = handler.FindListKey(kExcludePaths); |
| bool exclude_paths_exist = exclude_paths && exclude_paths->is_list() && |
| !exclude_paths->GetList().empty(); |
| if (exclude_paths_exist && ExcludePathMatches(url_path, *exclude_paths)) |
| continue; |
| |
| matches.emplace_back(*profile_path, *app_id, url, best_choice, |
| latest_timestamp); |
| } |
| } |
| |
| // Find the most recent match. If it is saved as kInBrowser, preferred choice |
| // is the browser so no matches should be returned; If saved as kNone, all the |
| // matches should be returned so the user can make a new saved choice; If |
| // kInApp, only returned the app match as it is the saved choice. |
| void FilterBySavedChoice(std::vector<UrlHandlerLaunchParams>& matches) { |
| if (matches.empty()) |
| return; |
| |
| // Record the most recent match. |
| auto most_recent_match_iterator = base::ranges::max_element( |
| matches, base::ranges::less(), |
| &UrlHandlerLaunchParams::saved_choice_timestamp); |
| |
| switch (most_recent_match_iterator->saved_choice) { |
| case UrlHandlerSavedChoice::kInApp: |
| matches = {std::move(*most_recent_match_iterator)}; |
| break; |
| case UrlHandlerSavedChoice::kInBrowser: |
| matches = {}; |
| break; |
| case UrlHandlerSavedChoice::kNone: |
| // `matches` already contain all matches. Do not modify. |
| break; |
| } |
| } |
| |
| void FindMatchesImpl(const base::Value& pref_value, |
| const GURL& url, |
| std::vector<UrlHandlerLaunchParams>& matches, |
| const std::string& origin_str, |
| const bool origin_trimmed) { |
| const base::Value* const all_handlers = pref_value.FindListKey(origin_str); |
| if (all_handlers) { |
| DCHECK(UrlMatchesOrigin(url, origin_str, origin_trimmed)); |
| FilterAndAddMatches(*all_handlers, url, origin_trimmed, matches); |
| FilterBySavedChoice(matches); |
| } |
| } |
| |
| // Helper function that runs |op| repeatedly with shorter versions of |
| // |origin_str|. This helps match URLs to entries keyed by broader origins. |
| template <typename Operation> |
| void TryDifferentOriginSubstrings(std::string origin_str, Operation op) { |
| bool origin_trimmed = false; |
| while (true) { |
| op(origin_str, origin_trimmed); |
| |
| // Try to shorten origin_str to the next origin suffix by removing 1 |
| // sub-domain. This enables matching against origins that contain wildcard |
| // prefixes. As these origins with wildcard prefixes could be of different |
| // lengths and yet match the initial origin_str, every suffix is processed. |
| auto found = origin_str.find('.'); |
| if (found != std::string::npos) { |
| // Trim origin to after next '.' character if there is one. |
| origin_str = base::StrCat({"https://", origin_str.substr(found + 1)}); |
| origin_trimmed = true; |
| // Do not early return here. There could be other apps that match using |
| // origin wildcard. |
| } else { |
| // There is no more '.'. Stop looking. |
| break; |
| } |
| } |
| } |
| |
| // Returns the URL handlers stored in |pref_value| that match |url|'s origin. |
| std::vector<UrlHandlerLaunchParams> FindMatches(const base::Value& pref_value, |
| const GURL& url) { |
| std::vector<UrlHandlerLaunchParams> matches; |
| |
| if (!pref_value.is_dict()) |
| return matches; |
| |
| url::Origin origin = url::Origin::Create(url); |
| if (origin.opaque()) |
| return matches; |
| |
| if (origin.scheme() != url::kHttpsScheme) |
| return matches; |
| |
| // FindMatchesImpl accumulates results to |matches|. |
| std::string origin_str = origin.Serialize(); |
| TryDifferentOriginSubstrings( |
| origin_str, [&pref_value, &url, &matches](const std::string& origin_str, |
| bool origin_trimmed) { |
| FindMatchesImpl(pref_value, url, matches, origin_str, origin_trimmed); |
| }); |
| return matches; |
| } |
| |
| base::Value GetIncludePathsValue(const std::vector<std::string>& include_paths, |
| const base::Time& time) { |
| base::Value value(base::Value::Type::LIST); |
| // When no "paths" are specified in web-app-origin-association, all include |
| // paths are allowed. |
| for (const auto& include_path : include_paths.empty() |
| ? std::vector<std::string>({kDefaultPath}) |
| : include_paths) { |
| base::Value path_dict(base::Value::Type::DICTIONARY); |
| path_dict.SetStringKey(kPath, include_path); |
| path_dict.SetIntKey(kChoice, |
| static_cast<int>(UrlHandlerSavedChoice::kNone)); |
| path_dict.SetKey(kTimestamp, util::TimeToValue(time)); |
| value.Append(std::move(path_dict)); |
| } |
| return value; |
| } |
| |
| base::Value GetExcludePathsValue( |
| const std::vector<std::string>& exclude_paths) { |
| base::Value value(base::Value::Type::LIST); |
| for (const auto& exclude_path : exclude_paths) { |
| value.Append(exclude_path); |
| } |
| return value; |
| } |
| |
| base::Value NewHandler(const AppId& app_id, |
| const base::FilePath& profile_path, |
| const apps::UrlHandlerInfo& info, |
| const base::Time& time) { |
| base::Value value(base::Value::Type::DICTIONARY); |
| value.SetStringKey(kAppId, app_id); |
| value.SetKey(kProfilePath, util::FilePathToValue(profile_path)); |
| value.SetBoolKey(kHasOriginWildcard, info.has_origin_wildcard); |
| // Set include_paths and exclude paths from associated app. |
| value.SetKey(kIncludePaths, GetIncludePathsValue(info.paths, time)); |
| value.SetKey(kExcludePaths, GetExcludePathsValue(info.exclude_paths)); |
| return value; |
| } |
| |
| // If |match_app_id| is true, returns true if |handler| has dict. values equal |
| // to |app_id| and |profile_path|. If |match_app_id| is false, only compare |
| // |profile_path|. |
| bool IsHandlerForApp(const AppId& app_id, |
| const base::FilePath& profile_path, |
| bool match_app_id, |
| const base::Value& handler) { |
| const std::string* const handler_app_id = handler.FindStringKey(kAppId); |
| absl::optional<base::FilePath> handler_profile_path = |
| util::ValueToFilePath(handler.FindKey(kProfilePath)); |
| |
| if (!handler_app_id || !handler_profile_path) |
| return false; |
| |
| if (*handler_profile_path != profile_path) |
| return false; |
| |
| return !match_app_id || *handler_app_id == app_id; |
| } |
| |
| // Removes entries that match |profile_path| and |app_id|. |
| // |profile_path| is always compared while |app_id| is only compared when it is |
| // not empty. |
| void RemoveEntries(base::Value& pref_value, |
| const AppId& app_id, |
| const base::FilePath& profile_path) { |
| if (!pref_value.is_dict()) |
| return; |
| |
| std::vector<std::string> origins_to_remove; |
| for (auto origin_value : pref_value.DictItems()) { |
| base::Value::ListStorage handlers = origin_value.second.TakeList(); |
| handlers.erase( |
| std::remove_if(handlers.begin(), handlers.end(), |
| [&app_id, &profile_path](const base::Value& handler) { |
| return IsHandlerForApp( |
| app_id, profile_path, |
| /*match_app_id=*/!app_id.empty(), handler); |
| }), |
| handlers.end()); |
| // Replace list if any entries remain. |
| if (!handlers.empty()) { |
| origin_value.second = base::Value(std::move(handlers)); |
| } else { |
| origins_to_remove.push_back(origin_value.first); |
| } |
| } |
| |
| for (const auto& origin_to_remove : origins_to_remove) |
| pref_value.RemoveKey(origin_to_remove); |
| } |
| |
| // Sets |choice| on every path in |include_paths| that matches |url|. |
| void UpdateSavedChoice(base::Value& include_paths, |
| const GURL& url, |
| UrlHandlerSavedChoice choice, |
| const base::Time& time) { |
| // |include_paths| is a list of include path dicts. Eg: |
| // [ { |
| // "choice": 0, |
| // "path": "/abc", |
| // "timestamp": "-9223372036854775808" |
| // } ] |
| auto include_paths_list = include_paths.TakeList(); |
| for (base::Value& include_path_dict : include_paths_list) { |
| if (!include_path_dict.is_dict()) |
| continue; |
| const std::string* path = include_path_dict.FindStringKey(kPath); |
| if (!path) |
| continue; |
| |
| // Any matching path dict. will be updated with the input choice and |
| // timestamp. |
| if (PathMatchesPathPattern(url.path(), *path)) { |
| include_path_dict.SetIntKey(kChoice, static_cast<int>(choice)); |
| include_path_dict.SetKey(kTimestamp, util::TimeToValue(time)); |
| } |
| } |
| include_paths = base::Value(std::move(include_paths_list)); |
| } |
| |
| void SaveChoiceImpl(const AppId* app_id, |
| const base::FilePath* profile_path, |
| const GURL& url, |
| const UrlHandlerSavedChoice choice, |
| const base::Time& time, |
| base::Value& pref_value, |
| const std::string& origin_str, |
| const bool origin_trimmed) { |
| base::Value* const handlers_mutable = pref_value.FindListKey(origin_str); |
| if (handlers_mutable) { |
| DCHECK(UrlMatchesOrigin(url, origin_str, origin_trimmed)); |
| base::Value::ListStorage handlers = handlers_mutable->TakeList(); |
| for (auto& handler : handlers) { |
| if (!handler.is_dict()) |
| continue; |
| const std::string* const handler_app_id = handler.FindStringKey(kAppId); |
| absl::optional<base::FilePath> handler_profile_path = |
| util::ValueToFilePath(handler.FindKey(kProfilePath)); |
| if (!handler_app_id || !handler_profile_path) |
| continue; |
| |
| if (choice == UrlHandlerSavedChoice::kInApp) { |
| if (*handler_app_id != *app_id || |
| *handler_profile_path != *profile_path) { |
| continue; |
| } |
| } |
| |
| base::Value* const include_paths = handler.FindListKey(kIncludePaths); |
| if (include_paths) |
| UpdateSavedChoice(*include_paths, url, choice, time); |
| } |
| *handlers_mutable = base::Value(std::move(handlers)); |
| } |
| } |
| |
| // Saves |choice| and |time| to all handler include_paths that match |app_id|, |
| // |profile_path|, and |url|. |url| provides both origin and path for matching. |
| void SaveChoice(PrefService* local_state, |
| const AppId* app_id, |
| const base::FilePath* profile_path, |
| const GURL& url, |
| const UrlHandlerSavedChoice choice, |
| const base::Time& time) { |
| DCHECK(url.is_valid()); |
| DCHECK(local_state); |
| DCHECK(choice != UrlHandlerSavedChoice::kNone); |
| // |app_id| and |profile_path| are not needed when choice == kInBrowser. |
| DCHECK(choice != UrlHandlerSavedChoice::kInBrowser || |
| (app_id == nullptr && profile_path == nullptr)); |
| |
| DictionaryPrefUpdate update(local_state, prefs::kWebAppsUrlHandlerInfo); |
| base::Value* const pref_value = update.Get(); |
| if (!pref_value || !pref_value->is_dict()) |
| return; |
| |
| url::Origin origin = url::Origin::Create(url); |
| if (origin.opaque()) |
| return; |
| |
| if (origin.scheme() != url::kHttpsScheme) |
| return; |
| |
| std::string origin_str = origin.Serialize(); |
| |
| // SaveChoiceImpl modifies prefs but produces no output. |
| TryDifferentOriginSubstrings( |
| origin_str, [app_id, profile_path, &url, choice, &time, pref_value]( |
| const std::string& origin_str, bool origin_trimmed) { |
| SaveChoiceImpl(app_id, profile_path, url, choice, time, *pref_value, |
| origin_str, origin_trimmed); |
| }); |
| } |
| |
| } // namespace |
| |
| void RegisterLocalStatePrefs(PrefRegistrySimple* registry) { |
| DCHECK(registry); |
| registry->RegisterDictionaryPref(prefs::kWebAppsUrlHandlerInfo); |
| } |
| |
| void AddWebApp(PrefService* local_state, |
| const AppId& app_id, |
| const base::FilePath& profile_path, |
| const apps::UrlHandlers& url_handlers, |
| const base::Time& time) { |
| if (profile_path.empty() || url_handlers.empty()) |
| return; |
| |
| DictionaryPrefUpdate update(local_state, prefs::kWebAppsUrlHandlerInfo); |
| base::Value* const pref_value = update.Get(); |
| if (!pref_value || !pref_value->is_dict()) |
| return; |
| |
| for (const apps::UrlHandlerInfo& handler_info : url_handlers) { |
| const url::Origin& origin = handler_info.origin; |
| if (origin.opaque()) |
| continue; |
| |
| base::Value new_handler( |
| NewHandler(app_id, profile_path, handler_info, time)); |
| base::Value* const handlers_mutable = |
| pref_value->FindListKey(origin.Serialize()); |
| // One or more apps are already associated with this origin. |
| if (handlers_mutable) { |
| base::Value::ListStorage handlers = handlers_mutable->TakeList(); |
| auto it = |
| std::find_if(handlers.begin(), handlers.end(), |
| [&app_id, &profile_path](const base::Value& handler) { |
| return IsHandlerForApp(app_id, profile_path, |
| /*match_app_id=*/true, handler); |
| }); |
| // If there is already an entry with the same app_id and profile, replace |
| // it. Otherwise, add new entry to the end. |
| if (it != handlers.end()) { |
| *it = std::move(new_handler); |
| } else { |
| handlers.push_back(std::move(new_handler)); |
| } |
| *handlers_mutable = base::Value(std::move(handlers)); |
| } else { |
| base::Value new_handlers(base::Value::Type::LIST); |
| new_handlers.Append(std::move(new_handler)); |
| pref_value->SetKey(origin.Serialize(), std::move(new_handlers)); |
| } |
| } |
| } |
| |
| void UpdateWebApp(PrefService* local_state, |
| const AppId& app_id, |
| const base::FilePath& profile_path, |
| const apps::UrlHandlers& url_handlers) { |
| // TODO(crbug/1072058): Retain saved choices where possible if there are |
| // updates to 'url_handlers'. |
| RemoveWebApp(local_state, app_id, profile_path); |
| AddWebApp(local_state, app_id, profile_path, url_handlers); |
| } |
| |
| void RemoveWebApp(PrefService* local_state, |
| const AppId& app_id, |
| const base::FilePath& profile_path) { |
| if (app_id.empty() || profile_path.empty()) |
| return; |
| |
| DictionaryPrefUpdate update(local_state, prefs::kWebAppsUrlHandlerInfo); |
| base::Value* const pref_value = update.Get(); |
| if (!pref_value || !pref_value->is_dict()) |
| return; |
| |
| RemoveEntries(*pref_value, app_id, profile_path); |
| } |
| |
| void RemoveProfile(PrefService* local_state, |
| const base::FilePath& profile_path) { |
| if (profile_path.empty()) |
| return; |
| |
| DictionaryPrefUpdate update(local_state, prefs::kWebAppsUrlHandlerInfo); |
| base::Value* const pref_value = update.Get(); |
| if (!pref_value || !pref_value->is_dict()) |
| return; |
| |
| RemoveEntries(*pref_value, /*app_id*/ "", profile_path); |
| } |
| |
| void Clear(PrefService* local_state) { |
| DictionaryPrefUpdate update(local_state, prefs::kWebAppsUrlHandlerInfo); |
| base::Value* const pref_value = update.Get(); |
| pref_value->DictClear(); |
| } |
| |
| std::vector<UrlHandlerLaunchParams> FindMatchingUrlHandlers( |
| PrefService* local_state, |
| const GURL& url) { |
| if (!url.is_valid()) |
| return {}; |
| |
| const base::Value* const pref_value = |
| local_state->Get(prefs::kWebAppsUrlHandlerInfo); |
| if (!pref_value || !pref_value->is_dict()) |
| return {}; |
| |
| return FindMatches(*pref_value, url); |
| } |
| |
| void SaveOpenInApp(PrefService* local_state, |
| const AppId& app_id, |
| const base::FilePath& profile_path, |
| const GURL& url, |
| const base::Time& time) { |
| DCHECK(!profile_path.empty()); |
| DCHECK(!app_id.empty()); |
| SaveChoice(local_state, &app_id, &profile_path, url, |
| UrlHandlerSavedChoice::kInApp, time); |
| } |
| |
| void SaveOpenInBrowser(PrefService* local_state, |
| const GURL& url, |
| const base::Time& time) { |
| SaveChoice(local_state, /*app_id=*/nullptr, /*profile_path=*/nullptr, url, |
| UrlHandlerSavedChoice::kInBrowser, time); |
| } |
| |
| } // namespace url_handler_prefs |
| } // namespace web_app |