Chromium Code Reviews| 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/signin/cross_device_promo.h" | |
| 6 | |
| 7 #include "base/metrics/histogram_macros.h" | |
| 8 #include "base/prefs/pref_service.h" | |
| 9 #include "base/rand_util.h" | |
| 10 #include "base/strings/string_number_conversions.h" | |
| 11 #include "base/time/time.h" | |
| 12 #include "chrome/common/pref_names.h" | |
| 13 #include "components/signin/core/browser/signin_client.h" | |
| 14 #include "components/signin/core/browser/signin_manager.h" | |
| 15 #include "components/signin/core/browser/signin_metrics.h" | |
| 16 #include "components/variations/variations_associated_data.h" | |
| 17 #include "net/cookies/canonical_cookie.h" | |
| 18 | |
| 19 namespace { | |
| 20 | |
| 21 const int kDelayUntilGettingDeviceActivityInMS = 3000; | |
| 22 const int kDefaultBrowsingSessionDurationInMinutes = 15; | |
| 23 | |
| 24 // Helper method to set a parameter based on a particular variable from the | |
| 25 // configuration. Returns false if the parameter was not found or the | |
| 26 // converstion could not succeed. | |
|
Roger Tawa OOO till Jul 10th
2015/05/15 16:01:39
typo: converstion
Mike Lerman
2015/05/15 20:47:34
Done.
| |
| 27 bool SetParameterFromVariation( | |
| 28 std::string variation_parameter, | |
| 29 base::TimeDelta& local_parameter, | |
|
Roger Tawa OOO till Jul 10th
2015/05/15 16:01:40
const & for line 28, pointer for out arg on line 2
Mike Lerman
2015/05/15 20:47:34
Done.
| |
| 30 base::Callback<base::TimeDelta(int)> conversion) { | |
| 31 std::string parameter_as_string = variations::GetVariationParamValue( | |
| 32 "CrossDevicePromo", variation_parameter); | |
| 33 if (parameter_as_string.empty()) | |
| 34 return false; | |
| 35 | |
| 36 int parameter_as_int; | |
| 37 if (!base::StringToInt(parameter_as_string, ¶meter_as_int)) | |
| 38 return false; | |
| 39 | |
| 40 local_parameter = conversion.Run(parameter_as_int); | |
| 41 return true; | |
| 42 } | |
| 43 | |
| 44 } // namespace | |
| 45 | |
| 46 CrossDevicePromo::CrossDevicePromo( | |
| 47 SigninManager* signin_manager, | |
| 48 GaiaCookieManagerService* cookie_manager_service, | |
| 49 SigninClient* signin_client, | |
| 50 PrefService* pref_service) | |
| 51 : signin_manager_(signin_manager), | |
| 52 cookie_manager_service_(cookie_manager_service), | |
| 53 prefs_(pref_service), | |
| 54 signin_client_(signin_client), | |
| 55 start_last_browsing_session_(base::Time()), | |
| 56 initialized_(false), | |
| 57 track_metrics_(true) { | |
| 58 VLOG(1) << "CrossDevicePromo::CrossDevicePromo."; | |
|
Roger Tawa OOO till Jul 10th
2015/05/15 16:01:40
nit: DCHECK pointers are good?
Mike Lerman
2015/05/15 20:47:33
Done.
| |
| 59 Init(); | |
| 60 } | |
| 61 | |
| 62 CrossDevicePromo::~CrossDevicePromo() {} | |
| 63 | |
| 64 void CrossDevicePromo::Shutdown() { | |
| 65 VLOG(1) << "CrossDevicePromo::Shutdown."; | |
| 66 UnregisterForCookieChanges(); | |
| 67 if (start_last_browsing_session_ != base::Time()) | |
| 68 signin_metrics::LogBrowsingSessionDuration(start_last_browsing_session_); | |
| 69 } | |
| 70 | |
| 71 | |
| 72 void CrossDevicePromo::AddObserver(CrossDevicePromo::Observer* observer) { | |
| 73 observer_list_.AddObserver(observer); | |
| 74 } | |
| 75 | |
| 76 void CrossDevicePromo::RemoveObserver(CrossDevicePromo::Observer* observer) { | |
| 77 observer_list_.RemoveObserver(observer); | |
| 78 } | |
| 79 | |
| 80 bool CrossDevicePromo::IsPromoActive() { | |
| 81 return prefs_->GetBoolean(prefs::kCrossDevicePromoActive); | |
| 82 } | |
| 83 | |
| 84 void CrossDevicePromo::Init() { | |
| 85 DCHECK(!initialized_); | |
| 86 // We need a default value for this as it is referenced early in | |
| 87 // UpdateLastActiveTime and we want to gather as many stats about Browsing | |
| 88 // Sessions as possible. | |
| 89 inactivity_between_browsing_sessions_ = base::TimeDelta::FromMinutes( | |
| 90 kDefaultBrowsingSessionDurationInMinutes); | |
| 91 | |
| 92 if (prefs_->GetBoolean(prefs::kCrossDevicePromoOptedOut)) { | |
| 93 UMA_HISTOGRAM_BOOLEAN("Signin.XDevicePromo.Initialized", false); | |
| 94 return; | |
| 95 } | |
| 96 | |
| 97 if (!SetParameterFromVariation("HoursBetweenSyncDeviceChecks", | |
| 98 delay_until_next_list_devices_, | |
| 99 base::Bind(&base::TimeDelta::FromHours)) || | |
| 100 !SetParameterFromVariation("DaysToVerifySingleUserProfile", | |
| 101 single_account_duration_threshold_, | |
| 102 base::Bind(&base::TimeDelta::FromDays)) || | |
| 103 !SetParameterFromVariation("MinutesBetweenBrowsingSessions", | |
| 104 inactivity_between_browsing_sessions_, | |
| 105 base::Bind(&base::TimeDelta::FromMinutes)) || | |
| 106 !SetParameterFromVariation("MinutesMaxContextSwitchDuration", | |
| 107 context_switch_duration_, | |
| 108 base::Bind(&base::TimeDelta::FromMinutes))) { | |
| 109 UMA_HISTOGRAM_BOOLEAN("Signin.XDevicePromo.Initialized", false); | |
|
Roger Tawa OOO till Jul 10th
2015/05/15 16:01:40
Is it worth making this histogram an enum to diffe
Mike Lerman
2015/05/15 20:47:34
I'll split it into three: OPTED_OUT, NO_VARIATIONS
| |
| 110 return; | |
| 111 } | |
| 112 | |
| 113 std::string throttle = variations::GetVariationParamValue( | |
| 114 "CrossDevicePromo", "RPCThrottle"); | |
| 115 if (throttle.empty() || !base::StringToUint64(throttle, &rpc_throttle_)) { | |
| 116 UMA_HISTOGRAM_BOOLEAN("Signin.XDevicePromo.Initialized", false); | |
| 117 return; | |
| 118 } | |
| 119 | |
| 120 VLOG(1) << "CrossDevicePromo::Init. Service initialized. Parameters: " | |
| 121 << "Hour between RPC checks: " | |
| 122 << delay_until_next_list_devices_.InHours() | |
| 123 << "Days to verify an account in the cookie: " | |
| 124 << single_account_duration_threshold_.InDays() | |
| 125 << "Minutes between browsing sessions: " | |
| 126 << inactivity_between_browsing_sessions_.InMinutes() | |
| 127 << "Window (in minutes) for a context switch: " | |
| 128 << context_switch_duration_.InMinutes() | |
| 129 << "Throttle rate for RPC calls: " | |
| 130 << rpc_throttle_; | |
| 131 RegisterForCookieChanges(); | |
| 132 initialized_ = true; | |
| 133 UMA_HISTOGRAM_BOOLEAN("Signin.XDevicePromo.Initialized", true); | |
| 134 return; | |
| 135 } | |
| 136 | |
| 137 void CrossDevicePromo::OptOut() { | |
| 138 VLOG(1) << "CrossDevicePromo::OptOut."; | |
| 139 UnregisterForCookieChanges(); | |
| 140 prefs_->SetBoolean(prefs::kCrossDevicePromoOptedOut, true); | |
| 141 MarkPromoInactive(); | |
|
Roger Tawa OOO till Jul 10th
2015/05/15 16:01:40
Stop any fetchers too?
Mike Lerman
2015/05/15 20:47:34
Yes. Added a Stop method to the DeviceActivityFetc
| |
| 142 } | |
| 143 | |
| 144 bool CrossDevicePromo::VerifyPromoEligibleReadOnly() { | |
| 145 if (!initialized_) | |
| 146 return false; | |
| 147 | |
| 148 if (prefs_->GetBoolean(prefs::kCrossDevicePromoOptedOut)) | |
| 149 return false; | |
| 150 | |
| 151 if (!prefs_->HasPrefPath(prefs::kCrossDevicePromoObservedSingleAccountCookie)) | |
| 152 return false; | |
| 153 | |
| 154 if (base::Time::FromInternalValue(prefs_->GetInt64( | |
| 155 prefs::kCrossDevicePromoObservedSingleAccountCookie)) + | |
| 156 single_account_duration_threshold_ > | |
| 157 base::Time::Now()) { | |
| 158 return false; | |
| 159 } | |
| 160 | |
| 161 return true; | |
| 162 } | |
| 163 | |
| 164 bool CrossDevicePromo::CheckPromoEligibility() { | |
| 165 if (!initialized_) { | |
| 166 // In tests the variations may not be present when Init() was first called. | |
| 167 Init(); | |
| 168 if (!initialized_) | |
| 169 return false; | |
| 170 } | |
| 171 | |
| 172 if (prefs_->GetBoolean(prefs::kCrossDevicePromoOptedOut)) { | |
| 173 signin_metrics::LogXDevicePromoEligible(signin_metrics::OPTED_OUT); | |
| 174 return false; | |
| 175 } | |
| 176 | |
| 177 if (!signin_manager_->GetAuthenticatedUsername().empty()) { | |
|
Roger Tawa OOO till Jul 10th
2015/05/15 16:01:40
Use IsAuthenticated() instead of !GetAuthenticated
Mike Lerman
2015/05/15 20:47:34
Done.
| |
| 178 signin_metrics::LogXDevicePromoEligible(signin_metrics::SIGNED_IN); | |
| 179 return false; | |
| 180 } | |
| 181 | |
| 182 base::Time cookie_has_one_account_since = base::Time::FromInternalValue( | |
| 183 prefs_->GetInt64( | |
| 184 prefs::kCrossDevicePromoObservedSingleAccountCookie)); | |
| 185 if (!prefs_->HasPrefPath(prefs::kCrossDevicePromoObservedSingleAccountCookie) | |
| 186 || cookie_has_one_account_since + single_account_duration_threshold_ > | |
| 187 base::Time::Now()) { | |
| 188 signin_metrics::LogXDevicePromoEligible( | |
| 189 signin_metrics::NOT_SINGLE_GAIA_ACCOUNT); | |
| 190 return false; | |
| 191 } | |
| 192 | |
| 193 // This is the first time the promo's being run; determine when to call the | |
| 194 // DeviceActivityFetcher. | |
| 195 if (!prefs_->HasPrefPath(prefs::kCrossDevicePromoNextFetchListDevicesTime)) { | |
| 196 int minutes_until_next_list_devices = base::RandGenerator( | |
| 197 delay_until_next_list_devices_.InMinutes()); | |
| 198 prefs_->SetInt64(prefs::kCrossDevicePromoNextFetchListDevicesTime, | |
| 199 (base::Time::Now() + | |
| 200 base::TimeDelta::FromMinutes(minutes_until_next_list_devices)). | |
| 201 ToInternalValue()); | |
| 202 signin_metrics::LogXDevicePromoEligible( | |
| 203 signin_metrics::UNKNOWN_COUNT_DEVICES); | |
| 204 return false; | |
| 205 } | |
| 206 | |
| 207 // We have no knowledge of other device activity yet. | |
| 208 if (!prefs_->HasPrefPath(prefs::kCrossDevicePromoNumDevices)) { | |
| 209 base::Time time_next_list_devices = base::Time::FromInternalValue( | |
| 210 prefs_->GetInt64(prefs::kCrossDevicePromoNextFetchListDevicesTime)); | |
| 211 // Not time yet to poll the list of devices. | |
| 212 if (time_next_list_devices > base::Time::Now()) { | |
| 213 signin_metrics::LogXDevicePromoEligible( | |
| 214 signin_metrics::UNKNOWN_COUNT_DEVICES); | |
| 215 return false; | |
| 216 } else { | |
| 217 // We're not eligible... but might be! Track metrics in the results. | |
| 218 GetDevicesActivityForAccountInCookie(); | |
| 219 return false; | |
| 220 } | |
| 221 } | |
| 222 | |
| 223 int num_devices = prefs_->GetInteger(prefs::kCrossDevicePromoNumDevices); | |
| 224 base::Time time_next_list_devices = base::Time::FromInternalValue( | |
| 225 prefs_->GetInt64(prefs::kCrossDevicePromoNextFetchListDevicesTime)); | |
| 226 if (time_next_list_devices < base::Time::Now()) { | |
| 227 GetDevicesActivityForAccountInCookie(); | |
| 228 } else if (num_devices == 0) { | |
| 229 signin_metrics::LogXDevicePromoEligible(signin_metrics::ZERO_DEVICES); | |
| 230 return false; | |
| 231 } | |
| 232 | |
| 233 DCHECK(VerifyPromoEligibleReadOnly()); | |
| 234 return true; | |
| 235 } | |
| 236 | |
| 237 void CrossDevicePromo::UpdateLastActiveTime( | |
|
Roger Tawa OOO till Jul 10th
2015/05/15 16:01:40
nit: should this be called something like UpdateSt
Mike Lerman
2015/05/15 20:47:34
I opted for: MaybeBrowsingSessionStarted
| |
| 238 const base::Time& previous_last_active) { | |
| 239 // In tests, or the first call for a profile, don't pass go. | |
| 240 if (previous_last_active == base::Time()) | |
| 241 return; | |
| 242 | |
| 243 // Determine how often this method is called. Need an estimate for QPS. | |
| 244 UMA_HISTOGRAM_CUSTOM_COUNTS( | |
| 245 "Signin.XDevicePromo.BrowsingSessionDurationComputed", | |
| 246 (base::Time::Now() - previous_last_active).InMinutes(), | |
| 247 1, base::TimeDelta::FromDays(30).InMinutes(), 50); | |
| 248 | |
| 249 // Check if this is a different browsing session since the last call. | |
| 250 if (base::Time::Now() - previous_last_active <= | |
| 251 inactivity_between_browsing_sessions_) { | |
| 252 VLOG(1) << "CrossDevicePromo::UpdateLastActiveTime. Same browsing session " | |
| 253 "as the last call."; | |
| 254 return; | |
| 255 } | |
| 256 | |
| 257 if (start_last_browsing_session_ != base::Time()) | |
| 258 signin_metrics::LogBrowsingSessionDuration(previous_last_active); | |
| 259 | |
| 260 start_last_browsing_session_ = base::Time::Now(); | |
| 261 | |
| 262 if (!CheckPromoEligibility()) { | |
| 263 VLOG(1) << "CrossDevicePromo::UpdateLastActiveTime; Ineligible for promo."; | |
| 264 if (IsPromoActive()) | |
| 265 MarkPromoInactive(); | |
| 266 return; | |
| 267 } | |
| 268 | |
| 269 // Check if there is a record of recent browsing activity on another device. | |
| 270 // If there is none, we set a timer to update the records after a small delay | |
| 271 // to ensure server-side data is synchronized. | |
| 272 base::Time device_last_active = base::Time::FromInternalValue( | |
| 273 prefs_->GetInt64(prefs::kCrossDevicePromoLastDeviceActiveTime)); | |
| 274 if (base::Time::Now() - device_last_active < context_switch_duration_) { | |
| 275 VLOG(1) << "CrossDevicePromo::UpdateLastActiveTime; promo active."; | |
| 276 signin_metrics::LogXDevicePromoEligible(signin_metrics::ELIGIBLE); | |
| 277 MarkPromoActive(); | |
| 278 return; | |
| 279 } | |
| 280 | |
| 281 // Check for recency of device activity unless a check is already being | |
| 282 // executed because the number of devices is being updated. | |
| 283 if (!device_activity_fetcher_.get()) { | |
| 284 VLOG(1) << "CrossDevicePromo::UpdateLastActiveTime; Check device activity."; | |
| 285 device_activity_timer_.Start( | |
| 286 FROM_HERE, | |
| 287 base::TimeDelta::FromMilliseconds(kDelayUntilGettingDeviceActivityInMS), | |
| 288 this, | |
| 289 &CrossDevicePromo::GetDevicesActivityForAccountInCookie); | |
| 290 } | |
| 291 } | |
| 292 | |
| 293 void CrossDevicePromo::GetDevicesActivityForAccountInCookie() { | |
| 294 // Don't start a fetch while one is processing. | |
| 295 if (device_activity_fetcher_.get()) | |
| 296 return; | |
| 297 | |
| 298 if (rpc_throttle_ && base::RandGenerator(100) < rpc_throttle_) { | |
|
Roger Tawa OOO till Jul 10th
2015/05/15 16:01:40
Should be calculated each time or only once per ch
Mike Lerman
2015/05/15 20:47:34
Once per Chrome/per client makes sense, that way w
| |
| 299 if (track_metrics_) { | |
| 300 signin_metrics::LogXDevicePromoEligible( | |
| 301 signin_metrics::THROTTLED_FETCHING_DEVICE_ACTIVITY); | |
| 302 } | |
| 303 return; | |
| 304 } | |
| 305 | |
| 306 VLOG(1) << "CrossDevicePromo::GetDevicesActivityForAccountInCookie. Start."; | |
| 307 DCHECK(VerifyPromoEligibleReadOnly()); | |
| 308 device_activity_fetcher_.reset( | |
| 309 new DeviceActivityFetcher(signin_client_, this)); | |
| 310 device_activity_fetcher_->Start(); | |
| 311 } | |
| 312 | |
| 313 void CrossDevicePromo::OnFetchDeviceActivitySuccess( | |
| 314 const std::vector<DeviceActivityFetcher::DeviceActivity>& devices) { | |
| 315 VLOG(1) << "CrossDevicePromo::OnFetchDeviceActivitySuccess. " | |
| 316 << devices.size() << " devices."; | |
| 317 prefs_->SetInt64(prefs::kCrossDevicePromoNextFetchListDevicesTime, | |
| 318 (base::Time::Now() + | |
| 319 delay_until_next_list_devices_).ToInternalValue()); | |
| 320 prefs_->SetInteger(prefs::kCrossDevicePromoNumDevices, devices.size()); | |
| 321 | |
| 322 if (devices.empty()) { | |
| 323 if (track_metrics_) | |
| 324 signin_metrics::LogXDevicePromoEligible(signin_metrics::ZERO_DEVICES); | |
| 325 return; | |
| 326 } | |
| 327 | |
| 328 base::Time most_recent_last_active = base::Time(); | |
| 329 for (size_t i = 0; i < devices.size(); i++) { | |
| 330 if (devices[i].last_active > most_recent_last_active) | |
| 331 most_recent_last_active = devices[i].last_active; | |
| 332 } | |
| 333 | |
| 334 prefs_->SetInt64(prefs::kCrossDevicePromoLastDeviceActiveTime, | |
| 335 most_recent_last_active.ToInternalValue()); | |
|
Roger Tawa OOO till Jul 10th
2015/05/15 16:01:40
nit: extra space.
Mike Lerman
2015/05/15 20:47:34
Done.
| |
| 336 | |
| 337 if (base::Time::Now() - most_recent_last_active < context_switch_duration_) { | |
| 338 // Make sure eligibility wasn't lost while executing the remote call. | |
| 339 if (!VerifyPromoEligibleReadOnly()) | |
| 340 return; | |
| 341 | |
| 342 // The context switch will only be valid for so long. Schedule another | |
| 343 // DeviceActivity check for when our switch would expire to check for more | |
| 344 // recent activity. | |
| 345 if (!device_activity_timer_.IsRunning()) { | |
| 346 base::TimeDelta time_to_next_check = most_recent_last_active + | |
| 347 context_switch_duration_ - base::Time::Now(); | |
| 348 device_activity_timer_.Start( | |
| 349 FROM_HERE, | |
| 350 time_to_next_check, | |
| 351 this, | |
| 352 &CrossDevicePromo::GetDevicesActivityForAccountInCookie); | |
| 353 } | |
| 354 | |
| 355 if (track_metrics_) | |
| 356 signin_metrics::LogXDevicePromoEligible(signin_metrics::ELIGIBLE); | |
|
Roger Tawa OOO till Jul 10th
2015/05/15 16:01:40
It seems that not all calls to uma logs are protec
Mike Lerman
2015/05/15 20:47:34
Ah, I don't need track_metrics_ anymore! It's neve
| |
| 357 MarkPromoActive(); | |
| 358 } else { | |
| 359 if (track_metrics_) { | |
| 360 signin_metrics::LogXDevicePromoEligible( | |
| 361 signin_metrics::NO_ACTIVE_DEVICES); | |
| 362 } | |
| 363 MarkPromoInactive(); | |
| 364 } | |
| 365 track_metrics_ = true; | |
| 366 device_activity_fetcher_.reset(); | |
| 367 } | |
| 368 | |
| 369 void CrossDevicePromo::OnFetchDeviceActivityFailure() { | |
| 370 VLOG(1) << "CrossDevicePromo::OnFetchDeviceActivityFailure."; | |
| 371 if (track_metrics_) { | |
| 372 signin_metrics::LogXDevicePromoEligible( | |
| 373 signin_metrics::ERROR_FETCHING_DEVICE_ACTIVITY); | |
| 374 } | |
| 375 device_activity_fetcher_.reset(); | |
| 376 } | |
| 377 | |
| 378 void CrossDevicePromo::RegisterForCookieChanges() { | |
| 379 cookie_manager_service_->AddObserver(this); | |
| 380 } | |
| 381 | |
| 382 void CrossDevicePromo::UnregisterForCookieChanges() { | |
| 383 cookie_manager_service_->RemoveObserver(this); | |
| 384 } | |
| 385 | |
| 386 void CrossDevicePromo::OnGaiaAccountsInCookieUpdated( | |
| 387 const std::vector<std::pair<std::string, bool> >& accounts, | |
| 388 const GoogleServiceAuthError& error) { | |
|
Roger Tawa OOO till Jul 10th
2015/05/15 16:01:40
Too many spaces?
Mike Lerman
2015/05/15 20:47:34
Done.
| |
| 389 VLOG(1) << "CrossDevicePromo::OnGaiaAccountsInCookieUpdated. " | |
| 390 << accounts.size() << " accounts with auth error " << error.state(); | |
| 391 if (error.state() != GoogleServiceAuthError::State::NONE) | |
| 392 return; | |
| 393 | |
| 394 // Multiple accounts seen - clear the pref. | |
| 395 if (accounts.size() != 1 && prefs_->HasPrefPath( | |
| 396 prefs::kCrossDevicePromoObservedSingleAccountCookie)) { | |
| 397 prefs_->ClearPref(prefs::kCrossDevicePromoObservedSingleAccountCookie); | |
| 398 MarkPromoInactive(); | |
| 399 // Single account seen. Note if this is the first time we've seen this. | |
| 400 } else if (accounts.size() == 1 && !prefs_->HasPrefPath( | |
| 401 prefs::kCrossDevicePromoObservedSingleAccountCookie)){ | |
|
Roger Tawa OOO till Jul 10th
2015/05/15 16:01:40
nit: space before {
Mike Lerman
2015/05/15 20:47:34
Done.
| |
| 402 prefs_->SetInt64(prefs::kCrossDevicePromoObservedSingleAccountCookie, | |
| 403 base::Time::Now().ToInternalValue()); | |
| 404 } | |
| 405 } | |
| 406 | |
| 407 void CrossDevicePromo::MarkPromoActive() { | |
| 408 VLOG(1) << "CrossDevicePromo::MarkPromoActive."; | |
| 409 DCHECK(!prefs_->GetBoolean(prefs::kCrossDevicePromoOptedOut)); | |
| 410 | |
| 411 if (!prefs_->GetBoolean(prefs::kCrossDevicePromoActive)) { | |
| 412 prefs_->SetBoolean(prefs::kCrossDevicePromoActive, true); | |
| 413 FOR_EACH_OBSERVER(CrossDevicePromo::Observer, | |
| 414 observer_list_, | |
| 415 OnPromoActivationChanged(true)); | |
| 416 } | |
| 417 } | |
| 418 | |
| 419 void CrossDevicePromo::MarkPromoInactive() { | |
| 420 VLOG(1) << "CrossDevicePromo::MarkPromoInactive."; | |
| 421 if (prefs_->GetBoolean(prefs::kCrossDevicePromoActive)) { | |
| 422 prefs_->SetBoolean(prefs::kCrossDevicePromoActive, false); | |
| 423 FOR_EACH_OBSERVER(CrossDevicePromo::Observer, | |
| 424 observer_list_, | |
| 425 OnPromoActivationChanged(false)); | |
| 426 } | |
| 427 } | |
| OLD | NEW |