1 /* 2 * Copyright (C) 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.timezone.location.data_pipeline.steps; 18 19 import static java.util.stream.Collectors.toList; 20 21 import com.android.timezone.location.common.LicenseSupport.License; 22 import com.android.timezone.location.data_pipeline.steps.proto.S2Protos; 23 import com.google.common.geometry.S2CellId; 24 import com.google.common.geometry.S2CellUnion; 25 import com.google.common.geometry.S2Loop; 26 import com.google.common.geometry.S2Point; 27 import com.google.common.geometry.S2Polygon; 28 import com.google.protobuf.Message; 29 import com.google.protobuf.TextFormat; 30 31 import java.io.File; 32 import java.io.FileFilter; 33 import java.io.FileInputStream; 34 import java.io.FileOutputStream; 35 import java.io.FileReader; 36 import java.io.FileWriter; 37 import java.io.IOException; 38 import java.util.ArrayList; 39 import java.util.Arrays; 40 import java.util.Collections; 41 import java.util.List; 42 import java.util.Objects; 43 44 /** A set of shared types for use in the reference time zone geolocation data pipeline. */ 45 public final class Types { 46 47 public static final ProtoStorageFormat DEFAULT_PROTO_STORAGE_FORMAT = ProtoStorageFormat.TEXT; 48 Types()49 private Types() { 50 } 51 52 /** The two proto storage formats supported. */ 53 public enum ProtoStorageFormat { 54 TEXT(".prototxt"), 55 BINARY(".proto"); 56 57 private final String mSuffix; 58 ProtoStorageFormat(String suffix)59 ProtoStorageFormat(String suffix) { 60 this.mSuffix = suffix; 61 } 62 63 /** 64 * Stores {@code message} in {@code outputFile} in this format. Also checks for a LICENSE 65 * file and adds a header to the file if possible. 66 */ store(Message message, File outputFile, License license)67 public void store(Message message, File outputFile, License license) throws IOException { 68 // Require the LICENSE file to be present. 69 license.checkLicensePresentInDir(outputFile.getParentFile()); 70 71 if (this == TEXT) { 72 storeProtoAsText(message, outputFile, license); 73 } else if (this == BINARY) { 74 storeProtoAsBinary(message, outputFile); 75 } else { 76 throw new IllegalArgumentException(); 77 } 78 } 79 80 /** 81 * Loads a proto in this format from {@code inputFile} by populating the supplied 82 * {@code builder}. 83 */ load(Message.Builder builder, File inputFile)84 public void load(Message.Builder builder, File inputFile) throws IOException { 85 if (this == TEXT) { 86 loadProtoAsText(builder, inputFile); 87 } else if (this == BINARY) { 88 loadProtoAsBinary(builder, inputFile); 89 } else { 90 throw new IllegalArgumentException(); 91 } 92 } 93 94 /** Returns the type suffix for this storage format. */ getTypeSuffix()95 public String getTypeSuffix() { 96 return mSuffix; 97 } 98 loadProtoAsText(Message.Builder builder, File inputFile)99 private static void loadProtoAsText(Message.Builder builder, File inputFile) 100 throws IOException { 101 try (FileReader reader = new FileReader(inputFile)) { 102 TextFormat.getParser().merge(reader, builder); 103 } 104 } 105 loadProtoAsBinary(Message.Builder builder, File inputFile)106 private static void loadProtoAsBinary(Message.Builder builder, File inputFile) 107 throws IOException { 108 try (FileInputStream inputStream = new FileInputStream(inputFile)) { 109 builder.mergeFrom(inputStream); 110 } 111 } 112 storeProtoAsText(Message message, File outputFile, License license)113 private static void storeProtoAsText(Message message, File outputFile, License license) 114 throws IOException { 115 try (FileWriter writer = new FileWriter(outputFile)) { 116 // Add the license text header. 117 writer.append(license.getTextProtoHeader()); 118 119 TextFormat.print(message, writer); 120 } 121 } 122 storeProtoAsBinary(Message message, File outputFile)123 private static void storeProtoAsBinary(Message message, File outputFile) 124 throws IOException { 125 try (FileOutputStream fileOutputStream = new FileOutputStream(outputFile)) { 126 fileOutputStream.write(message.toByteArray()); 127 } 128 } 129 } 130 131 /** A basic pair class. */ 132 public static class Pair<A, B> { 133 134 public final A a; 135 136 public final B b; 137 Pair(A a, B b)138 public Pair(A a, B b) { 139 this.a = a; 140 this.b = b; 141 } 142 } 143 144 /** A combination of a time zone ID and a list of {@link S2Polygon} instances. */ 145 public static class TzS2Polygons { 146 147 private static final String FILE_NAME_CONTENT_IDENTIFIER = "_tzs2polygons"; 148 149 public final String tzId; 150 151 public final List<S2Polygon> s2PolygonList; 152 153 /** Creates a new instance. */ TzS2Polygons(String tzId, List<S2Polygon> s2PolygonList)154 public TzS2Polygons(String tzId, List<S2Polygon> s2PolygonList) { 155 this.tzId = Objects.requireNonNull(tzId); 156 157 // Defensive copy of mutable S2 objects. 158 this.s2PolygonList = new ArrayList<>(); 159 s2PolygonList.forEach(x -> this.s2PolygonList.add(new S2Polygon(x))); 160 } 161 162 @Override equals(Object o)163 public boolean equals(Object o) { 164 if (this == o) { 165 return true; 166 } 167 if (o == null || getClass() != o.getClass()) { 168 return false; 169 } 170 TzS2Polygons that = (TzS2Polygons) o; 171 return tzId.equals(that.tzId) 172 && s2PolygonsEquals(s2PolygonList, that.s2PolygonList); 173 } 174 s2PolygonsEquals(List<S2Polygon> one, List<S2Polygon> two)175 private static boolean s2PolygonsEquals(List<S2Polygon> one, List<S2Polygon> two) { 176 if (one.size() != two.size()) { 177 return false; 178 } 179 for (int polyIndex = 0; polyIndex < one.size(); polyIndex++) { 180 S2Polygon onePoly = one.get(polyIndex); 181 S2Polygon twoPoly = two.get(polyIndex); 182 if (!s2PolygonEquals(onePoly, twoPoly)) { 183 return false; 184 } 185 } 186 return true; 187 } 188 s2PolygonEquals(S2Polygon one, S2Polygon two)189 private static boolean s2PolygonEquals(S2Polygon one, S2Polygon two) { 190 if (one.numLoops() != two.numLoops()) { 191 return false; 192 } 193 194 for (int loopIndex = 0; loopIndex < one.numLoops(); loopIndex++) { 195 S2Loop oneLoop = one.loop(loopIndex); 196 S2Loop twoLoop = two.loop(loopIndex); 197 if (!s2LoopEquals(oneLoop, twoLoop)) { 198 return false; 199 } 200 } 201 return true; 202 } 203 s2LoopEquals(S2Loop one, S2Loop two)204 private static boolean s2LoopEquals(S2Loop one, S2Loop two) { 205 if (one.numVertices() != two.numVertices()) { 206 return false; 207 } 208 209 for (int vertexIndex = 0; vertexIndex < one.numVertices(); vertexIndex++) { 210 S2Point onePoint = one.vertex(vertexIndex); 211 S2Point twoPoint = two.vertex(vertexIndex); 212 if (!onePoint.equals(twoPoint)) { 213 return false; 214 } 215 } 216 return true; 217 } 218 219 @Override hashCode()220 public int hashCode() { 221 return Objects.hash(tzId); 222 } 223 224 @Override toString()225 public String toString() { 226 return "TzS2Polygons{" 227 + "tzId='" + tzId + '\'' 228 + ", s2PolygonList=" + s2PolygonList 229 + '}'; 230 } 231 232 /** 233 * Stores the supplied object in {@code outputFile} in the specified format, adding license 234 * information to the header when possible and checking the target directory contains the 235 * necessary LICENSE file. 236 */ store(TzS2Polygons tzPolygons, File outputFile, ProtoStorageFormat storageFormat, License license)237 public static void store(TzS2Polygons tzPolygons, File outputFile, 238 ProtoStorageFormat storageFormat, License license) throws IOException { 239 S2Protos.TzS2Polygons.Builder s2PolygonsProtoBuilder = 240 S2Protos.TzS2Polygons.newBuilder().setTzId(tzPolygons.tzId); 241 for (S2Polygon s2Polygon : tzPolygons.s2PolygonList) { 242 S2Protos.S2Polygon.Builder s2PolygonProtoBuilder = S2Protos.S2Polygon.newBuilder(); 243 for (int loopIndex = 0; loopIndex < s2Polygon.numLoops(); loopIndex++) { 244 S2Loop s2Loop = s2Polygon.loop(loopIndex); 245 S2Protos.S2Loop.Builder s2LoopProtoBuilder = S2Protos.S2Loop.newBuilder(); 246 for (int vIndex = 0; vIndex < s2Loop.numVertices(); vIndex++) { 247 S2Point s2Point = s2Loop.vertex(vIndex); 248 S2Protos.S2Point s2PointProto = S2Protos.S2Point.newBuilder() 249 .setX(s2Point.get(0)) 250 .setY(s2Point.get(1)) 251 .setZ(s2Point.get(2)) 252 .build(); 253 s2LoopProtoBuilder.addVertices(s2PointProto); 254 } 255 s2PolygonProtoBuilder.addLoops(s2LoopProtoBuilder.build()); 256 } 257 s2PolygonsProtoBuilder.addPolygons(s2PolygonProtoBuilder.build()); 258 } 259 S2Protos.TzS2Polygons s2PolygonsProto = s2PolygonsProtoBuilder.build(); 260 storageFormat.store(s2PolygonsProto, outputFile, license); 261 } 262 263 /** 264 * Lists the files that have the file suffix returned by 265 * {@link #getFileSuffix(ProtoStorageFormat)} in the specified directory. 266 */ listFiles(File dir, ProtoStorageFormat storageFormat)267 public static List<File> listFiles(File dir, ProtoStorageFormat storageFormat) { 268 String fileSuffix = getFileSuffix(storageFormat); 269 FileFilter fileFilter = x -> x.getName().endsWith(fileSuffix); 270 return Types.listFiles(dir, fileFilter); 271 } 272 273 /** Returns a standard suffix for this type in the specified file format. */ getFileSuffix(ProtoStorageFormat storageFormat)274 public static String getFileSuffix(ProtoStorageFormat storageFormat) { 275 return TzS2Polygons.FILE_NAME_CONTENT_IDENTIFIER + storageFormat.getTypeSuffix(); 276 } 277 278 /** Loads the {@code inputFile} in the specified format as a {@link TzS2Polygons}. */ load(File inputFile, ProtoStorageFormat storageFormat)279 public static TzS2Polygons load(File inputFile, ProtoStorageFormat storageFormat) 280 throws IOException { 281 S2Protos.TzS2Polygons.Builder builder = S2Protos.TzS2Polygons.newBuilder(); 282 storageFormat.load(builder, inputFile); 283 S2Protos.TzS2Polygons tzS2PolygonsProto = builder.build(); 284 285 List<S2Polygon> s2Polygons = new ArrayList<>(); 286 for (S2Protos.S2Polygon s2PolygonProto : tzS2PolygonsProto.getPolygonsList()) { 287 List<S2Loop> s2Loops = new ArrayList<>(); 288 for (S2Protos.S2Loop s2LoopProto : s2PolygonProto.getLoopsList()) { 289 List<S2Point> vertices = new ArrayList<>(); 290 for (S2Protos.S2Point s2PointProto : s2LoopProto.getVerticesList()) { 291 S2Point s2Point = new S2Point( 292 s2PointProto.getX(), 293 s2PointProto.getY(), 294 s2PointProto.getZ()); 295 vertices.add(s2Point); 296 } 297 S2Loop s2Loop = new S2Loop(vertices); 298 s2Loops.add(s2Loop); 299 } 300 S2Polygon s2Polygon = new S2Polygon(s2Loops); 301 s2Polygons.add(s2Polygon); 302 } 303 return new TzS2Polygons(tzS2PolygonsProto.getTzId(), s2Polygons); 304 } 305 } 306 307 /** A combination of a time zone ID and a {@link S2CellUnion}. */ 308 public static class TzS2CellUnion { 309 310 private static final String FILE_NAME_CONTENT_IDENTIFIER = "_tzs2cellunion"; 311 312 public final String tzId; 313 314 public final S2CellUnion s2CellUnion; 315 316 /** Creates a new instance. */ TzS2CellUnion(String tzId, S2CellUnion s2CellUnion)317 public TzS2CellUnion(String tzId, S2CellUnion s2CellUnion) { 318 this.tzId = Objects.requireNonNull(tzId); 319 this.s2CellUnion = (S2CellUnion) s2CellUnion.clone(); 320 } 321 322 @Override equals(Object o)323 public boolean equals(Object o) { 324 if (this == o) { 325 return true; 326 } 327 if (o == null || getClass() != o.getClass()) { 328 return false; 329 } 330 TzS2CellUnion that = (TzS2CellUnion) o; 331 return tzId.equals(that.tzId) 332 && s2CellUnionEquals(this.s2CellUnion, that.s2CellUnion); 333 } 334 s2CellUnionEquals(S2CellUnion one, S2CellUnion two)335 private static boolean s2CellUnionEquals(S2CellUnion one, S2CellUnion two) { 336 return one.cellIds().equals(two.cellIds()); 337 } 338 339 @Override hashCode()340 public int hashCode() { 341 return Objects.hash(tzId); 342 } 343 344 @Override toString()345 public String toString() { 346 return "TzS2CellUnion{" 347 + "tzId='" + tzId + '\'' + 348 ", s2CellUnion=" + s2CellUnion 349 + '}'; 350 } 351 352 /** 353 * Stores the supplied object in {@code outputFile} in the specified format, adding license 354 * information to the header when possible and checking the target directory contains the 355 * necessary LICENSE file. 356 */ store(TzS2CellUnion tzS2CellUnion, File outputFile, ProtoStorageFormat storageFormat, License license)357 public static void store(TzS2CellUnion tzS2CellUnion, File outputFile, 358 ProtoStorageFormat storageFormat, License license) throws IOException { 359 ArrayList<S2CellId> s2CellIds = tzS2CellUnion.s2CellUnion.cellIds(); 360 List<Long> cellIds = s2CellIds.stream().map(S2CellId::id).collect(toList()); 361 S2Protos.TzS2CellUnion message = S2Protos.TzS2CellUnion.newBuilder() 362 .setTzId(tzS2CellUnion.tzId) 363 .addAllCellIds(cellIds) 364 .build(); 365 storageFormat.store(message, outputFile, license); 366 } 367 368 /** Loads the {@code inputFile} in the specified format as a {@link TzS2CellUnion}. */ load(File file, ProtoStorageFormat storageFormat)369 public static TzS2CellUnion load(File file, ProtoStorageFormat storageFormat) { 370 S2Protos.TzS2CellUnion.Builder builder = S2Protos.TzS2CellUnion.newBuilder(); 371 try { 372 storageFormat.load(builder, file); 373 } catch (IOException e) { 374 throw new RuntimeException(e); 375 } 376 377 S2CellUnion s2CellUnion = new S2CellUnion(); 378 List<S2CellId> cellIds = 379 builder.getCellIdsList().stream().map(S2CellId::new).collect(toList()); 380 s2CellUnion.initFromCellIds(new ArrayList<>(cellIds)); 381 return new TzS2CellUnion(builder.getTzId(), s2CellUnion); 382 } 383 384 /** 385 * Lists the files that have the file suffix returned by 386 * {@link #getFileSuffix(ProtoStorageFormat)} in the specified directory. 387 */ listFiles(File dir, ProtoStorageFormat storageFormat)388 public static List<File> listFiles(File dir, ProtoStorageFormat storageFormat) { 389 String fileSuffix = getFileSuffix(storageFormat); 390 FileFilter fileFilter = x -> x.getName().endsWith(fileSuffix); 391 return Types.listFiles(dir, fileFilter); 392 } 393 394 /** Returns a standard suffix for this type in the specified file format. */ getFileSuffix(ProtoStorageFormat storageFormat)395 public static String getFileSuffix(ProtoStorageFormat storageFormat) { 396 return FILE_NAME_CONTENT_IDENTIFIER + storageFormat.getTypeSuffix(); 397 } 398 } 399 400 /** A <em>mutable</em> container for a list of {@link TzS2Range} instances. */ 401 public static class TzS2Ranges { 402 403 private static final String FILE_NAME_CONTENT_IDENTIFIER = "_tzs2ranges"; 404 405 private final List<TzS2Range> mValues; 406 TzS2Ranges(List<TzS2Range> values)407 public TzS2Ranges(List<TzS2Range> values) { 408 this.mValues = new ArrayList<>(values); 409 } 410 411 /** 412 * Replaces the {@link TzS2Range} with the specified index with a new range. Returns the 413 * old range at the index. 414 */ replace(int i, TzS2Range newRange)415 public TzS2Range replace(int i, TzS2Range newRange) { 416 return mValues.set(i, newRange); 417 } 418 419 /** Returns the {@link TzS2Range} at the specified index. */ get(int i)420 public TzS2Range get(int i) { 421 return mValues.get(i); 422 } 423 424 /** Returns the number of {@link TzS2Range} instances. */ size()425 public int size() { 426 return mValues.size(); 427 } 428 429 /** Returns (a copy of) the list of {@link TzS2Range} instances held. */ getAll()430 public List<TzS2Range> getAll() { 431 return new ArrayList<>(mValues); 432 } 433 434 @Override equals(Object o)435 public boolean equals(Object o) { 436 if (this == o) { 437 return true; 438 } 439 if (o == null || getClass() != o.getClass()) { 440 return false; 441 } 442 TzS2Ranges that = (TzS2Ranges) o; 443 return mValues.equals(that.mValues); 444 } 445 446 @Override hashCode()447 public int hashCode() { 448 return Objects.hash(mValues); 449 } 450 451 @Override toString()452 public String toString() { 453 return "TzS2Ranges{" 454 + "values=" + mValues 455 + '}'; 456 } 457 458 /** Loads the {@code inputFile} in the specified format as a {@link TzS2Ranges}. */ load(File inputFile, ProtoStorageFormat storageFormat)459 public static TzS2Ranges load(File inputFile, ProtoStorageFormat storageFormat) { 460 S2Protos.TzS2Ranges.Builder builder = S2Protos.TzS2Ranges.newBuilder(); 461 try { 462 storageFormat.load(builder, inputFile); 463 } catch (IOException e) { 464 throw new RuntimeException("Failure while reading " + inputFile, e); 465 } 466 467 List<TzS2Range> values = builder.getRangesList().stream() 468 .map(x -> new TzS2Range( 469 x.getValuesList(), 470 new S2CellId(x.getStartCellId()), 471 new S2CellId(x.getEndCellId()))) 472 .collect(toList()); 473 return new TzS2Ranges(values); 474 } 475 476 /** 477 * Stores the supplied object in {@code outputFile} in the specified format, adding license 478 * information to the header when possible and checking the target directory contains the 479 * necessary LICENSE file. 480 */ store(TzS2Ranges ranges, File outputFile, ProtoStorageFormat storageFormat, License license)481 public static void store(TzS2Ranges ranges, File outputFile, 482 ProtoStorageFormat storageFormat, License license) throws IOException { 483 List<S2Protos.TzS2Range> rangeProtos = ranges.mValues.stream() 484 .map(TzS2Range::createTzS2RangeProto) 485 .collect(toList()); 486 S2Protos.TzS2Ranges message = S2Protos.TzS2Ranges.newBuilder() 487 .addAllRanges(rangeProtos) 488 .build(); 489 storageFormat.store(message, outputFile, license); 490 } 491 492 /** 493 * Lists the files that have the file suffix returned by 494 * {@link #getFileSuffix(ProtoStorageFormat)} in the specified directory. 495 */ listFiles(File dir, ProtoStorageFormat storageFormat)496 public static List<File> listFiles(File dir, ProtoStorageFormat storageFormat) { 497 FileFilter fileFilter = x -> x.getName().endsWith(getFileSuffix(storageFormat)); 498 return Types.listFiles(dir, fileFilter); 499 } 500 501 /** Returns a standard suffix for this type in the specified file format. */ getFileSuffix(ProtoStorageFormat storageFormat)502 public static String getFileSuffix(ProtoStorageFormat storageFormat) { 503 return FILE_NAME_CONTENT_IDENTIFIER + storageFormat.getTypeSuffix(); 504 } 505 } 506 507 /** 508 * A range of S2 cell IDs at a fixed S2 level associated with a list of time zone IDs. The range 509 * is expressed as a start cell ID (inclusive) and an end cell ID (exclusive). 510 */ 511 public static class TzS2Range { 512 513 public final List<String> tzIds; 514 515 public final S2CellId rangeStart; 516 517 public final S2CellId rangeEnd; 518 519 /** 520 * Creates an instance. If the range is invalid or the cell IDs are from different levels 521 * this method throws an {@link IllegalArgumentException}. 522 */ TzS2Range(List<String> tzIds, S2CellId rangeStart, S2CellId rangeEnd)523 public TzS2Range(List<String> tzIds, S2CellId rangeStart, S2CellId rangeEnd) { 524 this.tzIds = new ArrayList<>(tzIds); 525 this.rangeStart = Objects.requireNonNull(rangeStart); 526 this.rangeEnd = Objects.requireNonNull(rangeEnd); 527 if (rangeStart.level() != rangeEnd.level()) { 528 throw new IllegalArgumentException( 529 "Levels differ: rangeStart=" + rangeStart + ", rangeEnd=" + rangeEnd); 530 } 531 if (rangeStart.greaterOrEquals(rangeEnd)) { 532 throw new IllegalArgumentException( 533 "Range start (" + rangeStart + " >= range end (" + rangeEnd + ")"); 534 } 535 } 536 createTzS2RangeProto(TzS2Range x)537 private static S2Protos.TzS2Range createTzS2RangeProto(TzS2Range x) { 538 return S2Protos.TzS2Range.newBuilder() 539 .setStartCellId(x.rangeStart.id()) 540 .setEndCellId(x.rangeEnd.id()) 541 .addAllValues(x.tzIds) 542 .build(); 543 } 544 545 /** 546 * Returns the number of cells in this range. 547 */ size()548 public long size() { 549 // Convert into unsigned values. 550 final long rangeStartVal = rangeStart.id() >>> 1; 551 final long rangeEndVal = rangeEnd.id() >>> 1; 552 // Since we shifted the above values right, we need not shift the lowestOnBit left to 553 // obtain the difference between adjacent S2 cells at the given S2 level. 554 final long increment = rangeStart.lowestOnBit(); 555 return (rangeEndVal - rangeStartVal) / increment; 556 } 557 558 @Override equals(Object o)559 public boolean equals(Object o) { 560 if (this == o) { 561 return true; 562 } 563 if (o == null || getClass() != o.getClass()) { 564 return false; 565 } 566 TzS2Range tzS2Range = (TzS2Range) o; 567 return tzIds.equals(tzS2Range.tzIds) 568 && rangeStart.equals(tzS2Range.rangeStart) 569 && rangeEnd.equals(tzS2Range.rangeEnd); 570 } 571 572 @Override hashCode()573 public int hashCode() { 574 return Objects.hash(tzIds, rangeStart, rangeEnd); 575 } 576 577 @Override toString()578 public String toString() { 579 return "TzS2Range{" 580 + " value=" + tzIds 581 + ", rangeStart=" + rangeStart 582 + ", rangeEnd=" + rangeEnd 583 + '}'; 584 } 585 } 586 587 /** Returns a list of files that match the supplied {@link FileFilter}. */ listFiles(File dir, FileFilter fileFilter)588 private static List<File> listFiles(File dir, FileFilter fileFilter) { 589 File[] files = dir.listFiles(fileFilter); 590 if (files == null) { 591 return Collections.emptyList(); 592 } 593 594 // Sort the results as listFiles() isn't deterministic, which can lead to variations 595 // in output that (for example) cause tests to break occasionally. 596 Arrays.sort(files); 597 598 return Arrays.asList(files); 599 } 600 } 601