OLD | NEW |
1 # Copyright (c) 2012 The Chromium Authors. All rights reserved. | 1 # Copyright (c) 2012 The Chromium Authors. All rights reserved. |
2 # Use of this source code is governed by a BSD-style license that can be | 2 # Use of this source code is governed by a BSD-style license that can be |
3 # found in the LICENSE file. | 3 # found in the LICENSE file. |
4 | 4 |
5 """An implementation of the server side of the Chromium sync protocol. | 5 """An implementation of the server side of the Chromium sync protocol. |
6 | 6 |
7 The details of the protocol are described mostly by comments in the protocol | 7 The details of the protocol are described mostly by comments in the protocol |
8 buffer definition at chrome/browser/sync/protocol/sync.proto. | 8 buffer definition at chrome/browser/sync/protocol/sync.proto. |
9 """ | 9 """ |
10 | 10 |
(...skipping 19 matching lines...) Expand all Loading... |
30 import preference_specifics_pb2 | 30 import preference_specifics_pb2 |
31 import search_engine_specifics_pb2 | 31 import search_engine_specifics_pb2 |
32 import session_specifics_pb2 | 32 import session_specifics_pb2 |
33 import sync_pb2 | 33 import sync_pb2 |
34 import sync_enums_pb2 | 34 import sync_enums_pb2 |
35 import theme_specifics_pb2 | 35 import theme_specifics_pb2 |
36 import typed_url_specifics_pb2 | 36 import typed_url_specifics_pb2 |
37 | 37 |
38 # An enumeration of the various kinds of data that can be synced. | 38 # An enumeration of the various kinds of data that can be synced. |
39 # Over the wire, this enumeration is not used: a sync object's type is | 39 # Over the wire, this enumeration is not used: a sync object's type is |
40 # inferred by which EntitySpecifics extension it has. But in the context | 40 # inferred by which EntitySpecifics field it has. But in the context |
41 # of a program, it is useful to have an enumeration. | 41 # of a program, it is useful to have an enumeration. |
42 ALL_TYPES = ( | 42 ALL_TYPES = ( |
43 TOP_LEVEL, # The type of the 'Google Chrome' folder. | 43 TOP_LEVEL, # The type of the 'Google Chrome' folder. |
44 APPS, | 44 APPS, |
45 APP_NOTIFICATION, | 45 APP_NOTIFICATION, |
46 APP_SETTINGS, | 46 APP_SETTINGS, |
47 AUTOFILL, | 47 AUTOFILL, |
48 AUTOFILL_PROFILE, | 48 AUTOFILL_PROFILE, |
49 BOOKMARK, | 49 BOOKMARK, |
50 EXTENSIONS, | 50 EXTENSIONS, |
(...skipping 10 matching lines...) Expand all Loading... |
61 # to the client. This would be specified by the url that triggers the error. | 61 # to the client. This would be specified by the url that triggers the error. |
62 # Note: This enum should be kept in the same order as the enum in sync_test.h. | 62 # Note: This enum should be kept in the same order as the enum in sync_test.h. |
63 SYNC_ERROR_FREQUENCY = ( | 63 SYNC_ERROR_FREQUENCY = ( |
64 ERROR_FREQUENCY_NONE, | 64 ERROR_FREQUENCY_NONE, |
65 ERROR_FREQUENCY_ALWAYS, | 65 ERROR_FREQUENCY_ALWAYS, |
66 ERROR_FREQUENCY_TWO_THIRDS) = range(3) | 66 ERROR_FREQUENCY_TWO_THIRDS) = range(3) |
67 | 67 |
68 # Well-known server tag of the top level 'Google Chrome' folder. | 68 # Well-known server tag of the top level 'Google Chrome' folder. |
69 TOP_LEVEL_FOLDER_TAG = 'google_chrome' | 69 TOP_LEVEL_FOLDER_TAG = 'google_chrome' |
70 | 70 |
71 # Given a sync type from ALL_TYPES, find the extension token corresponding | 71 # Given a sync type from ALL_TYPES, find the FieldDescriptor corresponding |
72 # to that datatype. Note that TOP_LEVEL has no such token. | 72 # to that datatype. Note that TOP_LEVEL has no such token. |
73 SYNC_TYPE_TO_EXTENSION = { | 73 SYNC_TYPE_FIELDS = sync_pb2.EntitySpecifics.DESCRIPTOR.fields_by_name |
74 APP_NOTIFICATION: app_notification_specifics_pb2.app_notification, | 74 SYNC_TYPE_TO_DESCRIPTOR = { |
75 APP_SETTINGS: app_setting_specifics_pb2.app_setting, | 75 APP_NOTIFICATION: SYNC_TYPE_FIELDS['app_notification'], |
76 APPS: app_specifics_pb2.app, | 76 APP_SETTINGS: SYNC_TYPE_FIELDS['app_setting'], |
77 AUTOFILL: autofill_specifics_pb2.autofill, | 77 APPS: SYNC_TYPE_FIELDS['app'], |
78 AUTOFILL_PROFILE: autofill_specifics_pb2.autofill_profile, | 78 AUTOFILL: SYNC_TYPE_FIELDS['autofill'], |
79 BOOKMARK: bookmark_specifics_pb2.bookmark, | 79 AUTOFILL_PROFILE: SYNC_TYPE_FIELDS['autofill_profile'], |
80 EXTENSION_SETTINGS: extension_setting_specifics_pb2.extension_setting, | 80 BOOKMARK: SYNC_TYPE_FIELDS['bookmark'], |
81 EXTENSIONS: extension_specifics_pb2.extension, | 81 EXTENSION_SETTINGS: SYNC_TYPE_FIELDS['extension_setting'], |
82 NIGORI: nigori_specifics_pb2.nigori, | 82 EXTENSIONS: SYNC_TYPE_FIELDS['extension'], |
83 PASSWORD: password_specifics_pb2.password, | 83 NIGORI: SYNC_TYPE_FIELDS['nigori'], |
84 PREFERENCE: preference_specifics_pb2.preference, | 84 PASSWORD: SYNC_TYPE_FIELDS['password'], |
85 SEARCH_ENGINE: search_engine_specifics_pb2.search_engine, | 85 PREFERENCE: SYNC_TYPE_FIELDS['preference'], |
86 SESSION: session_specifics_pb2.session, | 86 SEARCH_ENGINE: SYNC_TYPE_FIELDS['search_engine'], |
87 THEME: theme_specifics_pb2.theme, | 87 SESSION: SYNC_TYPE_FIELDS['session'], |
88 TYPED_URL: typed_url_specifics_pb2.typed_url, | 88 THEME: SYNC_TYPE_FIELDS['theme'], |
| 89 TYPED_URL: SYNC_TYPE_FIELDS['typed_url'], |
89 } | 90 } |
90 | 91 |
91 # The parent ID used to indicate a top-level node. | 92 # The parent ID used to indicate a top-level node. |
92 ROOT_ID = '0' | 93 ROOT_ID = '0' |
93 | 94 |
94 # Unix time epoch in struct_time format. The tuple corresponds to UTC Wednesday | 95 # Unix time epoch in struct_time format. The tuple corresponds to UTC Wednesday |
95 # Jan 1 1970, 00:00:00, non-dst. | 96 # Jan 1 1970, 00:00:00, non-dst. |
96 UNIX_TIME_EPOCH = (1970, 1, 1, 0, 0, 0, 3, 1, 0) | 97 UNIX_TIME_EPOCH = (1970, 1, 1, 0, 0, 0, 3, 1, 0) |
97 | 98 |
98 class Error(Exception): | 99 class Error(Exception): |
99 """Error class for this module.""" | 100 """Error class for this module.""" |
100 | 101 |
101 | 102 |
102 class ProtobufExtensionNotUnique(Error): | 103 class ProtobufDataTypeFieldNotUnique(Error): |
103 """An entry should not have more than one protobuf extension present.""" | 104 """An entry should not have more than one data type present.""" |
104 | 105 |
105 | 106 |
106 class DataTypeIdNotRecognized(Error): | 107 class DataTypeIdNotRecognized(Error): |
107 """The requested data type is not recognized.""" | 108 """The requested data type is not recognized.""" |
108 | 109 |
109 | 110 |
110 class MigrationDoneError(Error): | 111 class MigrationDoneError(Error): |
111 """A server-side migration occurred; clients must re-sync some datatypes. | 112 """A server-side migration occurred; clients must re-sync some datatypes. |
112 | 113 |
113 Attributes: | 114 Attributes: |
(...skipping 22 matching lines...) Expand all Loading... |
136 | 137 |
137 def GetEntryType(entry): | 138 def GetEntryType(entry): |
138 """Extract the sync type from a SyncEntry. | 139 """Extract the sync type from a SyncEntry. |
139 | 140 |
140 Args: | 141 Args: |
141 entry: A SyncEntity protobuf object whose type to determine. | 142 entry: A SyncEntity protobuf object whose type to determine. |
142 Returns: | 143 Returns: |
143 An enum value from ALL_TYPES if the entry's type can be determined, or None | 144 An enum value from ALL_TYPES if the entry's type can be determined, or None |
144 if the type cannot be determined. | 145 if the type cannot be determined. |
145 Raises: | 146 Raises: |
146 ProtobufExtensionNotUnique: More than one type was indicated by the entry. | 147 ProtobufDataTypeFieldNotUnique: More than one type was indicated by |
| 148 the entry. |
147 """ | 149 """ |
148 if entry.server_defined_unique_tag == TOP_LEVEL_FOLDER_TAG: | 150 if entry.server_defined_unique_tag == TOP_LEVEL_FOLDER_TAG: |
149 return TOP_LEVEL | 151 return TOP_LEVEL |
150 entry_types = GetEntryTypesFromSpecifics(entry.specifics) | 152 entry_types = GetEntryTypesFromSpecifics(entry.specifics) |
151 if not entry_types: | 153 if not entry_types: |
152 return None | 154 return None |
153 | 155 |
154 # If there is more than one, either there's a bug, or else the caller | 156 # If there is more than one, either there's a bug, or else the caller |
155 # should use GetEntryTypes. | 157 # should use GetEntryTypes. |
156 if len(entry_types) > 1: | 158 if len(entry_types) > 1: |
157 raise ProtobufExtensionNotUnique | 159 raise ProtobufDataTypeFieldNotUnique |
158 return entry_types[0] | 160 return entry_types[0] |
159 | 161 |
160 | 162 |
161 def GetEntryTypesFromSpecifics(specifics): | 163 def GetEntryTypesFromSpecifics(specifics): |
162 """Determine the sync types indicated by an EntitySpecifics's extension(s). | 164 """Determine the sync types indicated by an EntitySpecifics's field(s). |
163 | 165 |
164 If the specifics have more than one recognized extension (as commonly | 166 If the specifics have more than one recognized data type field (as commonly |
165 happens with the requested_types field of GetUpdatesMessage), all types | 167 happens with the requested_types field of GetUpdatesMessage), all types |
166 will be returned. Callers must handle the possibility of the returned | 168 will be returned. Callers must handle the possibility of the returned |
167 value having more than one item. | 169 value having more than one item. |
168 | 170 |
169 Args: | 171 Args: |
170 specifics: A EntitySpecifics protobuf message whose extensions to | 172 specifics: A EntitySpecifics protobuf message whose extensions to |
171 enumerate. | 173 enumerate. |
172 Returns: | 174 Returns: |
173 A list of the sync types (values from ALL_TYPES) associated with each | 175 A list of the sync types (values from ALL_TYPES) associated with each |
174 recognized extension of the specifics message. | 176 recognized extension of the specifics message. |
175 """ | 177 """ |
176 return [data_type for data_type, extension | 178 return [data_type for data_type, field_descriptor |
177 in SYNC_TYPE_TO_EXTENSION.iteritems() | 179 in SYNC_TYPE_TO_DESCRIPTOR.iteritems() |
178 if specifics.HasExtension(extension)] | 180 if specifics.HasField(field_descriptor.name)] |
179 | 181 |
180 | 182 |
181 def SyncTypeToProtocolDataTypeId(data_type): | 183 def SyncTypeToProtocolDataTypeId(data_type): |
182 """Convert from a sync type (python enum) to the protocol's data type id.""" | 184 """Convert from a sync type (python enum) to the protocol's data type id.""" |
183 return SYNC_TYPE_TO_EXTENSION[data_type].number | 185 return SYNC_TYPE_TO_DESCRIPTOR[data_type].number |
184 | 186 |
185 | 187 |
186 def ProtocolDataTypeIdToSyncType(protocol_data_type_id): | 188 def ProtocolDataTypeIdToSyncType(protocol_data_type_id): |
187 """Convert from the protocol's data type id to a sync type (python enum).""" | 189 """Convert from the protocol's data type id to a sync type (python enum).""" |
188 for data_type, protocol_extension in SYNC_TYPE_TO_EXTENSION.iteritems(): | 190 for data_type, field_descriptor in SYNC_TYPE_TO_DESCRIPTOR.iteritems(): |
189 if protocol_extension.number == protocol_data_type_id: | 191 if field_descriptor.number == protocol_data_type_id: |
190 return data_type | 192 return data_type |
191 raise DataTypeIdNotRecognized | 193 raise DataTypeIdNotRecognized |
192 | 194 |
193 | 195 |
194 def DataTypeStringToSyncTypeLoose(data_type_string): | 196 def DataTypeStringToSyncTypeLoose(data_type_string): |
195 """Converts a human-readable string to a sync type (python enum). | 197 """Converts a human-readable string to a sync type (python enum). |
196 | 198 |
197 Capitalization and pluralization don't matter; this function is appropriate | 199 Capitalization and pluralization don't matter; this function is appropriate |
198 for values that might have been typed by a human being; e.g., command-line | 200 for values that might have been typed by a human being; e.g., command-line |
199 flags or query parameters. | 201 flags or query parameters. |
200 """ | 202 """ |
201 if data_type_string.isdigit(): | 203 if data_type_string.isdigit(): |
202 return ProtocolDataTypeIdToSyncType(int(data_type_string)) | 204 return ProtocolDataTypeIdToSyncType(int(data_type_string)) |
203 name = data_type_string.lower().rstrip('s') | 205 name = data_type_string.lower().rstrip('s') |
204 for data_type, protocol_extension in SYNC_TYPE_TO_EXTENSION.iteritems(): | 206 for data_type, field_descriptor in SYNC_TYPE_TO_DESCRIPTOR.iteritems(): |
205 if protocol_extension.name.lower().rstrip('s') == name: | 207 if field_descriptor.name.lower().rstrip('s') == name: |
206 return data_type | 208 return data_type |
207 raise DataTypeIdNotRecognized | 209 raise DataTypeIdNotRecognized |
208 | 210 |
209 | 211 |
210 def SyncTypeToString(data_type): | 212 def SyncTypeToString(data_type): |
211 """Formats a sync type enum (from ALL_TYPES) to a human-readable string.""" | 213 """Formats a sync type enum (from ALL_TYPES) to a human-readable string.""" |
212 return SYNC_TYPE_TO_EXTENSION[data_type].name | 214 return SYNC_TYPE_TO_DESCRIPTOR[data_type].name |
213 | 215 |
214 | 216 |
215 def CallerInfoToString(caller_info_source): | 217 def CallerInfoToString(caller_info_source): |
216 """Formats a GetUpdatesSource enum value to a readable string.""" | 218 """Formats a GetUpdatesSource enum value to a readable string.""" |
217 return sync_pb2.GetUpdatesCallerInfo.DESCRIPTOR.enum_types_by_name[ | 219 return sync_pb2.GetUpdatesCallerInfo.DESCRIPTOR.enum_types_by_name[ |
218 'GetUpdatesSource'].values_by_number[caller_info_source].name | 220 'GetUpdatesSource'].values_by_number[caller_info_source].name |
219 | 221 |
220 | 222 |
221 def ShortDatatypeListSummary(data_types): | 223 def ShortDatatypeListSummary(data_types): |
222 """Formats compactly a list of sync types (python enums) for human eyes. | 224 """Formats compactly a list of sync types (python enums) for human eyes. |
(...skipping 11 matching lines...) Expand all Loading... |
234 simple_text = '+'.join(sorted([SyncTypeToString(x) for x in included])) | 236 simple_text = '+'.join(sorted([SyncTypeToString(x) for x in included])) |
235 all_but_text = 'all except %s' % ( | 237 all_but_text = 'all except %s' % ( |
236 '+'.join(sorted([SyncTypeToString(x) for x in excluded]))) | 238 '+'.join(sorted([SyncTypeToString(x) for x in excluded]))) |
237 if len(included) < len(excluded) or len(simple_text) <= len(all_but_text): | 239 if len(included) < len(excluded) or len(simple_text) <= len(all_but_text): |
238 return simple_text | 240 return simple_text |
239 else: | 241 else: |
240 return all_but_text | 242 return all_but_text |
241 | 243 |
242 | 244 |
243 def GetDefaultEntitySpecifics(data_type): | 245 def GetDefaultEntitySpecifics(data_type): |
244 """Get an EntitySpecifics having a sync type's default extension value.""" | 246 """Get an EntitySpecifics having a sync type's default field value.""" |
245 specifics = sync_pb2.EntitySpecifics() | 247 specifics = sync_pb2.EntitySpecifics() |
246 if data_type in SYNC_TYPE_TO_EXTENSION: | 248 if data_type in SYNC_TYPE_TO_DESCRIPTOR: |
247 extension_handle = SYNC_TYPE_TO_EXTENSION[data_type] | 249 descriptor = SYNC_TYPE_TO_DESCRIPTOR[data_type] |
248 specifics.Extensions[extension_handle].SetInParent() | 250 getattr(specifics, descriptor.name).SetInParent() |
249 return specifics | 251 return specifics |
250 | 252 |
251 | 253 |
252 class PermanentItem(object): | 254 class PermanentItem(object): |
253 """A specification of one server-created permanent item. | 255 """A specification of one server-created permanent item. |
254 | 256 |
255 Attributes: | 257 Attributes: |
256 tag: A known-to-the-client value that uniquely identifies a server-created | 258 tag: A known-to-the-client value that uniquely identifies a server-created |
257 permanent item. | 259 permanent item. |
258 name: The human-readable display name for this item. | 260 name: The human-readable display name for this item. |
(...skipping 214 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
473 entry.sync_timestamp = self._version | 475 entry.sync_timestamp = self._version |
474 | 476 |
475 # Preserve the originator info, which the client is not required to send | 477 # Preserve the originator info, which the client is not required to send |
476 # when updating. | 478 # when updating. |
477 base_entry = self._entries.get(entry.id_string) | 479 base_entry = self._entries.get(entry.id_string) |
478 if base_entry: | 480 if base_entry: |
479 entry.originator_cache_guid = base_entry.originator_cache_guid | 481 entry.originator_cache_guid = base_entry.originator_cache_guid |
480 entry.originator_client_item_id = base_entry.originator_client_item_id | 482 entry.originator_client_item_id = base_entry.originator_client_item_id |
481 | 483 |
482 self._entries[entry.id_string] = copy.deepcopy(entry) | 484 self._entries[entry.id_string] = copy.deepcopy(entry) |
483 # Store the current time since the Unix epoch in milliseconds. | |
484 self._entries[entry.id_string].mtime = (int((time.mktime(time.gmtime()) - | |
485 time.mktime(UNIX_TIME_EPOCH))*1000)) | |
486 | 485 |
487 def _ServerTagToId(self, tag): | 486 def _ServerTagToId(self, tag): |
488 """Determine the server ID from a server-unique tag. | 487 """Determine the server ID from a server-unique tag. |
489 | 488 |
490 The resulting value is guaranteed not to collide with the other ID | 489 The resulting value is guaranteed not to collide with the other ID |
491 generation methods. | 490 generation methods. |
492 | 491 |
493 Args: | 492 Args: |
494 datatype: The sync type (python enum) of the identified object. | 493 datatype: The sync type (python enum) of the identified object. |
495 tag: The unique, known-to-the-client tag of a server-generated item. | 494 tag: The unique, known-to-the-client tag of a server-generated item. |
(...skipping 375 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
871 # we ignore insert_after_item_id (an older style). | 870 # we ignore insert_after_item_id (an older style). |
872 self._WritePosition(entry, entry.parent_id_string) | 871 self._WritePosition(entry, entry.parent_id_string) |
873 | 872 |
874 # Preserve the originator info, which the client is not required to send | 873 # Preserve the originator info, which the client is not required to send |
875 # when updating. | 874 # when updating. |
876 base_entry = self._entries.get(entry.id_string) | 875 base_entry = self._entries.get(entry.id_string) |
877 if base_entry and not entry.HasField('originator_cache_guid'): | 876 if base_entry and not entry.HasField('originator_cache_guid'): |
878 entry.originator_cache_guid = base_entry.originator_cache_guid | 877 entry.originator_cache_guid = base_entry.originator_cache_guid |
879 entry.originator_client_item_id = base_entry.originator_client_item_id | 878 entry.originator_client_item_id = base_entry.originator_client_item_id |
880 | 879 |
| 880 # Store the current time since the Unix epoch in milliseconds. |
| 881 entry.mtime = (int((time.mktime(time.gmtime()) - |
| 882 time.mktime(UNIX_TIME_EPOCH))*1000)) |
| 883 |
881 # Commit the change. This also updates the version number. | 884 # Commit the change. This also updates the version number. |
882 self._SaveEntry(entry) | 885 self._SaveEntry(entry) |
883 return entry | 886 return entry |
884 | 887 |
885 def _RewriteVersionInId(self, id_string): | 888 def _RewriteVersionInId(self, id_string): |
886 """Rewrites an ID so that its migration version becomes current.""" | 889 """Rewrites an ID so that its migration version becomes current.""" |
887 parsed_id = self._ExtractIdInfo(id_string) | 890 parsed_id = self._ExtractIdInfo(id_string) |
888 if not parsed_id: | 891 if not parsed_id: |
889 return id_string | 892 return id_string |
890 datatype, old_migration_version, inner_id = parsed_id | 893 datatype, old_migration_version, inner_id = parsed_id |
(...skipping 18 matching lines...) Expand all Loading... |
909 | 912 |
910 def TriggerSyncTabs(self): | 913 def TriggerSyncTabs(self): |
911 """Set the 'sync_tabs' field to this account's nigori node. | 914 """Set the 'sync_tabs' field to this account's nigori node. |
912 | 915 |
913 If the field is not currently set, will write a new nigori node entry | 916 If the field is not currently set, will write a new nigori node entry |
914 with the field set. Else does nothing. | 917 with the field set. Else does nothing. |
915 """ | 918 """ |
916 | 919 |
917 nigori_tag = "google_chrome_nigori" | 920 nigori_tag = "google_chrome_nigori" |
918 nigori_original = self._entries.get(self._ServerTagToId(nigori_tag)) | 921 nigori_original = self._entries.get(self._ServerTagToId(nigori_tag)) |
919 if (nigori_original.specifics.Extensions[nigori_specifics_pb2.nigori]. | 922 if (nigori_original.specifics.nigori.sync_tabs): |
920 sync_tabs): | |
921 return | 923 return |
922 nigori_new = copy.deepcopy(nigori_original) | 924 nigori_new = copy.deepcopy(nigori_original) |
923 nigori_new.specifics.Extensions[nigori_specifics_pb2.nigori].sync_tabs = ( | 925 nigori_new.specifics.nigori.sync_tabs = True |
924 True) | |
925 self._SaveEntry(nigori_new) | 926 self._SaveEntry(nigori_new) |
926 | 927 |
927 def SetInducedError(self, error, error_frequency, | 928 def SetInducedError(self, error, error_frequency, |
928 sync_count_before_errors): | 929 sync_count_before_errors): |
929 self.induced_error = error | 930 self.induced_error = error |
930 self.induced_error_frequency = error_frequency | 931 self.induced_error_frequency = error_frequency |
931 self.sync_count_before_errors = sync_count_before_errors | 932 self.sync_count_before_errors = sync_count_before_errors |
932 | 933 |
933 def GetInducedError(self): | 934 def GetInducedError(self): |
934 return self.induced_error | 935 return self.induced_error |
(...skipping 290 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
1225 | 1226 |
1226 update_sieve.CheckMigrationState() | 1227 update_sieve.CheckMigrationState() |
1227 | 1228 |
1228 new_timestamp, entries, remaining = self.account.GetChanges(update_sieve) | 1229 new_timestamp, entries, remaining = self.account.GetChanges(update_sieve) |
1229 | 1230 |
1230 update_response.changes_remaining = remaining | 1231 update_response.changes_remaining = remaining |
1231 for entry in entries: | 1232 for entry in entries: |
1232 reply = update_response.entries.add() | 1233 reply = update_response.entries.add() |
1233 reply.CopyFrom(entry) | 1234 reply.CopyFrom(entry) |
1234 update_sieve.SaveProgress(new_timestamp, update_response) | 1235 update_sieve.SaveProgress(new_timestamp, update_response) |
OLD | NEW |