| OLD | NEW |
| (Empty) |
| 1 // Copyright 2015 The Chromium Authors. All rights reserved. | |
| 2 // Use of this source code is governed by a BSD-style license that can be | |
| 3 // found in the LICENSE file. | |
| 4 | |
| 5 #include "chrome/browser/banners/app_banner_data_fetcher.h" | |
| 6 | |
| 7 #include "base/bind.h" | |
| 8 #include "base/command_line.h" | |
| 9 #include "base/lazy_instance.h" | |
| 10 #include "base/strings/string_number_conversions.h" | |
| 11 #include "base/strings/string_util.h" | |
| 12 #include "base/strings/utf_string_conversions.h" | |
| 13 #include "chrome/browser/banners/app_banner_debug_log.h" | |
| 14 #include "chrome/browser/banners/app_banner_metrics.h" | |
| 15 #include "chrome/browser/banners/app_banner_settings_helper.h" | |
| 16 #include "chrome/browser/browser_process.h" | |
| 17 #include "chrome/browser/manifest/manifest_icon_downloader.h" | |
| 18 #include "chrome/browser/manifest/manifest_icon_selector.h" | |
| 19 #include "chrome/browser/profiles/profile.h" | |
| 20 #include "chrome/common/chrome_switches.h" | |
| 21 #include "chrome/common/render_messages.h" | |
| 22 #include "components/rappor/rappor_utils.h" | |
| 23 #include "content/public/browser/browser_context.h" | |
| 24 #include "content/public/browser/browser_thread.h" | |
| 25 #include "content/public/browser/navigation_details.h" | |
| 26 #include "content/public/browser/render_frame_host.h" | |
| 27 #include "content/public/browser/service_worker_context.h" | |
| 28 #include "content/public/browser/storage_partition.h" | |
| 29 #include "net/base/load_flags.h" | |
| 30 #include "third_party/WebKit/public/platform/WebDisplayMode.h" | |
| 31 #include "third_party/WebKit/public/platform/modules/app_banner/WebAppBannerProm
ptReply.h" | |
| 32 | |
| 33 namespace { | |
| 34 | |
| 35 base::LazyInstance<base::TimeDelta> gTimeDeltaForTesting = | |
| 36 LAZY_INSTANCE_INITIALIZER; | |
| 37 int gCurrentRequestID = -1; | |
| 38 const char kPngExtension[] = ".png"; | |
| 39 | |
| 40 // The requirement for now is an image/png that is at least 144x144. | |
| 41 const int kIconMinimumSize = 144; | |
| 42 bool DoesManifestContainRequiredIcon(const content::Manifest& manifest) { | |
| 43 for (const auto& icon : manifest.icons) { | |
| 44 // The type field is optional. If it isn't present, fall back on checking | |
| 45 // the src extension, and allow the icon if the extension ends with png. | |
| 46 if (!base::EqualsASCII(icon.type.string(), "image/png") && | |
| 47 !(icon.type.is_null() && | |
| 48 base::EndsWith(icon.src.ExtractFileName(), kPngExtension, | |
| 49 base::CompareCase::INSENSITIVE_ASCII))) | |
| 50 continue; | |
| 51 | |
| 52 for (const auto& size : icon.sizes) { | |
| 53 if (size.IsEmpty()) // "any" | |
| 54 return true; | |
| 55 if (size.width() >= kIconMinimumSize && size.height() >= kIconMinimumSize) | |
| 56 return true; | |
| 57 } | |
| 58 } | |
| 59 | |
| 60 return false; | |
| 61 } | |
| 62 | |
| 63 } // anonymous namespace | |
| 64 | |
| 65 namespace banners { | |
| 66 | |
| 67 // static | |
| 68 base::Time AppBannerDataFetcher::GetCurrentTime() { | |
| 69 return base::Time::Now() + gTimeDeltaForTesting.Get(); | |
| 70 } | |
| 71 | |
| 72 // static | |
| 73 void AppBannerDataFetcher::SetTimeDeltaForTesting(int days) { | |
| 74 gTimeDeltaForTesting.Get() = base::TimeDelta::FromDays(days); | |
| 75 } | |
| 76 | |
| 77 AppBannerDataFetcher::AppBannerDataFetcher(content::WebContents* web_contents, | |
| 78 base::WeakPtr<Delegate> delegate, | |
| 79 int ideal_icon_size_in_dp, | |
| 80 int minimum_icon_size_in_dp, | |
| 81 bool is_debug_mode) | |
| 82 : WebContentsObserver(web_contents), | |
| 83 weak_delegate_(delegate), | |
| 84 ideal_icon_size_in_dp_(ideal_icon_size_in_dp), | |
| 85 minimum_icon_size_in_dp_(minimum_icon_size_in_dp), | |
| 86 is_active_(false), | |
| 87 was_canceled_by_page_(false), | |
| 88 page_requested_prompt_(false), | |
| 89 is_debug_mode_(is_debug_mode || | |
| 90 base::CommandLine::ForCurrentProcess()->HasSwitch( | |
| 91 switches::kBypassAppBannerEngagementChecks)), | |
| 92 event_request_id_(-1) { | |
| 93 DCHECK(minimum_icon_size_in_dp <= ideal_icon_size_in_dp); | |
| 94 } | |
| 95 | |
| 96 void AppBannerDataFetcher::Start(const GURL& validated_url, | |
| 97 ui::PageTransition transition_type) { | |
| 98 DCHECK_CURRENTLY_ON(content::BrowserThread::UI); | |
| 99 | |
| 100 content::WebContents* web_contents = GetWebContents(); | |
| 101 DCHECK(web_contents); | |
| 102 | |
| 103 is_active_ = true; | |
| 104 was_canceled_by_page_ = false; | |
| 105 page_requested_prompt_ = false; | |
| 106 transition_type_ = transition_type; | |
| 107 validated_url_ = validated_url; | |
| 108 referrer_.erase(); | |
| 109 web_contents->GetManifest( | |
| 110 base::Bind(&AppBannerDataFetcher::OnDidGetManifest, this)); | |
| 111 } | |
| 112 | |
| 113 void AppBannerDataFetcher::Cancel() { | |
| 114 DCHECK_CURRENTLY_ON(content::BrowserThread::UI); | |
| 115 if (is_active_) { | |
| 116 FOR_EACH_OBSERVER(Observer, observer_list_, | |
| 117 OnDecidedWhetherToShow(this, false)); | |
| 118 if (was_canceled_by_page_ && !page_requested_prompt_) { | |
| 119 TrackBeforeInstallEvent( | |
| 120 BEFORE_INSTALL_EVENT_PROMPT_NOT_CALLED_AFTER_PREVENT_DEFAULT); | |
| 121 } | |
| 122 | |
| 123 is_active_ = false; | |
| 124 was_canceled_by_page_ = false; | |
| 125 page_requested_prompt_ = false; | |
| 126 referrer_.erase(); | |
| 127 } | |
| 128 } | |
| 129 | |
| 130 void AppBannerDataFetcher::ReplaceWebContents( | |
| 131 content::WebContents* web_contents) { | |
| 132 Observe(web_contents); | |
| 133 } | |
| 134 | |
| 135 void AppBannerDataFetcher::AddObserverForTesting(Observer* observer) { | |
| 136 observer_list_.AddObserver(observer); | |
| 137 } | |
| 138 | |
| 139 void AppBannerDataFetcher::RemoveObserverForTesting(Observer* observer) { | |
| 140 observer_list_.RemoveObserver(observer); | |
| 141 } | |
| 142 | |
| 143 void AppBannerDataFetcher::WebContentsDestroyed() { | |
| 144 Cancel(); | |
| 145 Observe(nullptr); | |
| 146 } | |
| 147 | |
| 148 void AppBannerDataFetcher::DidNavigateMainFrame( | |
| 149 const content::LoadCommittedDetails& details, | |
| 150 const content::FrameNavigateParams& params) { | |
| 151 if (!details.is_in_page) | |
| 152 Cancel(); | |
| 153 } | |
| 154 | |
| 155 bool AppBannerDataFetcher::OnMessageReceived( | |
| 156 const IPC::Message& message, content::RenderFrameHost* render_frame_host) { | |
| 157 bool handled = true; | |
| 158 | |
| 159 IPC_BEGIN_MESSAGE_MAP_WITH_PARAM(AppBannerDataFetcher, message, | |
| 160 render_frame_host) | |
| 161 IPC_MESSAGE_HANDLER(ChromeViewHostMsg_AppBannerPromptReply, | |
| 162 OnBannerPromptReply) | |
| 163 IPC_MESSAGE_HANDLER(ChromeViewHostMsg_RequestShowAppBanner, | |
| 164 OnRequestShowAppBanner) | |
| 165 IPC_MESSAGE_UNHANDLED(handled = false) | |
| 166 IPC_END_MESSAGE_MAP() | |
| 167 | |
| 168 return handled; | |
| 169 } | |
| 170 | |
| 171 void AppBannerDataFetcher::OnBannerPromptReply( | |
| 172 content::RenderFrameHost* render_frame_host, | |
| 173 int request_id, | |
| 174 blink::WebAppBannerPromptReply reply, | |
| 175 std::string referrer) { | |
| 176 content::WebContents* web_contents = GetWebContents(); | |
| 177 if (!CheckFetcherIsStillAlive(web_contents) || | |
| 178 request_id != event_request_id_) { | |
| 179 Cancel(); | |
| 180 return; | |
| 181 } | |
| 182 | |
| 183 // The renderer might have requested the prompt to be canceled. | |
| 184 // They may request that it is redisplayed later, so don't Cancel() here. | |
| 185 // However, log that the cancelation was requested, so Cancel() can be | |
| 186 // called if a redisplay isn't asked for. | |
| 187 // | |
| 188 // The redisplay request may be received before the Cancel prompt reply | |
| 189 // *after* if it is made before the beforeinstallprompt event handler | |
| 190 // concludes (e.g. in the event handler itself), so allow the pipeline | |
| 191 // to continue in this case. | |
| 192 // | |
| 193 // Stash the referrer for the case where the banner is redisplayed. | |
| 194 if (reply == blink::WebAppBannerPromptReply::Cancel && | |
| 195 !page_requested_prompt_) { | |
| 196 TrackBeforeInstallEvent(BEFORE_INSTALL_EVENT_PREVENT_DEFAULT_CALLED); | |
| 197 was_canceled_by_page_ = true; | |
| 198 referrer_ = referrer; | |
| 199 OutputDeveloperNotShownMessage(web_contents, kRendererRequestCancel, | |
| 200 is_debug_mode_); | |
| 201 return; | |
| 202 } | |
| 203 | |
| 204 // If we haven't yet returned, but either of |was_canceled_by_page_| or | |
| 205 // |page_requested_prompt_| is true, the page has requested a delayed showing | |
| 206 // of the prompt. Otherwise, the prompt was never canceled by the page. | |
| 207 if (was_canceled_by_page_ || page_requested_prompt_) { | |
| 208 TrackBeforeInstallEvent( | |
| 209 BEFORE_INSTALL_EVENT_PROMPT_CALLED_AFTER_PREVENT_DEFAULT); | |
| 210 was_canceled_by_page_ = false; | |
| 211 } else { | |
| 212 TrackBeforeInstallEvent(BEFORE_INSTALL_EVENT_NO_ACTION); | |
| 213 } | |
| 214 | |
| 215 AppBannerSettingsHelper::RecordMinutesFromFirstVisitToShow( | |
| 216 web_contents, validated_url_, GetAppIdentifier(), GetCurrentTime()); | |
| 217 | |
| 218 // Definitely going to show the banner now. | |
| 219 FOR_EACH_OBSERVER(Observer, observer_list_, | |
| 220 OnDecidedWhetherToShow(this, true)); | |
| 221 | |
| 222 TrackBeforeInstallEvent(BEFORE_INSTALL_EVENT_COMPLETE); | |
| 223 ShowBanner(app_icon_url_, app_icon_.get(), app_title_, referrer); | |
| 224 is_active_ = false; | |
| 225 } | |
| 226 | |
| 227 void AppBannerDataFetcher::OnRequestShowAppBanner( | |
| 228 content::RenderFrameHost* render_frame_host, | |
| 229 int request_id) { | |
| 230 if (was_canceled_by_page_) { | |
| 231 // Simulate an "OK" from the website to restart the banner display pipeline. | |
| 232 // Don't reset |was_canceled_by_page_| yet for metrics purposes. | |
| 233 OnBannerPromptReply(render_frame_host, request_id, | |
| 234 blink::WebAppBannerPromptReply::None, referrer_); | |
| 235 } else { | |
| 236 // Log that the prompt request was made for when we get the prompt reply. | |
| 237 page_requested_prompt_ = true; | |
| 238 } | |
| 239 } | |
| 240 | |
| 241 AppBannerDataFetcher::~AppBannerDataFetcher() { | |
| 242 FOR_EACH_OBSERVER(Observer, observer_list_, OnFetcherDestroyed(this)); | |
| 243 } | |
| 244 | |
| 245 std::string AppBannerDataFetcher::GetBannerType() { | |
| 246 return "web"; | |
| 247 } | |
| 248 | |
| 249 content::WebContents* AppBannerDataFetcher::GetWebContents() { | |
| 250 if (!web_contents() || web_contents()->IsBeingDestroyed()) | |
| 251 return nullptr; | |
| 252 return web_contents(); | |
| 253 } | |
| 254 | |
| 255 std::string AppBannerDataFetcher::GetAppIdentifier() { | |
| 256 DCHECK(!manifest_.IsEmpty()); | |
| 257 return manifest_.start_url.spec(); | |
| 258 } | |
| 259 | |
| 260 void AppBannerDataFetcher::RecordDidShowBanner(const std::string& event_name) { | |
| 261 content::WebContents* web_contents = GetWebContents(); | |
| 262 DCHECK(web_contents); | |
| 263 | |
| 264 AppBannerSettingsHelper::RecordBannerEvent( | |
| 265 web_contents, validated_url_, GetAppIdentifier(), | |
| 266 AppBannerSettingsHelper::APP_BANNER_EVENT_DID_SHOW, | |
| 267 GetCurrentTime()); | |
| 268 rappor::SampleDomainAndRegistryFromGURL(g_browser_process->rappor_service(), | |
| 269 event_name, | |
| 270 web_contents->GetURL()); | |
| 271 } | |
| 272 | |
| 273 void AppBannerDataFetcher::OnDidGetManifest( | |
| 274 const GURL& manifest_url, | |
| 275 const content::Manifest& manifest) { | |
| 276 content::WebContents* web_contents = GetWebContents(); | |
| 277 if (!CheckFetcherIsStillAlive(web_contents)) { | |
| 278 Cancel(); | |
| 279 return; | |
| 280 } | |
| 281 if (manifest_url.is_empty()) { | |
| 282 OutputDeveloperNotShownMessage(web_contents, kNoManifest, is_debug_mode_); | |
| 283 Cancel(); | |
| 284 return; | |
| 285 } | |
| 286 if (manifest.IsEmpty()) { | |
| 287 OutputDeveloperNotShownMessage(web_contents, kManifestEmpty, | |
| 288 is_debug_mode_); | |
| 289 Cancel(); | |
| 290 return; | |
| 291 } | |
| 292 | |
| 293 if (manifest.prefer_related_applications && | |
| 294 manifest.related_applications.size()) { | |
| 295 for (const auto& application : manifest.related_applications) { | |
| 296 std::string platform = base::UTF16ToUTF8(application.platform.string()); | |
| 297 std::string id = base::UTF16ToUTF8(application.id.string()); | |
| 298 if (weak_delegate_->HandleNonWebApp(platform, application.url, id, | |
| 299 is_debug_mode_)) | |
| 300 return; | |
| 301 } | |
| 302 } | |
| 303 | |
| 304 if (!IsManifestValidForWebApp(manifest, web_contents, is_debug_mode_)) { | |
| 305 Cancel(); | |
| 306 return; | |
| 307 } | |
| 308 | |
| 309 // Since the manifest is valid, one of short name or name must be non-null. | |
| 310 // Prefer name if it isn't null. | |
| 311 manifest_url_ = manifest_url; | |
| 312 manifest_ = manifest; | |
| 313 app_title_ = (manifest_.name.is_null()) ? manifest_.short_name.string() | |
| 314 : manifest_.name.string(); | |
| 315 | |
| 316 if (IsWebAppInstalled(web_contents->GetBrowserContext(), | |
| 317 manifest.start_url) && | |
| 318 !is_debug_mode_) { | |
| 319 Cancel(); | |
| 320 return; | |
| 321 } | |
| 322 | |
| 323 banners::TrackDisplayEvent(DISPLAY_EVENT_WEB_APP_BANNER_REQUESTED); | |
| 324 | |
| 325 // Check to see if there is a single service worker controlling this page | |
| 326 // and the manifest's start url. | |
| 327 Profile* profile = | |
| 328 Profile::FromBrowserContext(web_contents->GetBrowserContext()); | |
| 329 content::StoragePartition* storage_partition = | |
| 330 content::BrowserContext::GetStoragePartition( | |
| 331 profile, web_contents->GetSiteInstance()); | |
| 332 DCHECK(storage_partition); | |
| 333 | |
| 334 storage_partition->GetServiceWorkerContext()->CheckHasServiceWorker( | |
| 335 validated_url_, manifest.start_url, | |
| 336 base::Bind(&AppBannerDataFetcher::OnDidCheckHasServiceWorker, | |
| 337 this)); | |
| 338 } | |
| 339 | |
| 340 void AppBannerDataFetcher::OnDidCheckHasServiceWorker( | |
| 341 bool has_service_worker) { | |
| 342 content::WebContents* web_contents = GetWebContents(); | |
| 343 if (!CheckFetcherIsStillAlive(web_contents)) { | |
| 344 Cancel(); | |
| 345 return; | |
| 346 } | |
| 347 | |
| 348 if (!has_service_worker) { | |
| 349 TrackDisplayEvent(DISPLAY_EVENT_LACKS_SERVICE_WORKER); | |
| 350 OutputDeveloperNotShownMessage(web_contents, kNoMatchingServiceWorker, | |
| 351 is_debug_mode_); | |
| 352 Cancel(); | |
| 353 return; | |
| 354 } | |
| 355 | |
| 356 OnHasServiceWorker(web_contents); | |
| 357 } | |
| 358 | |
| 359 void AppBannerDataFetcher::OnHasServiceWorker( | |
| 360 content::WebContents* web_contents) { | |
| 361 GURL icon_url = ManifestIconSelector::FindBestMatchingIcon( | |
| 362 manifest_.icons, ideal_icon_size_in_dp_, minimum_icon_size_in_dp_); | |
| 363 | |
| 364 if (icon_url.is_empty()) { | |
| 365 OutputDeveloperNotShownMessage( | |
| 366 web_contents, | |
| 367 kNoIconMatchingRequirements, | |
| 368 base::IntToString(ManifestIconSelector::ConvertIconSizeFromDpToPx( | |
| 369 minimum_icon_size_in_dp_)), | |
| 370 is_debug_mode_); | |
| 371 Cancel(); | |
| 372 } else if (!FetchAppIcon(web_contents, icon_url)) { | |
| 373 OutputDeveloperNotShownMessage(web_contents, kCannotDownloadIcon, | |
| 374 is_debug_mode_); | |
| 375 Cancel(); | |
| 376 } | |
| 377 } | |
| 378 | |
| 379 bool AppBannerDataFetcher::FetchAppIcon(content::WebContents* web_contents, | |
| 380 const GURL& icon_url) { | |
| 381 app_icon_url_ = icon_url; | |
| 382 return ManifestIconDownloader::Download( | |
| 383 web_contents, icon_url, ideal_icon_size_in_dp_, minimum_icon_size_in_dp_, | |
| 384 base::Bind(&AppBannerDataFetcher::OnAppIconFetched, this)); | |
| 385 } | |
| 386 | |
| 387 void AppBannerDataFetcher::OnAppIconFetched(const SkBitmap& bitmap) { | |
| 388 if (!is_active_) return; | |
| 389 | |
| 390 content::WebContents* web_contents = GetWebContents(); | |
| 391 if (!CheckFetcherIsStillAlive(web_contents)) { | |
| 392 Cancel(); | |
| 393 return; | |
| 394 } | |
| 395 if (bitmap.drawsNothing()) { | |
| 396 OutputDeveloperNotShownMessage(web_contents, kNoIconAvailable, | |
| 397 is_debug_mode_); | |
| 398 Cancel(); | |
| 399 return; | |
| 400 } | |
| 401 | |
| 402 RecordCouldShowBanner(); | |
| 403 if (!is_debug_mode_ && !CheckIfShouldShowBanner()) { | |
| 404 // At this point, the only possible case is that the banner has been added | |
| 405 // to the homescreen, given all of the other checks that have been made. | |
| 406 Cancel(); | |
| 407 return; | |
| 408 } | |
| 409 | |
| 410 app_icon_.reset(new SkBitmap(bitmap)); | |
| 411 event_request_id_ = ++gCurrentRequestID; | |
| 412 | |
| 413 TrackBeforeInstallEvent(BEFORE_INSTALL_EVENT_CREATED); | |
| 414 web_contents->GetMainFrame()->Send( | |
| 415 new ChromeViewMsg_AppBannerPromptRequest( | |
| 416 web_contents->GetMainFrame()->GetRoutingID(), | |
| 417 event_request_id_, | |
| 418 GetBannerType())); | |
| 419 } | |
| 420 | |
| 421 bool AppBannerDataFetcher::IsWebAppInstalled( | |
| 422 content::BrowserContext* browser_context, | |
| 423 const GURL& start_url) { | |
| 424 return false; | |
| 425 } | |
| 426 | |
| 427 void AppBannerDataFetcher::RecordCouldShowBanner() { | |
| 428 content::WebContents* web_contents = GetWebContents(); | |
| 429 DCHECK(web_contents); | |
| 430 | |
| 431 AppBannerSettingsHelper::RecordBannerCouldShowEvent( | |
| 432 web_contents, validated_url_, GetAppIdentifier(), | |
| 433 GetCurrentTime(), transition_type_); | |
| 434 } | |
| 435 | |
| 436 bool AppBannerDataFetcher::CheckIfShouldShowBanner() { | |
| 437 content::WebContents* web_contents = GetWebContents(); | |
| 438 DCHECK(web_contents); | |
| 439 | |
| 440 return AppBannerSettingsHelper::ShouldShowBanner( | |
| 441 web_contents, validated_url_, GetAppIdentifier(), GetCurrentTime()); | |
| 442 } | |
| 443 | |
| 444 bool AppBannerDataFetcher::CheckFetcherIsStillAlive( | |
| 445 content::WebContents* web_contents) { | |
| 446 if (!is_active_) { | |
| 447 OutputDeveloperNotShownMessage( | |
| 448 web_contents, kUserNavigatedBeforeBannerShown, is_debug_mode_); | |
| 449 return false; | |
| 450 } | |
| 451 if (!web_contents) { | |
| 452 return false; // We cannot show a message if |web_contents| is null | |
| 453 } | |
| 454 return true; | |
| 455 } | |
| 456 | |
| 457 // static | |
| 458 bool AppBannerDataFetcher::IsManifestValidForWebApp( | |
| 459 const content::Manifest& manifest, | |
| 460 content::WebContents* web_contents, | |
| 461 bool is_debug_mode) { | |
| 462 if (manifest.IsEmpty()) { | |
| 463 OutputDeveloperNotShownMessage(web_contents, kManifestEmpty, is_debug_mode); | |
| 464 return false; | |
| 465 } | |
| 466 if (!manifest.start_url.is_valid()) { | |
| 467 OutputDeveloperNotShownMessage(web_contents, kStartURLNotValid, | |
| 468 is_debug_mode); | |
| 469 return false; | |
| 470 } | |
| 471 if ((manifest.name.is_null() || manifest.name.string().empty()) && | |
| 472 (manifest.short_name.is_null() || manifest.short_name.string().empty())) { | |
| 473 OutputDeveloperNotShownMessage( | |
| 474 web_contents, kManifestMissingNameOrShortName, is_debug_mode); | |
| 475 return false; | |
| 476 } | |
| 477 | |
| 478 // TODO(dominickn,mlamouri): when Chrome supports "minimal-ui", it should be | |
| 479 // accepted. If we accept it today, it would fallback to "browser" and make | |
| 480 // this check moot. See https://crbug.com/604390 | |
| 481 if (manifest.display != blink::WebDisplayModeStandalone && | |
| 482 manifest.display != blink::WebDisplayModeFullscreen) { | |
| 483 OutputDeveloperNotShownMessage( | |
| 484 web_contents, kManifestDisplayStandaloneFullscreen, is_debug_mode); | |
| 485 return false; | |
| 486 } | |
| 487 | |
| 488 if (!DoesManifestContainRequiredIcon(manifest)) { | |
| 489 OutputDeveloperNotShownMessage(web_contents, kManifestMissingSuitableIcon, | |
| 490 is_debug_mode); | |
| 491 return false; | |
| 492 } | |
| 493 return true; | |
| 494 } | |
| 495 | |
| 496 } // namespace banners | |
| OLD | NEW |