Skip to content

Commit 21e21ff

Browse files
Merge branch 'mrschmidt/bundle/master' of github.com:firebase/firebase-android-sdk into mrschmidt/bundle/master
2 parents 41a50ce + 3f47480 commit 21e21ff

File tree

2 files changed

+154
-45
lines changed

2 files changed

+154
-45
lines changed

firebase-firestore/src/main/java/com/google/firebase/firestore/bundle/BundleSerializer.java

Lines changed: 122 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,41 +37,56 @@
3737
import com.google.protobuf.ByteString;
3838
import com.google.protobuf.NullValue;
3939
import com.google.type.LatLng;
40+
import java.text.ParseException;
41+
import java.text.SimpleDateFormat;
4042
import java.util.ArrayList;
43+
import java.util.Date;
44+
import java.util.GregorianCalendar;
4145
import java.util.Iterator;
4246
import java.util.List;
47+
import java.util.Locale;
48+
import java.util.TimeZone;
4349
import org.json.JSONArray;
4450
import org.json.JSONException;
4551
import org.json.JSONObject;
4652

4753
/** A JSON serializer to deserialize Firestore Bundles. */
4854
class BundleSerializer {
4955

56+
private static final long MILLIS_PER_SECOND = 1000;
57+
58+
private final SimpleDateFormat timestampFormat;
5059
private final RemoteSerializer remoteSerializer;
5160

5261
public BundleSerializer(RemoteSerializer remoteSerializer) {
5362
this.remoteSerializer = remoteSerializer;
63+
64+
timestampFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH);
65+
GregorianCalendar calendar = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
66+
// We use Proleptic Gregorian Calendar (i.e., Gregorian calendar extends backwards to year one)
67+
// for timestamp formatting.
68+
calendar.setGregorianChange(new Date(Long.MIN_VALUE));
69+
timestampFormat.setCalendar(calendar);
5470
}
5571

5672
public NamedQuery decodeNamedQuery(JSONObject namedQuery) throws JSONException {
5773
String name = namedQuery.getString("name");
5874
BundledQuery bundledQuery = decodeBundledQuery(namedQuery.getJSONObject("bundledQuery"));
59-
SnapshotVersion readTime = decodeSnapshotVersion(namedQuery.getJSONObject("readTime"));
75+
SnapshotVersion readTime = decodeSnapshotVersion(namedQuery.get("readTime"));
6076
return new NamedQuery(name, bundledQuery, readTime);
6177
}
6278

6379
public BundleMetadata decodeBundleMetadata(JSONObject bundleMetadata) throws JSONException {
6480
String bundleId = bundleMetadata.getString("id");
6581
int version = bundleMetadata.getInt("version");
66-
SnapshotVersion createTime = decodeSnapshotVersion(bundleMetadata.getJSONObject("createTime"));
82+
SnapshotVersion createTime = decodeSnapshotVersion(bundleMetadata.get("createTime"));
6783
return new BundleMetadata(bundleId, version, createTime);
6884
}
6985

