OLD | NEW |
| (Empty) |
1 // Copyright (c) 2011 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/sync/glue/autofill_profile_model_associator.h" | |
6 | |
7 #include "base/tracked.h" | |
8 #include "base/utf_string_conversions.h" | |
9 #include "chrome/browser/sync/api/sync_error.h" | |
10 #include "chrome/browser/sync/glue/autofill_profile_change_processor.h" | |
11 #include "chrome/browser/sync/glue/do_optimistic_refresh_task.h" | |
12 #include "chrome/browser/sync/internal_api/read_node.h" | |
13 #include "chrome/browser/sync/internal_api/read_transaction.h" | |
14 #include "chrome/browser/sync/internal_api/write_node.h" | |
15 #include "chrome/browser/sync/internal_api/write_transaction.h" | |
16 #include "chrome/browser/sync/profile_sync_service.h" | |
17 #include "chrome/browser/webdata/autofill_table.h" | |
18 #include "chrome/browser/webdata/web_database.h" | |
19 #include "chrome/common/guid.h" | |
20 | |
21 using sync_api::ReadNode; | |
22 namespace browser_sync { | |
23 | |
24 const char kAutofillProfileTag[] = "google_chrome_autofill_profiles"; | |
25 | |
26 AutofillProfileModelAssociator::AutofillProfileModelAssociator( | |
27 ProfileSyncService* sync_service, | |
28 WebDatabase* web_database, | |
29 PersonalDataManager* personal_data) | |
30 : sync_service_(sync_service), | |
31 web_database_(web_database), | |
32 personal_data_(personal_data), | |
33 autofill_node_id_(sync_api::kInvalidId), | |
34 abort_association_pending_(false), | |
35 number_of_profiles_created_(0) { | |
36 DCHECK(BrowserThread::CurrentlyOn(BrowserThread::DB)); | |
37 DCHECK(sync_service_); | |
38 DCHECK(web_database_); | |
39 DCHECK(personal_data_); | |
40 } | |
41 | |
42 AutofillProfileModelAssociator::~AutofillProfileModelAssociator() { | |
43 DCHECK(BrowserThread::CurrentlyOn(BrowserThread::DB)); | |
44 } | |
45 | |
46 AutofillProfileModelAssociator::AutofillProfileModelAssociator() | |
47 : sync_service_(NULL), | |
48 web_database_(NULL), | |
49 personal_data_(NULL), | |
50 autofill_node_id_(0), | |
51 abort_association_pending_(false), | |
52 number_of_profiles_created_(0) { | |
53 } | |
54 | |
55 bool AutofillProfileModelAssociator::TraverseAndAssociateChromeAutofillProfiles( | |
56 sync_api::WriteTransaction* write_trans, | |
57 const sync_api::ReadNode& autofill_root, | |
58 const std::vector<AutofillProfile*>& all_profiles_from_db, | |
59 std::set<std::string>* current_profiles, | |
60 std::vector<AutofillProfile*>* updated_profiles, | |
61 std::vector<AutofillProfile*>* new_profiles, | |
62 std::vector<std::string>* profiles_to_delete) { | |
63 | |
64 if (VLOG_IS_ON(2)) { | |
65 VLOG(2) << "[AUTOFILL MIGRATION]" | |
66 << "Printing profiles from web db"; | |
67 | |
68 for (std::vector<AutofillProfile*>::const_iterator ix = | |
69 all_profiles_from_db.begin(); ix != all_profiles_from_db.end(); ++ix) { | |
70 AutofillProfile* p = *ix; | |
71 VLOG(2) << "[AUTOFILL MIGRATION] " | |
72 << p->GetInfo(NAME_FIRST) | |
73 << p->GetInfo(NAME_LAST) | |
74 << p->guid(); | |
75 } | |
76 } | |
77 | |
78 VLOG(1) << "[AUTOFILL MIGRATION]" | |
79 << "Looking for the above data in sync db.."; | |
80 | |
81 // Alias the all_profiles_from_db so we fit in 80 characters | |
82 const std::vector<AutofillProfile*>& profiles(all_profiles_from_db); | |
83 for (std::vector<AutofillProfile*>::const_iterator ix = profiles.begin(); | |
84 ix != profiles.end(); | |
85 ++ix) { | |
86 std::string guid((*ix)->guid()); | |
87 if (guid::IsValidGUID(guid) == false) { | |
88 DCHECK(false) << "Guid in the web db is invalid " << guid; | |
89 continue; | |
90 } | |
91 | |
92 ReadNode node(write_trans); | |
93 if (node.InitByClientTagLookup(syncable::AUTOFILL_PROFILE, guid) && | |
94 // The following check is to ensure the given sync node is not already | |
95 // associated with another profile. That could happen if the user has | |
96 // the same profile duplicated. | |
97 current_profiles->find(guid) == current_profiles->end()) { | |
98 VLOG(2) << "[AUTOFILL MIGRATION]" | |
99 << " Found in sync db: " | |
100 << (*ix)->GetInfo(NAME_FIRST) | |
101 << (*ix)->GetInfo(NAME_LAST) | |
102 << (*ix)->guid() | |
103 << " so associating with node id " << node.GetId(); | |
104 const sync_pb::AutofillProfileSpecifics& autofill( | |
105 node.GetAutofillProfileSpecifics()); | |
106 if (OverwriteProfileWithServerData(*ix, autofill)) { | |
107 updated_profiles->push_back(*ix); | |
108 } | |
109 Associate(&guid, node.GetId()); | |
110 current_profiles->insert(guid); | |
111 } else { | |
112 MakeNewAutofillProfileSyncNodeIfNeeded(write_trans, | |
113 autofill_root, | |
114 (**ix), | |
115 new_profiles, | |
116 current_profiles, | |
117 profiles_to_delete); | |
118 } | |
119 } | |
120 return true; | |
121 } | |
122 | |
123 bool AutofillProfileModelAssociator::GetSyncIdForTaggedNode( | |
124 const std::string& tag, | |
125 int64* sync_id) { | |
126 sync_api::ReadTransaction trans(FROM_HERE, sync_service_->GetUserShare()); | |
127 sync_api::ReadNode sync_node(&trans); | |
128 if (!sync_node.InitByTagLookup(tag.c_str())) | |
129 return false; | |
130 *sync_id = sync_node.GetId(); | |
131 return true; | |
132 } | |
133 | |
134 bool AutofillProfileModelAssociator::LoadAutofillData( | |
135 std::vector<AutofillProfile*>* profiles) { | |
136 if (IsAbortPending()) | |
137 return false; | |
138 | |
139 if (!web_database_->GetAutofillTable()->GetAutofillProfiles(profiles)) | |
140 return false; | |
141 | |
142 return true; | |
143 } | |
144 | |
145 bool AutofillProfileModelAssociator::AssociateModels(SyncError* error) { | |
146 VLOG(1) << "Associating Autofill Models"; | |
147 DCHECK(BrowserThread::CurrentlyOn(BrowserThread::DB)); | |
148 { | |
149 base::AutoLock lock(abort_association_pending_lock_); | |
150 abort_association_pending_ = false; | |
151 } | |
152 | |
153 ScopedVector<AutofillProfile> profiles; | |
154 | |
155 if (!LoadAutofillData(&profiles.get())) { | |
156 error->Reset(FROM_HERE, | |
157 "Could not get the autofill data from WebDatabase.", | |
158 model_type()); | |
159 return false; | |
160 } | |
161 | |
162 VLOG(1) << "[AUTOFILL MIGRATION]" | |
163 << " Now associating to the new autofill profile model associator" | |
164 << " root node"; | |
165 DataBundle bundle; | |
166 { | |
167 // The write transaction lock is held inside this block. | |
168 // We do all the web db operations outside this block. | |
169 sync_api::WriteTransaction trans(FROM_HERE, sync_service_->GetUserShare()); | |
170 | |
171 sync_api::ReadNode autofill_root(&trans); | |
172 if (!autofill_root.InitByTagLookup(kAutofillProfileTag)) { | |
173 error->Reset(FROM_HERE, | |
174 "Server did not create the top-level autofill node. We " | |
175 "might be running against an out-of-date server.", | |
176 model_type()); | |
177 return false; | |
178 } | |
179 | |
180 if (!TraverseAndAssociateChromeAutofillProfiles(&trans, autofill_root, | |
181 profiles.get(), &bundle.current_profiles, | |
182 &bundle.updated_profiles, | |
183 &bundle.new_profiles, | |
184 &bundle.profiles_to_delete) || | |
185 !TraverseAndAssociateAllSyncNodes(&trans, autofill_root, &bundle)) { | |
186 error->Reset(FROM_HERE, | |
187 "Failed to associate all sync nodes.", | |
188 model_type()); | |
189 return false; | |
190 } | |
191 } | |
192 | |
193 if (!SaveChangesToWebData(bundle)) { | |
194 error->Reset(FROM_HERE, "Failed to update webdata.", model_type()); | |
195 return false; | |
196 } | |
197 | |
198 BrowserThread::PostTask(BrowserThread::UI, FROM_HERE, | |
199 new DoOptimisticRefreshForAutofill(personal_data_)); | |
200 return true; | |
201 } | |
202 | |
203 bool AutofillProfileModelAssociator::DisassociateModels(SyncError* error) { | |
204 DCHECK(BrowserThread::CurrentlyOn(BrowserThread::DB)); | |
205 id_map_.clear(); | |
206 id_map_inverse_.clear(); | |
207 return true; | |
208 } | |
209 | |
210 // Helper to compare the local value and cloud value of a field, merge into | |
211 // the local value if they differ, and return whether the merge happened. | |
212 bool AutofillProfileModelAssociator::MergeField(FormGroup* f, | |
213 AutofillFieldType t, | |
214 const std::string& specifics_field) { | |
215 if (UTF16ToUTF8(f->GetInfo(t)) == specifics_field) | |
216 return false; | |
217 f->SetInfo(t, UTF8ToUTF16(specifics_field)); | |
218 return true; | |
219 } | |
220 bool AutofillProfileModelAssociator::SyncModelHasUserCreatedNodes( | |
221 bool *has_nodes) { | |
222 CHECK_NE(has_nodes, reinterpret_cast<bool*>(NULL)); | |
223 sync_api::ReadTransaction trans(FROM_HERE, sync_service_->GetUserShare()); | |
224 | |
225 sync_api::ReadNode node(&trans); | |
226 | |
227 if (!node.InitByTagLookup(kAutofillProfileTag)) { | |
228 LOG(ERROR) << "Sever did not create a top level node" | |
229 << "Out of data server or autofill type not enabled"; | |
230 return false; | |
231 } | |
232 | |
233 *has_nodes = sync_api::kInvalidId != node.GetFirstChildId(); | |
234 return true; | |
235 } | |
236 // static | |
237 bool AutofillProfileModelAssociator::OverwriteProfileWithServerData( | |
238 AutofillProfile* merge_into, | |
239 const sync_pb::AutofillProfileSpecifics& specifics) { | |
240 bool diff = false; | |
241 AutofillProfile* p = merge_into; | |
242 const sync_pb::AutofillProfileSpecifics& s(specifics); | |
243 diff = MergeField(p, NAME_FIRST, s.name_first()) || diff; | |
244 diff = MergeField(p, NAME_LAST, s.name_last()) || diff; | |
245 diff = MergeField(p, NAME_MIDDLE, s.name_middle()) || diff; | |
246 diff = MergeField(p, ADDRESS_HOME_LINE1, s.address_home_line1()) || diff; | |
247 diff = MergeField(p, ADDRESS_HOME_LINE2, s.address_home_line2()) || diff; | |
248 diff = MergeField(p, ADDRESS_HOME_CITY, s.address_home_city()) || diff; | |
249 diff = MergeField(p, ADDRESS_HOME_STATE, s.address_home_state()) || diff; | |
250 diff = MergeField(p, ADDRESS_HOME_COUNTRY, s.address_home_country()) || diff; | |
251 diff = MergeField(p, ADDRESS_HOME_ZIP, s.address_home_zip()) || diff; | |
252 diff = MergeField(p, EMAIL_ADDRESS, s.email_address()) || diff; | |
253 diff = MergeField(p, COMPANY_NAME, s.company_name()) || diff; | |
254 diff = MergeField(p, PHONE_FAX_WHOLE_NUMBER, s.phone_fax_whole_number()) | |
255 || diff; | |
256 diff = MergeField(p, PHONE_HOME_WHOLE_NUMBER, s.phone_home_whole_number()) | |
257 || diff; | |
258 return diff; | |
259 } | |
260 | |
261 | |
262 int64 AutofillProfileModelAssociator::FindSyncNodeWithProfile( | |
263 sync_api::WriteTransaction* trans, | |
264 const sync_api::BaseNode& autofill_root, | |
265 const AutofillProfile& profile_from_db, | |
266 std::set<std::string>* current_profiles) { | |
267 int64 sync_child_id = autofill_root.GetFirstChildId(); | |
268 while (sync_child_id != sync_api::kInvalidId) { | |
269 ReadNode read_node(trans); | |
270 AutofillProfile p; | |
271 if (!read_node.InitByIdLookup(sync_child_id)) { | |
272 LOG(ERROR) << "unable to find the id given by getfirst child " << | |
273 sync_child_id; | |
274 return sync_api::kInvalidId; | |
275 } | |
276 const sync_pb::AutofillProfileSpecifics& autofill_specifics( | |
277 read_node.GetAutofillProfileSpecifics()); | |
278 | |
279 // This find should be fast as the set uses tree. | |
280 if (current_profiles->find(autofill_specifics.guid()) | |
281 == current_profiles->end()) { | |
282 OverwriteProfileWithServerData(&p, autofill_specifics); | |
283 if (p.Compare(profile_from_db) == 0) { | |
284 return sync_child_id; | |
285 } | |
286 } | |
287 sync_child_id = read_node.GetSuccessorId(); | |
288 } | |
289 | |
290 return sync_api::kInvalidId; | |
291 } | |
292 bool AutofillProfileModelAssociator::MakeNewAutofillProfileSyncNodeIfNeeded( | |
293 sync_api::WriteTransaction* trans, | |
294 const sync_api::BaseNode& autofill_root, | |
295 const AutofillProfile& profile, | |
296 std::vector<AutofillProfile*>* new_profiles, | |
297 std::set<std::string>* current_profiles, | |
298 std::vector<std::string>* profiles_to_delete) { | |
299 | |
300 int64 sync_node_id = FindSyncNodeWithProfile(trans, | |
301 autofill_root, | |
302 profile, | |
303 current_profiles); | |
304 if (sync_node_id != sync_api::kInvalidId) { | |
305 // In case of duplicates throw away the local profile and apply the | |
306 // server profile.(The only difference between the 2 profiles are the guids) | |
307 profiles_to_delete->push_back(profile.guid()); | |
308 sync_api::ReadNode read_node(trans); | |
309 if (!read_node.InitByIdLookup(sync_node_id)) { | |
310 LOG(ERROR); | |
311 return false; | |
312 } | |
313 const sync_pb::AutofillProfileSpecifics& autofill_specifics( | |
314 read_node.GetAutofillProfileSpecifics()); | |
315 if (guid::IsValidGUID(autofill_specifics.guid()) == false) { | |
316 NOTREACHED() << "Guid in the web db is invalid " << | |
317 autofill_specifics.guid(); | |
318 return false; | |
319 } | |
320 AutofillProfile* p = new AutofillProfile(autofill_specifics.guid()); | |
321 OverwriteProfileWithServerData(p, autofill_specifics); | |
322 new_profiles->push_back(p); | |
323 std::string guid = autofill_specifics.guid(); | |
324 Associate(&guid, sync_node_id); | |
325 current_profiles->insert(autofill_specifics.guid()); | |
326 VLOG(2) << "[AUTOFILL MIGRATION]" | |
327 << "Found in sync db but with a different guid: " | |
328 << UTF16ToUTF8(profile.GetInfo(NAME_FIRST)) | |
329 << UTF16ToUTF8(profile.GetInfo(NAME_LAST)) | |
330 << "New guid " << autofill_specifics.guid() << " sync node id " | |
331 << sync_node_id << " so associating. Profile to be deleted " | |
332 << profile.guid(); | |
333 } else { | |
334 sync_api::WriteNode node(trans); | |
335 | |
336 // The profile.guid() is expected to be a valid guid. The caller is expected | |
337 // to pass in a valid profile object with a valid guid. Having to check in | |
338 // 2 places(the caller and here) is not optimal. | |
339 if (!node.InitUniqueByCreation( | |
340 syncable::AUTOFILL_PROFILE, autofill_root, profile.guid())) { | |
341 LOG(ERROR) << "Failed to create autofill sync node."; | |
342 return false; | |
343 } | |
344 node.SetTitle(UTF8ToWide(profile.guid())); | |
345 VLOG(2) << "[AUTOFILL MIGRATION]" | |
346 << "NOT Found in sync db " | |
347 << UTF16ToUTF8(profile.GetInfo(NAME_FIRST)) | |
348 << UTF16ToUTF8(profile.GetInfo(NAME_LAST)) | |
349 << profile.guid() | |
350 << " so creating a new sync node. Sync node id " | |
351 << node.GetId(); | |
352 AutofillProfileChangeProcessor::WriteAutofillProfile(profile, &node); | |
353 current_profiles->insert(profile.guid()); | |
354 std::string guid = profile.guid(); | |
355 Associate(&guid, node.GetId()); | |
356 number_of_profiles_created_++; | |
357 } | |
358 return true; | |
359 } | |
360 | |
361 bool AutofillProfileModelAssociator::TraverseAndAssociateAllSyncNodes( | |
362 sync_api::WriteTransaction* write_trans, | |
363 const sync_api::ReadNode& autofill_root, | |
364 DataBundle* bundle) { | |
365 DCHECK(BrowserThread::CurrentlyOn(BrowserThread::DB)); | |
366 VLOG(1) << "[AUTOFILL MIGRATION] " | |
367 << " Iterating over sync nodes of autofill profile root node"; | |
368 | |
369 int64 sync_child_id = autofill_root.GetFirstChildId(); | |
370 while (sync_child_id != sync_api::kInvalidId) { | |
371 ReadNode sync_child(write_trans); | |
372 if (!sync_child.InitByIdLookup(sync_child_id)) { | |
373 LOG(ERROR) << "Failed to fetch child node."; | |
374 return false; | |
375 } | |
376 const sync_pb::AutofillProfileSpecifics& autofill( | |
377 sync_child.GetAutofillProfileSpecifics()); | |
378 | |
379 AddNativeProfileIfNeeded(autofill, bundle, sync_child); | |
380 | |
381 sync_child_id = sync_child.GetSuccessorId(); | |
382 } | |
383 return true; | |
384 } | |
385 | |
386 void AutofillProfileModelAssociator::AddNativeProfileIfNeeded( | |
387 const sync_pb::AutofillProfileSpecifics& profile, | |
388 DataBundle* bundle, | |
389 const sync_api::ReadNode& node) { | |
390 DCHECK(BrowserThread::CurrentlyOn(BrowserThread::DB)); | |
391 | |
392 VLOG(2) << "[AUTOFILL MIGRATION] " | |
393 << "Trying to lookup " | |
394 << profile.name_first() | |
395 << " " | |
396 << profile.name_last() | |
397 << "sync node id " << node.GetId() | |
398 << " Guid " << profile.guid() | |
399 << " in the web db"; | |
400 | |
401 if (guid::IsValidGUID(profile.guid()) == false) { | |
402 DCHECK(false) << "Guid in the sync db is invalid " << profile.guid(); | |
403 return; | |
404 } | |
405 | |
406 if (bundle->current_profiles.find(profile.guid()) == | |
407 bundle->current_profiles.end()) { | |
408 std::string guid(profile.guid()); | |
409 Associate(&guid, node.GetId()); | |
410 AutofillProfile* p = new AutofillProfile(profile.guid()); | |
411 OverwriteProfileWithServerData(p, profile); | |
412 bundle->new_profiles.push_back(p); | |
413 VLOG(2) << "[AUTOFILL MIGRATION] " | |
414 << " Did not find one so creating it on web db"; | |
415 } else { | |
416 VLOG(2) << "[AUTOFILL MIGRATION] " | |
417 << " Found it on web db. Moving on "; | |
418 } | |
419 } | |
420 | |
421 bool AutofillProfileModelAssociator::SaveChangesToWebData( | |
422 const DataBundle& bundle) { | |
423 DCHECK(BrowserThread::CurrentlyOn(BrowserThread::DB)); | |
424 | |
425 if (IsAbortPending()) | |
426 return false; | |
427 | |
428 for (size_t i = 0; i < bundle.new_profiles.size(); i++) { | |
429 if (IsAbortPending()) | |
430 return false; | |
431 if (!web_database_->GetAutofillTable()->AddAutofillProfile( | |
432 *bundle.new_profiles[i])) | |
433 return false; | |
434 } | |
435 | |
436 for (size_t i = 0; i < bundle.updated_profiles.size(); i++) { | |
437 if (IsAbortPending()) | |
438 return false; | |
439 if (!web_database_->GetAutofillTable()->UpdateAutofillProfile( | |
440 *bundle.updated_profiles[i])) | |
441 return false; | |
442 } | |
443 | |
444 for (size_t i = 0; i< bundle.profiles_to_delete.size(); ++i) { | |
445 if (IsAbortPending()) | |
446 return false; | |
447 if (!web_database_->GetAutofillTable()->RemoveAutofillProfile( | |
448 bundle.profiles_to_delete[i])) | |
449 return false; | |
450 } | |
451 return true; | |
452 } | |
453 | |
454 bool AutofillProfileModelAssociator::InitSyncNodeFromChromeId( | |
455 const std::string& node_id, | |
456 sync_api::BaseNode* sync_node) { | |
457 return false; | |
458 } | |
459 | |
460 void AutofillProfileModelAssociator::Associate( | |
461 const std::string* autofill, | |
462 int64 sync_id) { | |
463 DCHECK(BrowserThread::CurrentlyOn(BrowserThread::DB)); | |
464 DCHECK_NE(sync_api::kInvalidId, sync_id); | |
465 DCHECK(id_map_.find(*autofill) == id_map_.end()); | |
466 DCHECK(id_map_inverse_.find(sync_id) == id_map_inverse_.end()); | |
467 id_map_[*autofill] = sync_id; | |
468 id_map_inverse_[sync_id] = *autofill; | |
469 } | |
470 | |
471 void AutofillProfileModelAssociator::Disassociate(int64 sync_id) { | |
472 DCHECK(BrowserThread::CurrentlyOn(BrowserThread::DB)); | |
473 SyncIdToAutofillMap::iterator iter = id_map_inverse_.find(sync_id); | |
474 if (iter == id_map_inverse_.end()) | |
475 return; | |
476 CHECK(id_map_.erase(iter->second)); | |
477 id_map_inverse_.erase(iter); | |
478 } | |
479 | |
480 int64 AutofillProfileModelAssociator::GetSyncIdFromChromeId( | |
481 const std::string& autofill) { | |
482 AutofillToSyncIdMap::const_iterator iter = id_map_.find(autofill); | |
483 return iter == id_map_.end() ? sync_api::kInvalidId : iter->second; | |
484 } | |
485 | |
486 void AutofillProfileModelAssociator::AbortAssociation() { | |
487 DCHECK(BrowserThread::CurrentlyOn(BrowserThread::UI)); | |
488 base::AutoLock lock(abort_association_pending_lock_); | |
489 abort_association_pending_ = true; | |
490 } | |
491 | |
492 const std::string* AutofillProfileModelAssociator::GetChromeNodeFromSyncId( | |
493 int64 sync_id) { | |
494 SyncIdToAutofillMap::const_iterator iter = id_map_inverse_.find(sync_id); | |
495 return iter == id_map_inverse_.end() ? NULL : &(iter->second); | |
496 } | |
497 | |
498 bool AutofillProfileModelAssociator::IsAbortPending() { | |
499 base::AutoLock lock(abort_association_pending_lock_); | |
500 return abort_association_pending_; | |
501 } | |
502 | |
503 AutofillProfileModelAssociator::DataBundle::DataBundle() {} | |
504 | |
505 AutofillProfileModelAssociator::DataBundle::~DataBundle() { | |
506 STLDeleteElements(&new_profiles); | |
507 } | |
508 | |
509 bool AutofillProfileModelAssociator::CryptoReadyIfNecessary() { | |
510 // We only access the cryptographer while holding a transaction. | |
511 sync_api::ReadTransaction trans(FROM_HERE, sync_service_->GetUserShare()); | |
512 syncable::ModelTypeSet encrypted_types; | |
513 encrypted_types = sync_api::GetEncryptedTypes(&trans); | |
514 return encrypted_types.count(syncable::AUTOFILL_PROFILE) == 0 || | |
515 sync_service_->IsCryptographerReady(&trans); | |
516 } | |
517 | |
518 } // namespace browser_sync | |
OLD | NEW |