| // Copyright 2016 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/installable/installable_manager.h" |
| |
| #include "base/bind.h" |
| #include "base/stl_util.h" |
| #include "base/strings/string_util.h" |
| #include "chrome/browser/manifest/manifest_icon_downloader.h" |
| #include "chrome/browser/manifest/manifest_icon_selector.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/ssl/security_state_tab_helper.h" |
| #include "components/security_state/core/security_state.h" |
| #include "content/public/browser/browser_context.h" |
| #include "content/public/browser/browser_thread.h" |
| #include "content/public/browser/navigation_handle.h" |
| #include "content/public/browser/service_worker_context.h" |
| #include "content/public/browser/storage_partition.h" |
| #include "net/base/url_util.h" |
| #include "third_party/WebKit/public/platform/WebDisplayMode.h" |
| |
| using IconPurpose = content::Manifest::Icon::IconPurpose; |
| |
| namespace { |
| |
| const char kPngExtension[] = ".png"; |
| |
| // This constant is the icon size on Android (48dp) multiplied by the scale |
| // factor of a Nexus 5 device (3x). For mobile and desktop platforms, a 144px |
| // icon is an approximate, appropriate lower bound. It is the currently |
| // advertised minimum icon size for triggering banners. |
| // TODO(dominickn): consolidate with minimum_icon_size_in_px across platforms. |
| const int kIconMinimumSizeInPx = 144; |
| |
| // Returns true if |manifest| specifies a PNG icon >= 144x144px (or size "any"), |
| // and of icon purpose IconPurpose::ANY. |
| bool DoesManifestContainRequiredIcon(const content::Manifest& manifest) { |
| for (const auto& icon : manifest.icons) { |
| // The type field is optional. If it isn't present, fall back on checking |
| // the src extension, and allow the icon if the extension ends with png. |
| if (!base::EqualsASCII(icon.type, "image/png") && |
| !(icon.type.empty() && base::EndsWith( |
| icon.src.ExtractFileName(), kPngExtension, |
| base::CompareCase::INSENSITIVE_ASCII))) |
| continue; |
| |
| if (!base::ContainsValue(icon.purpose, IconPurpose::ANY)) |
| continue; |
| |
| for (const auto& size : icon.sizes) { |
| if (size.IsEmpty()) // "any" |
| return true; |
| if (size.width() >= kIconMinimumSizeInPx && |
| size.height() >= kIconMinimumSizeInPx) { |
| return true; |
| } |
| } |
| } |
| |
| return false; |
| } |
| |
| // Returns true if |params| specifies a full PWA check. |
| bool IsParamsForPwaCheck(const InstallableParams& params) { |
| return params.check_installable && params.fetch_valid_primary_icon; |
| } |
| |
| } // namespace |
| |
| DEFINE_WEB_CONTENTS_USER_DATA_KEY(InstallableManager); |
| |
| struct InstallableManager::ManifestProperty { |
| InstallableStatusCode error = NO_ERROR_DETECTED; |
| GURL url; |
| content::Manifest manifest; |
| bool fetched = false; |
| }; |
| |
| struct InstallableManager::InstallableProperty { |
| InstallableStatusCode error = NO_ERROR_DETECTED; |
| bool installable = false; |
| bool fetched = false; |
| }; |
| |
| struct InstallableManager::IconProperty { |
| IconProperty() : |
| error(NO_ERROR_DETECTED), url(), icon(), fetched(false) { } |
| IconProperty(IconProperty&& other) = default; |
| IconProperty& operator=(IconProperty&& other) = default; |
| |
| InstallableStatusCode error = NO_ERROR_DETECTED; |
| GURL url; |
| std::unique_ptr<SkBitmap> icon; |
| bool fetched; |
| |
| private: |
| // This class contains a std::unique_ptr and therefore must be move-only. |
| DISALLOW_COPY_AND_ASSIGN(IconProperty); |
| }; |
| |
| InstallableManager::InstallableManager(content::WebContents* web_contents) |
| : content::WebContentsObserver(web_contents), |
| manifest_(new ManifestProperty()), |
| installable_(new InstallableProperty()), |
| page_status_(InstallabilityCheckStatus::NOT_STARTED), |
| menu_open_count_(0), |
| menu_item_add_to_homescreen_count_(0), |
| is_active_(false), |
| is_pwa_check_complete_(false), |
| weak_factory_(this) {} |
| |
| InstallableManager::~InstallableManager() = default; |
| |
| // static |
| bool InstallableManager::IsContentSecure(content::WebContents* web_contents) { |
| if (!web_contents) |
| return false; |
| |
| // Whitelist localhost. Check the VisibleURL to match what the |
| // SecurityStateTabHelper looks at. |
| if (net::IsLocalhost(web_contents->GetVisibleURL().HostNoBrackets())) |
| return true; |
| |
| security_state::SecurityInfo security_info; |
| SecurityStateTabHelper::FromWebContents(web_contents) |
| ->GetSecurityInfo(&security_info); |
| return security_info.security_level == security_state::SECURE || |
| security_info.security_level == security_state::EV_SECURE; |
| } |
| |
| // static |
| int InstallableManager::GetMinimumIconSizeInPx() { |
| return kIconMinimumSizeInPx; |
| } |
| |
| void InstallableManager::GetData(const InstallableParams& params, |
| const InstallableCallback& callback) { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| |
| // Return immediately if we're already working on a task. The new task will be |
| // looked at once the current task is finished. |
| tasks_.push_back({params, callback}); |
| if (is_active_) |
| return; |
| |
| is_active_ = true; |
| StartNextTask(); |
| } |
| |
| void InstallableManager::RecordMenuOpenHistogram() { |
| if (is_pwa_check_complete_) |
| InstallableMetrics::RecordMenuOpenHistogram(page_status_); |
| else |
| ++menu_open_count_; |
| } |
| |
| void InstallableManager::RecordMenuItemAddToHomescreenHistogram() { |
| if (is_pwa_check_complete_) |
| InstallableMetrics::RecordMenuItemAddToHomescreenHistogram(page_status_); |
| else |
| ++menu_item_add_to_homescreen_count_; |
| } |
| |
| void InstallableManager::RecordQueuedMetricsOnTaskCompletion( |
| const InstallableParams& params, |
| bool check_passed) { |
| // Don't do anything if we've already finished the PWA check or if this check |
| // was not for the full PWA params. Once a full check is completed, metrics |
| // will be directly recorded in Record*Histogram since |
| // |is_pwa_check_complete_| will be true. |
| if (is_pwa_check_complete_ || !IsParamsForPwaCheck(params)) |
| return; |
| |
| is_pwa_check_complete_ = true; |
| page_status_ = |
| check_passed |
| ? InstallabilityCheckStatus::COMPLETE_PROGRESSIVE_WEB_APP |
| : InstallabilityCheckStatus::COMPLETE_NON_PROGRESSIVE_WEB_APP; |
| |
| // Compute what the status would have been for any queued calls to |
| // Record*Histogram, and record appropriately. |
| InstallabilityCheckStatus prev_status = |
| check_passed |
| ? InstallabilityCheckStatus::IN_PROGRESS_PROGRESSIVE_WEB_APP |
| : InstallabilityCheckStatus::IN_PROGRESS_NON_PROGRESSIVE_WEB_APP; |
| for (; menu_open_count_ > 0; --menu_open_count_) |
| InstallableMetrics::RecordMenuOpenHistogram(prev_status); |
| |
| for (; menu_item_add_to_homescreen_count_ > 0; |
| --menu_item_add_to_homescreen_count_) { |
| InstallableMetrics::RecordMenuItemAddToHomescreenHistogram(prev_status); |
| } |
| } |
| |
| InstallableManager::IconParams InstallableManager::ParamsForPrimaryIcon( |
| const InstallableParams& params) const { |
| return std::make_tuple(params.ideal_primary_icon_size_in_px, |
| params.minimum_primary_icon_size_in_px, |
| IconPurpose::ANY); |
| } |
| |
| InstallableManager::IconParams InstallableManager::ParamsForBadgeIcon( |
| const InstallableParams& params) const { |
| return std::make_tuple(params.ideal_badge_icon_size_in_px, |
| params.minimum_badge_icon_size_in_px, |
| IconPurpose::BADGE); |
| } |
| |
| bool InstallableManager::IsIconFetched(const IconParams& params) const { |
| const auto it = icons_.find(params); |
| return it != icons_.end() && it->second.fetched; |
| } |
| |
| void InstallableManager::SetIconFetched(const IconParams& params) { |
| icons_[params].fetched = true; |
| } |
| |
| InstallableStatusCode InstallableManager::GetErrorCode( |
| const InstallableParams& params) { |
| if (manifest_->error != NO_ERROR_DETECTED) |
| return manifest_->error; |
| |
| if (params.check_installable && installable_->error != NO_ERROR_DETECTED) |
| return installable_->error; |
| |
| if (params.fetch_valid_primary_icon) { |
| IconProperty& icon = icons_[ParamsForPrimaryIcon(params)]; |
| if (icon.error != NO_ERROR_DETECTED) |
| return icon.error; |
| } |
| |
| if (params.fetch_valid_badge_icon) { |
| IconProperty& icon = icons_[ParamsForBadgeIcon(params)]; |
| |
| // If the error is NO_ACCEPTABLE_ICON, there is no icon suitable as a badge |
| // in the manifest. Ignore this case since we only want to fail the check if |
| // there was a suitable badge icon specified and we couldn't fetch it. |
| if (icon.error != NO_ERROR_DETECTED && icon.error != NO_ACCEPTABLE_ICON) |
| return icon.error; |
| } |
| |
| return NO_ERROR_DETECTED; |
| } |
| |
| InstallableStatusCode InstallableManager::manifest_error() const { |
| return manifest_->error; |
| } |
| |
| InstallableStatusCode InstallableManager::installable_error() const { |
| return installable_->error; |
| } |
| |
| void InstallableManager::set_installable_error( |
| InstallableStatusCode error_code) { |
| installable_->error = error_code; |
| } |
| |
| InstallableStatusCode InstallableManager::icon_error( |
| const IconParams& icon_params) { |
| return icons_[icon_params].error; |
| } |
| |
| GURL& InstallableManager::icon_url(const IconParams& icon_params) { |
| return icons_[icon_params].url; |
| } |
| |
| const SkBitmap* InstallableManager::icon(const IconParams& icon_params) { |
| return icons_[icon_params].icon.get(); |
| } |
| |
| content::WebContents* InstallableManager::GetWebContents() { |
| content::WebContents* contents = web_contents(); |
| if (!contents || contents->IsBeingDestroyed()) |
| return nullptr; |
| return contents; |
| } |
| |
| bool InstallableManager::IsComplete(const InstallableParams& params) const { |
| // Returns true if for all resources: |
| // a. the params did not request it, OR |
| // b. the resource has been fetched/checked. |
| return manifest_->fetched && |
| (!params.check_installable || installable_->fetched) && |
| (!params.fetch_valid_primary_icon || |
| IsIconFetched(ParamsForPrimaryIcon(params))) && |
| (!params.fetch_valid_badge_icon || |
| IsIconFetched(ParamsForBadgeIcon(params))); |
| } |
| |
| void InstallableManager::Reset() { |
| // Prevent any outstanding callbacks to or from this object from being called. |
| weak_factory_.InvalidateWeakPtrs(); |
| tasks_.clear(); |
| icons_.clear(); |
| |
| // We may have reset prior to completion, in which case |menu_open_count_| or |
| // |menu_item_add_to_homescreen_count_| will be nonzero. If we completed, then |
| // these values cannot be anything except 0. |
| page_status_ = InstallabilityCheckStatus::NOT_STARTED; |
| is_pwa_check_complete_ = false; |
| |
| for (; menu_open_count_ > 0; --menu_open_count_) { |
| InstallableMetrics::RecordMenuOpenHistogram( |
| InstallabilityCheckStatus::STOPPED_BEFORE_COMPLETION); |
| } |
| |
| for (; menu_item_add_to_homescreen_count_ > 0; |
| --menu_item_add_to_homescreen_count_) { |
| InstallableMetrics::RecordMenuItemAddToHomescreenHistogram( |
| InstallabilityCheckStatus::STOPPED_BEFORE_COMPLETION); |
| } |
| |
| manifest_.reset(new ManifestProperty()); |
| installable_.reset(new InstallableProperty()); |
| |
| is_active_ = false; |
| } |
| |
| void InstallableManager::SetManifestDependentTasksComplete() { |
| DCHECK(!tasks_.empty()); |
| const InstallableParams& params = tasks_[0].first; |
| |
| installable_->fetched = true; |
| SetIconFetched(ParamsForPrimaryIcon(params)); |
| SetIconFetched(ParamsForBadgeIcon(params)); |
| } |
| |
| void InstallableManager::RunCallback(const Task& task, |
| InstallableStatusCode code) { |
| const InstallableParams& params = task.first; |
| IconProperty null_icon; |
| IconProperty* primary_icon = &null_icon; |
| IconProperty* badge_icon = &null_icon; |
| |
| if (params.fetch_valid_primary_icon && |
| base::ContainsKey(icons_, ParamsForPrimaryIcon(params))) |
| primary_icon = &icons_[ParamsForPrimaryIcon(params)]; |
| if (params.fetch_valid_badge_icon && |
| base::ContainsKey(icons_, ParamsForBadgeIcon(params))) |
| badge_icon = &icons_[ParamsForBadgeIcon(params)]; |
| |
| InstallableData data = { |
| code, |
| manifest_url(), |
| manifest(), |
| primary_icon->url, |
| primary_icon->icon.get(), |
| badge_icon->url, |
| badge_icon->icon.get(), |
| params.check_installable ? is_installable() : false}; |
| |
| task.second.Run(data); |
| } |
| |
| void InstallableManager::StartNextTask() { |
| // If there's nothing to do, exit. Resources remain cached so any future calls |
| // won't re-fetch anything that has already been retrieved. |
| if (tasks_.empty()) { |
| is_active_ = false; |
| return; |
| } |
| |
| DCHECK(is_active_); |
| WorkOnTask(); |
| } |
| |
| void InstallableManager::WorkOnTask() { |
| DCHECK(!tasks_.empty()); |
| const Task& task = tasks_[0]; |
| const InstallableParams& params = task.first; |
| |
| InstallableStatusCode code = GetErrorCode(params); |
| bool check_passed = (code == NO_ERROR_DETECTED); |
| if (!check_passed || IsComplete(params)) { |
| RecordQueuedMetricsOnTaskCompletion(params, check_passed); |
| RunCallback(task, code); |
| tasks_.erase(tasks_.begin()); |
| StartNextTask(); |
| return; |
| } |
| |
| if (!manifest_->fetched) { |
| FetchManifest(); |
| } else if (params.fetch_valid_primary_icon && |
| !IsIconFetched(ParamsForPrimaryIcon(params))) { |
| CheckAndFetchBestIcon(ParamsForPrimaryIcon(params)); |
| } else if (params.check_installable && !installable_->fetched) { |
| CheckInstallable(); |
| } else if (params.fetch_valid_badge_icon && |
| !IsIconFetched(ParamsForBadgeIcon(params))) { |
| CheckAndFetchBestIcon(ParamsForBadgeIcon(params)); |
| } else { |
| NOTREACHED(); |
| } |
| } |
| |
| void InstallableManager::FetchManifest() { |
| DCHECK(!manifest_->fetched); |
| |
| content::WebContents* web_contents = GetWebContents(); |
| DCHECK(web_contents); |
| |
| web_contents->GetManifest(base::Bind(&InstallableManager::OnDidGetManifest, |
| weak_factory_.GetWeakPtr())); |
| } |
| |
| void InstallableManager::OnDidGetManifest(const GURL& manifest_url, |
| const content::Manifest& manifest) { |
| if (!GetWebContents()) |
| return; |
| |
| if (manifest_url.is_empty()) { |
| manifest_->error = NO_MANIFEST; |
| SetManifestDependentTasksComplete(); |
| } else if (manifest.IsEmpty()) { |
| manifest_->error = MANIFEST_EMPTY; |
| SetManifestDependentTasksComplete(); |
| } |
| |
| manifest_->url = manifest_url; |
| manifest_->manifest = manifest; |
| manifest_->fetched = true; |
| WorkOnTask(); |
| } |
| |
| void InstallableManager::CheckInstallable() { |
| DCHECK(!installable_->fetched); |
| DCHECK(!manifest().IsEmpty()); |
| |
| if (IsManifestValidForWebApp(manifest())) { |
| CheckServiceWorker(); |
| } else { |
| installable_->installable = false; |
| installable_->fetched = true; |
| WorkOnTask(); |
| } |
| } |
| |
| bool InstallableManager::IsManifestValidForWebApp( |
| const content::Manifest& manifest) { |
| if (manifest.IsEmpty()) { |
| installable_->error = MANIFEST_EMPTY; |
| return false; |
| } |
| |
| if (!manifest.start_url.is_valid()) { |
| installable_->error = START_URL_NOT_VALID; |
| return false; |
| } |
| |
| if ((manifest.name.is_null() || manifest.name.string().empty()) && |
| (manifest.short_name.is_null() || manifest.short_name.string().empty())) { |
| installable_->error = MANIFEST_MISSING_NAME_OR_SHORT_NAME; |
| return false; |
| } |
| |
| // TODO(dominickn,mlamouri): when Chrome supports "minimal-ui", it should be |
| // accepted. If we accept it today, it would fallback to "browser" and make |
| // this check moot. See https://crbug.com/604390. |
| if (manifest.display != blink::kWebDisplayModeStandalone && |
| manifest.display != blink::kWebDisplayModeFullscreen) { |
| installable_->error = MANIFEST_DISPLAY_NOT_SUPPORTED; |
| return false; |
| } |
| |
| if (!DoesManifestContainRequiredIcon(manifest)) { |
| installable_->error = MANIFEST_MISSING_SUITABLE_ICON; |
| return false; |
| } |
| |
| return true; |
| } |
| |
| void InstallableManager::CheckServiceWorker() { |
| DCHECK(!installable_->fetched); |
| DCHECK(!manifest().IsEmpty()); |
| DCHECK(manifest().start_url.is_valid()); |
| |
| content::WebContents* web_contents = GetWebContents(); |
| |
| // Check to see if there is a single service worker controlling this page |
| // and the manifest's start url. |
| content::StoragePartition* storage_partition = |
| content::BrowserContext::GetStoragePartition( |
| Profile::FromBrowserContext(web_contents->GetBrowserContext()), |
| web_contents->GetSiteInstance()); |
| DCHECK(storage_partition); |
| |
| storage_partition->GetServiceWorkerContext()->CheckHasServiceWorker( |
| web_contents->GetLastCommittedURL(), manifest().start_url, |
| base::Bind(&InstallableManager::OnDidCheckHasServiceWorker, |
| weak_factory_.GetWeakPtr())); |
| } |
| |
| void InstallableManager::OnDidCheckHasServiceWorker( |
| content::ServiceWorkerCapability capability) { |
| if (!GetWebContents()) |
| return; |
| |
| switch (capability) { |
| case content::ServiceWorkerCapability::SERVICE_WORKER_WITH_FETCH_HANDLER: |
| installable_->installable = true; |
| break; |
| case content::ServiceWorkerCapability::SERVICE_WORKER_NO_FETCH_HANDLER: |
| installable_->installable = false; |
| installable_->error = NOT_OFFLINE_CAPABLE; |
| break; |
| case content::ServiceWorkerCapability::NO_SERVICE_WORKER: |
| installable_->installable = false; |
| installable_->error = NO_MATCHING_SERVICE_WORKER; |
| break; |
| } |
| |
| installable_->fetched = true; |
| WorkOnTask(); |
| } |
| |
| void InstallableManager::CheckAndFetchBestIcon(const IconParams& params) { |
| DCHECK(!manifest().IsEmpty()); |
| |
| int ideal_icon_size_in_px = std::get<0>(params); |
| int minimum_icon_size_in_px = std::get<1>(params); |
| IconPurpose icon_purpose = std::get<2>(params); |
| |
| IconProperty& icon = icons_[params]; |
| icon.fetched = true; |
| |
| GURL icon_url = ManifestIconSelector::FindBestMatchingIcon( |
| manifest().icons, ideal_icon_size_in_px, minimum_icon_size_in_px, |
| icon_purpose); |
| |
| if (icon_url.is_empty()) { |
| icon.error = NO_ACCEPTABLE_ICON; |
| } else { |
| bool can_download_icon = ManifestIconDownloader::Download( |
| GetWebContents(), icon_url, ideal_icon_size_in_px, |
| minimum_icon_size_in_px, |
| base::Bind(&InstallableManager::OnIconFetched, |
| weak_factory_.GetWeakPtr(), icon_url, params)); |
| if (can_download_icon) |
| return; |
| icon.error = CANNOT_DOWNLOAD_ICON; |
| } |
| |
| WorkOnTask(); |
| } |
| |
| void InstallableManager::OnIconFetched( |
| const GURL icon_url, const IconParams& params, const SkBitmap& bitmap) { |
| IconProperty& icon = icons_[params]; |
| |
| if (!GetWebContents()) |
| return; |
| |
| if (bitmap.drawsNothing()) { |
| icon.error = NO_ICON_AVAILABLE; |
| } else { |
| icon.url = icon_url; |
| icon.icon.reset(new SkBitmap(bitmap)); |
| } |
| |
| WorkOnTask(); |
| } |
| |
| void InstallableManager::DidFinishNavigation( |
| content::NavigationHandle* handle) { |
| if (handle->IsInMainFrame() && handle->HasCommitted() && |
| !handle->IsSameDocument()) { |
| Reset(); |
| } |
| } |
| |
| void InstallableManager::WebContentsDestroyed() { |
| Reset(); |
| Observe(nullptr); |
| } |
| |
| const GURL& InstallableManager::manifest_url() const { |
| return manifest_->url; |
| } |
| |
| const content::Manifest& InstallableManager::manifest() const { |
| return manifest_->manifest; |
| } |
| |
| bool InstallableManager::is_installable() const { |
| return installable_->installable; |
| } |