7086
public BundledDocumentMetadata decodeBundledDocumentMetadata(JSONObject bundledDocumentMetadata)
7187
throws JSONException {
7288
DocumentKey key = DocumentKey.fromPath(decodeName(bundledDocumentMetadata.getString("name")));
73-
SnapshotVersion readTime =
74-
decodeSnapshotVersion(bundledDocumentMetadata.getJSONObject("readTime"));
89+
SnapshotVersion readTime = decodeSnapshotVersion(bundledDocumentMetadata.get("readTime"));
7590
boolean exists = bundledDocumentMetadata.optBoolean("exists", false);
7691
JSONArray queriesJson = bundledDocumentMetadata.optJSONArray("queries");
7792
List<String> queries = new ArrayList<>();
@@ -88,7 +103,7 @@ public BundledDocumentMetadata decodeBundledDocumentMetadata(JSONObject bundledD
88103
BundleDocument decodeDocument(JSONObject document) throws JSONException {
89104
String name = document.getString("name");
90105
DocumentKey key = DocumentKey.fromPath(decodeName(name));
91-
SnapshotVersion updateTime = decodeSnapshotVersion(document.getJSONObject("updateTime"));
106+
SnapshotVersion updateTime = decodeSnapshotVersion(document.get("updateTime"));
92107

93108
Value.Builder value = Value.newBuilder();
94109
decodeMapValue(value, document.getJSONObject("fields"));
@@ -110,10 +125,8 @@ private ResourcePath decodeName(String name) {
110125
return resourcePath.popFirst(5);
111126
}
112127

113-
private SnapshotVersion decodeSnapshotVersion(JSONObject timestamp) {
114-
long seconds = timestamp.optLong("seconds", 0);
115-
int nanos = timestamp.optInt("nanos", 0);
116-
return new SnapshotVersion(new Timestamp(seconds, nanos));
128+
private SnapshotVersion decodeSnapshotVersion(Object timestamp) throws JSONException {
129+
return new SnapshotVersion(decodeTimestamp(timestamp));
117130
}
118131

119132
private BundledQuery decodeBundledQuery(JSONObject bundledQuery) throws JSONException {
@@ -240,7 +253,7 @@ private Value decodeValue(JSONObject value) throws JSONException {
240253
} else if (value.has("doubleValue")) {
241254
builder.setDoubleValue(value.optDouble("doubleValue", 0.0));
242255
} else if (value.has("timestampValue")) {
243-
decodeTimestamp(builder, value.getJSONObject("timestampValue"));
256+
decodeTimestamp(builder, value.get("timestampValue"));
244257
} else if (value.has("stringValue")) {
245258
builder.setStringValue(value.optString("stringValue", ""));
246259
} else if (value.has("bytesValue")) {
@@ -291,11 +304,107 @@ private void decodeGeoPoint(Value.Builder builder, JSONObject geoPoint) {
291304
.setLongitude(geoPoint.optDouble("longitude", 0.0)));
292305
}
293306

294-
private void decodeTimestamp(Value.Builder builder, JSONObject timestamp) {
307+
private Timestamp decodeTimestamp(JSONObject timestamp) throws JSONException {
308+
return new Timestamp(timestamp.getLong("seconds"), timestamp.getInt("nanos"));
309+
}
310+
311+
private Timestamp decodeTimestamp(String timestamp) {
312+
// This method is copied from com.google.protobuf.util.Timestamps. See:
313+
// https://chromium.googlesource.com/external/github.com/google/protobuf/+/HEAD/java/util/src/main/java/com/google/protobuf/util/Timestamps.java?autodive=0%2F#232
314+
315+
try {
316+
int dayOffset = timestamp.indexOf('T');
317+
if (dayOffset == -1) {
318+
throw new IllegalArgumentException("Invalid timestamp: " + timestamp);
319+
}
320+
int timezoneOffsetPosition = timestamp.indexOf('Z', dayOffset);
321+
if (timezoneOffsetPosition == -1) {
322+
timezoneOffsetPosition = timestamp.indexOf('+', dayOffset);
323+
}
324+
if (timezoneOffsetPosition == -1) {
325+
timezoneOffsetPosition = timestamp.indexOf('-', dayOffset);
326+
}
327+
if (timezoneOffsetPosition == -1) {
328+
throw new IllegalArgumentException(
329+
"Invalid timestamp: Missing valid timezone offset: " + timestamp);
330+
}
331+
// Parse seconds and nanos.
332+
String timeValue = timestamp.substring(0, timezoneOffsetPosition);
333+
String secondValue = timeValue;
334+
String nanoValue = "";
335+
int pointPosition = timeValue.indexOf('.');
336+
if (pointPosition != -1) {
337+
secondValue = timeValue.substring(0, pointPosition);
338+
nanoValue = timeValue.substring(pointPosition + 1);
339+
}
340+
Date date = timestampFormat.parse(secondValue);
341+
long seconds = date.getTime() / MILLIS_PER_SECOND;
342+
int nanos = nanoValue.isEmpty() ? 0 : parseNanos(nanoValue);
343+
// Parse timezone offsets.
344+
if (timestamp.charAt(timezoneOffsetPosition) == 'Z') {
345+
if (timestamp.length() != timezoneOffsetPosition + 1) {
346+
throw new IllegalArgumentException(
347+
"Invalid timestamp: Invalid trailing data \""
348+
+ timestamp.substring(timezoneOffsetPosition)
349+
+ "\"");
350+
}
351+
} else {
352+
String offsetValue = timestamp.substring(timezoneOffsetPosition + 1);
353+
long offset = decodeTimezoneOffset(offsetValue);
354+
if (timestamp.charAt(timezoneOffsetPosition) == '+') {
355+
seconds -= offset;
356+
} else {
357+
seconds += offset;
358+
}
359+
}
360+
return new Timestamp(seconds, nanos);
361+
} catch (ParseException e) {
362+
throw new IllegalArgumentException("Failed to parse timestamp", e);
363+
}
364+
}
365+
366+
private Timestamp decodeTimestamp(Object timestamp) throws JSONException {
367+
if (timestamp instanceof String) {
368+
return decodeTimestamp((String) timestamp);
369+
} else {
370+
if (!(timestamp instanceof JSONObject)) {
371+
throw new IllegalArgumentException(
372+
"Timestamps must be either ISO 8601-formatted strings or JSON objects");
373+
}
374+
return decodeTimestamp((JSONObject) timestamp);
375+
}
376+
}
377+
378+
private void decodeTimestamp(Value.Builder builder, Object timestamp) throws JSONException {
379+
Timestamp decoded = decodeTimestamp(timestamp);
295380
builder.setTimestampValue(
296381
com.google.protobuf.Timestamp.newBuilder()
297-
.setSeconds(timestamp.optLong("seconds", 0))
298-
.setNanos(timestamp.optInt("nanos", 0)));
382+
.setSeconds(decoded.getSeconds())
383+
.setNanos(decoded.getNanoseconds()));
384+
}
385+
386+
private static int parseNanos(String value) {
387+
int result = 0;
388+
for (int i = 0; i < 9; ++i) {
389+
result = result * 10;
390+
if (i < value.length()) {
391+
if (value.charAt(i) < '0' || value.charAt(i) > '9') {
392+
throw new IllegalArgumentException("Invalid nanoseconds: " + value);
393+
}
394+
result += value.charAt(i) - '0';
395+
}
396+
}
397+
return result;
398+
}
399+
400+
private static long decodeTimezoneOffset(String value) {
401+
int pos = value.indexOf(':');
402+
if (pos == -1) {
403+
throw new IllegalArgumentException("Invalid offset value: " + value);
404+
}
405+
String hours = value.substring(0, pos);
406+
String minutes = value.substring(pos + 1);
407+
return (Long.parseLong(hours) * 60 + Long.parseLong(minutes)) * 60;
299408
}
300409

301410
private FieldFilter.Operator decodeFieldFilterOperator(String operator) {

firebase-firestore/src/test/java/com/google/firebase/firestore/bundle/BundleSerializerTest.java

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,8 @@
3838
import com.google.protobuf.Timestamp;
3939
import com.google.type.LatLng;
4040
import java.util.Arrays;
41-
import java.util.Calendar;
4241
import java.util.Collections;
4342
import java.util.List;
44-
import java.util.TimeZone;
4543
import org.json.JSONException;
4644
import org.json.JSONObject;
4745
import org.junit.Test;
@@ -148,27 +146,27 @@ public void testDecodesStringValues() throws JSONException {
148146

149147
@Test
150148
public void testDecodesDateValues() throws JSONException {
151-
Calendar date1 = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
152-
date1.set(2016, 0, 2, 10, 20, 50);
153-
date1.set(Calendar.MILLISECOND, 500);
154-
Timestamp ts1 = Timestamp.newBuilder().setNanos(500000000).setSeconds(1451730050).build();
155-
String json1 = "{ seconds: 1451730050, nanos: 500000000 }";
156-
157-
Calendar date2 = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
158-
date2.set(2016, 5, 17, 10, 50, 15);
159-
date2.set(Calendar.MILLISECOND, 0);
160-
Timestamp ts2 = Timestamp.newBuilder().setNanos(0).setSeconds(1466160615).build();
161-
String json2 = "{ seconds: 1466160615 }";
162-
163-
List<String> json =
164-
asList("{ timestampValue: " + json1 + " }", "{ timestampValue: " + json2 + " }");
165-
List<Value> proto =
166-
asList(
167-
Value.newBuilder().setTimestampValue(ts1).build(),
168-
Value.newBuilder().setTimestampValue(ts2).build());
169-
170-
for (int i = 0; i < json.size(); i++) {
171-
assertDecodesValue(json.get(i), proto.get(i));
149+
String[] json =
150+
new String[] {
151+
"'2020-01-01T01:00:00.001Z'", "{ seconds: 1577840400, nanos: 1000000 }",
152+
"'2020-01-01T01:02:00.001002Z'", "{ seconds: '1577840520', nanos: 1002000 }",
153+
"'2020-01-01T01:02:03.001002003Z'", "{ seconds: 1577840523, nanos: 1002003 }",
154+
};
155+
156+
Timestamp[] timestamps =
157+
new Timestamp[] {
158+
Timestamp.newBuilder().setNanos(1000000).setSeconds(1577840400).build(),
159+
Timestamp.newBuilder().setNanos(1000000).setSeconds(1577840400).build(),
160+
Timestamp.newBuilder().setNanos(1002000).setSeconds(1577840520).build(),
161+
Timestamp.newBuilder().setNanos(1002000).setSeconds(1577840520).build(),
162+
Timestamp.newBuilder().setNanos(1002003).setSeconds(1577840523).build(),
163+
Timestamp.newBuilder().setNanos(1002003).setSeconds(1577840523).build()
164+
};
165+
166+
for (int i = 0; i < json.length; i++) {
167+
assertDecodesValue(
168+
"{ timestampValue: " + json[i] + " }",
169+
Value.newBuilder().setTimestampValue(timestamps[i]).build());
172170
}
173171
}
174172

@@ -622,11 +620,11 @@ public void testDecodesBundleMetadata() throws JSONException {
622620
"{\n"
623621
+ "id: 'bundle-1',\n"
624622
+ "version: 1,\n"
625-
+ "createTime: { seconds: 2, nanos: 3 }\n"
623+
+ "createTime: '2020-01-01T00:00:01.000000001Z' \n"
626624
+ "}";
627625
BundleMetadata expectedMetadata =
628626
new BundleMetadata(
629-
"bundle-1", 1, new SnapshotVersion(new com.google.firebase.Timestamp(2, 3)));
627+
"bundle-1", 1, new SnapshotVersion(new com.google.firebase.Timestamp(1577836801, 1)));
630628
BundleMetadata actualMetadata = serializer.decodeBundleMetadata(new JSONObject(json));
631629
assertEquals(expectedMetadata, actualMetadata);
632630
}
@@ -640,14 +638,14 @@ public void testDecodesBundledDocumentMetadata() throws JSONException {
640638
+ "name: '"
641639
+ TEST_DOCUMENT
642640
+ "',\n"
643-
+ "readTime: { seconds: 1, nanos: 2 },\n"
641+
+ "readTime: '2020-01-01T00:00:01.000000001Z',\n"
644642
+ "exists: true,\n"
645643
+ "queries: [ 'query-1', 'query-2' ]\n"
646644
+ "}";
647645
BundledDocumentMetadata expectedMetadata =
648646
new BundledDocumentMetadata(
649647
key("coll/doc"),
650-
new SnapshotVersion(new com.google.firebase.Timestamp(1, 2)),
648+
new SnapshotVersion(new com.google.firebase.Timestamp(1577836801, 1)),
651649
true,
652650
Arrays.asList("query-1", "query-2"));
653651
BundledDocumentMetadata actualMetadata =
@@ -666,15 +664,15 @@ private void assertDecodesValue(String json, Value proto) throws JSONException {
666664
+ json
667665
+ "\n"
668666
+ " },\n"
669-
+ " crateTime: { seconds: 1, nanos: 2 },\n"
670-
+ " updateTime: { seconds: 3, nanos: 4 }\n"
667+
+ " crateTime: '2020-01-01T00:00:01.000000001Z',\n"
668+
+ " updateTime: '2020-01-01T00:00:02.000000002Z'\n"
671669
+ "}";
672670
BundleDocument actualDocument = serializer.decodeDocument(new JSONObject(documentJson));
673671
BundleDocument expectedDocument =
674672
new BundleDocument(
675673
new Document(
676674
DocumentKey.fromName(TEST_DOCUMENT),
677-
new SnapshotVersion(new com.google.firebase.Timestamp(3, 4)),
675+
new SnapshotVersion(new com.google.firebase.Timestamp(1577836802, 2)),
678676
new ObjectValue(
679677
Value.newBuilder()
680678
.setMapValue(MapValue.newBuilder().putFields("foo", proto))
@@ -699,7 +697,7 @@ private void assertDecodesNamedQuery(String json, Query query) throws JSONExcept
699697
+ (query.hasLimitToLast() ? "LAST" : "FIRST")
700698
+ "'\n"
701699
+ " },\n"
702-
+ " readTime: { seconds: 1, nanos: 2 }\n"
700+
+ " readTime: '2020-01-01T00:00:01.000000001Z'\n"
703701
+ "}";
704702
NamedQuery actualNamedQuery = serializer.decodeNamedQuery(new JSONObject(queryJson));
705703

@@ -724,7 +722,9 @@ private void assertDecodesNamedQuery(String json, Query query) throws JSONExcept
724722
: Query.LimitType.LIMIT_TO_FIRST);
725723
NamedQuery expectedNamedQuery =
726724
new NamedQuery(
727-
"query-1", bundledQuery, new SnapshotVersion(new com.google.firebase.Timestamp(1, 2)));
725+
"query-1",
726+
bundledQuery,
727+
new SnapshotVersion(new com.google.firebase.Timestamp(1577836801, 1)));
728728

729729
assertEquals(expectedNamedQuery, actualNamedQuery);
730730
}

0 commit comments

Comments
 (0)