1 /*
2  * Copyright (C) 2016 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 package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache;
17 
18 import android.annotation.SuppressLint;
19 import android.content.ContentValues;
20 import android.database.Cursor;
21 import android.database.SQLException;
22 import android.database.sqlite.SQLiteDatabase;
23 import android.database.sqlite.SQLiteException;
24 import android.util.SparseArray;
25 import android.util.SparseBooleanArray;
26 import androidx.annotation.Nullable;
27 import androidx.annotation.VisibleForTesting;
28 import androidx.annotation.WorkerThread;
29 import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseIOException;
30 import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseProvider;
31 import org.mozilla.thirdparty.com.google.android.exoplayer2.database.VersionTable;
32 import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
33 import org.mozilla.thirdparty.com.google.android.exoplayer2.util.AtomicFile;
34 import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ReusableBufferedOutputStream;
35 import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
36 import java.io.BufferedInputStream;
37 import java.io.ByteArrayInputStream;
38 import java.io.ByteArrayOutputStream;
39 import java.io.DataInputStream;
40 import java.io.DataOutputStream;
41 import java.io.File;
42 import java.io.IOException;
43 import java.io.InputStream;
44 import java.io.OutputStream;
45 import java.security.InvalidAlgorithmParameterException;
46 import java.security.InvalidKeyException;
47 import java.security.NoSuchAlgorithmException;
48 import java.util.Arrays;
49 import java.util.Collection;
50 import java.util.HashMap;
51 import java.util.Map;
52 import java.util.Random;
53 import java.util.Set;
54 import javax.crypto.Cipher;
55 import javax.crypto.CipherInputStream;
56 import javax.crypto.CipherOutputStream;
57 import javax.crypto.NoSuchPaddingException;
58 import javax.crypto.spec.IvParameterSpec;
59 import javax.crypto.spec.SecretKeySpec;
60 import org.checkerframework.checker.nullness.compatqual.NullableType;
61 
62 /** Maintains the index of cached content. */
63 /* package */ class CachedContentIndex {
64 
65   /* package */ static final String FILE_NAME_ATOMIC = "cached_content_index.exi";
66 
67   private static final int INCREMENTAL_METADATA_READ_LENGTH = 10 * 1024 * 1024;
68 
69   private final HashMap<String, CachedContent> keyToContent;
70   /**
71    * Maps assigned ids to their corresponding keys. Also contains (id -> null) entries for ids that
72    * have been removed from the index since it was last stored. This prevents reuse of these ids,
73    * which is necessary to avoid clashes that could otherwise occur as a result of the sequence:
74    *
75    * <p>[1] (key1, id1) is removed from the in-memory index ... the index is not stored to disk ...
76    * [2] id1 is reused for a different key2 ... the index is not stored to disk ... [3] A file for
77    * key2 is partially written using a path corresponding to id1 ... the process is killed before
78    * the index is stored to disk ... [4] The index is read from disk, causing the partially written
79    * file to be incorrectly associated to key1
80    *
81    * <p>By avoiding id reuse in step [2], a new id2 will be used instead. Step [4] will then delete
82    * the partially written file because the index does not contain an entry for id2.
83    *
84    * <p>When the index is next stored (id -> null) entries are removed, making the ids eligible for
85    * reuse.
86    */
87   private final SparseArray<@NullableType String> idToKey;
88   /**
89    * Tracks ids for which (id -> null) entries are present in idToKey, so that they can be removed
90    * efficiently when the index is next stored.
91    */
92   private final SparseBooleanArray removedIds;
93   /** Tracks ids that are new since the index was last stored. */
94   private final SparseBooleanArray newIds;
95 
96   private Storage storage;
97   @Nullable private Storage previousStorage;
98 
99   /** Returns whether the file is an index file. */
isIndexFile(String fileName)100   public static boolean isIndexFile(String fileName) {
101     // Atomic file backups add additional suffixes to the file name.
102     return fileName.startsWith(FILE_NAME_ATOMIC);
103   }
104 
105   /**
106    * Deletes index data for the specified cache.
107    *
108    * <p>This method may be slow and shouldn't normally be called on the main thread.
109    *
110    * @param databaseProvider Provides the database in which the index is stored.
111    * @param uid The cache UID.
112    * @throws DatabaseIOException If an error occurs deleting the index data.
113    */
114   @WorkerThread
delete(DatabaseProvider databaseProvider, long uid)115   public static void delete(DatabaseProvider databaseProvider, long uid)
116       throws DatabaseIOException {
117     DatabaseStorage.delete(databaseProvider, uid);
118   }
119 
120   /**
121    * Creates an instance supporting database storage only.
122    *
123    * @param databaseProvider Provides the database in which the index is stored.
124    */
CachedContentIndex(DatabaseProvider databaseProvider)125   public CachedContentIndex(DatabaseProvider databaseProvider) {
126     this(
127         databaseProvider,
128         /* legacyStorageDir= */ null,
129         /* legacyStorageSecretKey= */ null,
130         /* legacyStorageEncrypt= */ false,
131         /* preferLegacyStorage= */ false);
132   }
133 
134   /**
135    * Creates an instance supporting either or both of database and legacy storage.
136    *
137    * @param databaseProvider Provides the database in which the index is stored, or {@code null} to
138    *     use only legacy storage.
139    * @param legacyStorageDir The directory in which any legacy storage is stored, or {@code null} to
140    *     use only database storage.
141    * @param legacyStorageSecretKey A 16 byte AES key for reading, and optionally writing, legacy
142    *     storage.
143    * @param legacyStorageEncrypt Whether to encrypt when writing to legacy storage. Must be false if
144    *     {@code legacyStorageSecretKey} is null.
145    * @param preferLegacyStorage Whether to use prefer legacy storage if both storage types are
146    *     enabled. This option is only useful for downgrading from database storage back to legacy
147    *     storage.
148    */
CachedContentIndex( @ullable DatabaseProvider databaseProvider, @Nullable File legacyStorageDir, @Nullable byte[] legacyStorageSecretKey, boolean legacyStorageEncrypt, boolean preferLegacyStorage)149   public CachedContentIndex(
150       @Nullable DatabaseProvider databaseProvider,
151       @Nullable File legacyStorageDir,
152       @Nullable byte[] legacyStorageSecretKey,
153       boolean legacyStorageEncrypt,
154       boolean preferLegacyStorage) {
155     Assertions.checkState(databaseProvider != null || legacyStorageDir != null);
156     keyToContent = new HashMap<>();
157     idToKey = new SparseArray<>();
158     removedIds = new SparseBooleanArray();
159     newIds = new SparseBooleanArray();
160     Storage databaseStorage =
161         databaseProvider != null ? new DatabaseStorage(databaseProvider) : null;
162     Storage legacyStorage =
163         legacyStorageDir != null
164             ? new LegacyStorage(
165                 new File(legacyStorageDir, FILE_NAME_ATOMIC),
166                 legacyStorageSecretKey,
167                 legacyStorageEncrypt)
168             : null;
169     if (databaseStorage == null || (legacyStorage != null && preferLegacyStorage)) {
170       storage = legacyStorage;
171       previousStorage = databaseStorage;
172     } else {
173       storage = databaseStorage;
174       previousStorage = legacyStorage;
175     }
176   }
177 
178   /**
179    * Loads the index data for the given cache UID.
180    *
181    * <p>This method may be slow and shouldn't normally be called on the main thread.
182    *
183    * @param uid The UID of the cache whose index is to be loaded.
184    * @throws IOException If an error occurs initializing the index data.
185    */
186   @WorkerThread
initialize(long uid)187   public void initialize(long uid) throws IOException {
188     storage.initialize(uid);
189     if (previousStorage != null) {
190       previousStorage.initialize(uid);
191     }
192     if (!storage.exists() && previousStorage != null && previousStorage.exists()) {
193       // Copy from previous storage into current storage.
194       previousStorage.load(keyToContent, idToKey);
195       storage.storeFully(keyToContent);
196     } else {
197       // Load from the current storage.
198       storage.load(keyToContent, idToKey);
199     }
200     if (previousStorage != null) {
201       previousStorage.delete();
202       previousStorage = null;
203     }
204   }
205 
206   /**
207    * Stores the index data to index file if there is a change.
208    *
209    * <p>This method may be slow and shouldn't normally be called on the main thread.
210    *
211    * @throws IOException If an error occurs storing the index data.
212    */
213   @WorkerThread
store()214   public void store() throws IOException {
215     storage.storeIncremental(keyToContent);
216     // Make ids that were removed since the index was last stored eligible for re-use.
217     int removedIdCount = removedIds.size();
218     for (int i = 0; i < removedIdCount; i++) {
219       idToKey.remove(removedIds.keyAt(i));
220     }
221     removedIds.clear();
222     newIds.clear();
223   }
224 
225   /**
226    * Adds the given key to the index if it isn't there already.
227    *
228    * @param key The cache key that uniquely identifies the original stream.
229    * @return A new or existing CachedContent instance with the given key.
230    */
getOrAdd(String key)231   public CachedContent getOrAdd(String key) {
232     CachedContent cachedContent = keyToContent.get(key);
233     return cachedContent == null ? addNew(key) : cachedContent;
234   }
235 
236   /** Returns a CachedContent instance with the given key or null if there isn't one. */
get(String key)237   public CachedContent get(String key) {
238     return keyToContent.get(key);
239   }
240 
241   /**
242    * Returns a Collection of all CachedContent instances in the index. The collection is backed by
243    * the {@code keyToContent} map, so changes to the map are reflected in the collection, and
244    * vice-versa. If the map is modified while an iteration over the collection is in progress
245    * (except through the iterator's own remove operation), the results of the iteration are
246    * undefined.
247    */
getAll()248   public Collection<CachedContent> getAll() {
249     return keyToContent.values();
250   }
251 
252   /** Returns an existing or new id assigned to the given key. */
assignIdForKey(String key)253   public int assignIdForKey(String key) {
254     return getOrAdd(key).id;
255   }
256 
257   /** Returns the key which has the given id assigned. */
getKeyForId(int id)258   public String getKeyForId(int id) {
259     return idToKey.get(id);
260   }
261 
262   /** Removes {@link CachedContent} with the given key from index if it's empty and not locked. */
maybeRemove(String key)263   public void maybeRemove(String key) {
264     CachedContent cachedContent = keyToContent.get(key);
265     if (cachedContent != null && cachedContent.isEmpty() && !cachedContent.isLocked()) {
266       keyToContent.remove(key);
267       int id = cachedContent.id;
268       boolean neverStored = newIds.get(id);
269       storage.onRemove(cachedContent, neverStored);
270       if (neverStored) {
271         // The id can be reused immediately.
272         idToKey.remove(id);
273         newIds.delete(id);
274       } else {
275         // Keep an entry in idToKey to stop the id from being reused until the index is next stored,
276         // and add an entry to removedIds to track that it should be removed when this does happen.
277         idToKey.put(id, /* value= */ null);
278         removedIds.put(id, /* value= */ true);
279       }
280     }
281   }
282 
283   /** Removes empty and not locked {@link CachedContent} instances from index. */
removeEmpty()284   public void removeEmpty() {
285     String[] keys = new String[keyToContent.size()];
286     keyToContent.keySet().toArray(keys);
287     for (String key : keys) {
288       maybeRemove(key);
289     }
290   }
291 
292   /**
293    * Returns a set of all content keys. The set is backed by the {@code keyToContent} map, so
294    * changes to the map are reflected in the set, and vice-versa. If the map is modified while an
295    * iteration over the set is in progress (except through the iterator's own remove operation), the
296    * results of the iteration are undefined.
297    */
getKeys()298   public Set<String> getKeys() {
299     return keyToContent.keySet();
300   }
301 
302   /**
303    * Applies {@code mutations} to the {@link ContentMetadata} for the given key. A new {@link
304    * CachedContent} is added if there isn't one already with the given key.
305    */
applyContentMetadataMutations(String key, ContentMetadataMutations mutations)306   public void applyContentMetadataMutations(String key, ContentMetadataMutations mutations) {
307     CachedContent cachedContent = getOrAdd(key);
308     if (cachedContent.applyMetadataMutations(mutations)) {
309       storage.onUpdate(cachedContent);
310     }
311   }
312 
313   /** Returns a {@link ContentMetadata} for the given key. */
getContentMetadata(String key)314   public ContentMetadata getContentMetadata(String key) {
315     CachedContent cachedContent = get(key);
316     return cachedContent != null ? cachedContent.getMetadata() : DefaultContentMetadata.EMPTY;
317   }
318 
addNew(String key)319   private CachedContent addNew(String key) {
320     int id = getNewId(idToKey);
321     CachedContent cachedContent = new CachedContent(id, key);
322     keyToContent.put(key, cachedContent);
323     idToKey.put(id, key);
324     newIds.put(id, true);
325     storage.onUpdate(cachedContent);
326     return cachedContent;
327   }
328 
329   @SuppressLint("GetInstance") // Suppress warning about specifying "BC" as an explicit provider.
getCipher()330   private static Cipher getCipher() throws NoSuchPaddingException, NoSuchAlgorithmException {
331     // Workaround for https://issuetracker.google.com/issues/36976726
332     if (Util.SDK_INT == 18) {
333       try {
334         return Cipher.getInstance("AES/CBC/PKCS5PADDING", "BC");
335       } catch (Throwable ignored) {
336         // ignored
337       }
338     }
339     return Cipher.getInstance("AES/CBC/PKCS5PADDING");
340   }
341 
342   /**
343    * Returns an id which isn't used in the given array. If the maximum id in the array is smaller
344    * than {@link java.lang.Integer#MAX_VALUE} it just returns the next bigger integer. Otherwise it
345    * returns the smallest unused non-negative integer.
346    */
347   @VisibleForTesting
getNewId(SparseArray<String> idToKey)348   /* package */ static int getNewId(SparseArray<String> idToKey) {
349     int size = idToKey.size();
350     int id = size == 0 ? 0 : (idToKey.keyAt(size - 1) + 1);
351     if (id < 0) { // In case if we pass max int value.
352       // TODO optimization: defragmentation or binary search?
353       for (id = 0; id < size; id++) {
354         if (id != idToKey.keyAt(id)) {
355           break;
356         }
357       }
358     }
359     return id;
360   }
361 
362   /**
363    * Deserializes a {@link DefaultContentMetadata} from the given input stream.
364    *
365    * @param input Input stream to read from.
366    * @return a {@link DefaultContentMetadata} instance.
367    * @throws IOException If an error occurs during reading from the input.
368    */
readContentMetadata(DataInputStream input)369   private static DefaultContentMetadata readContentMetadata(DataInputStream input)
370       throws IOException {
371     int size = input.readInt();
372     HashMap<String, byte[]> metadata = new HashMap<>();
373     for (int i = 0; i < size; i++) {
374       String name = input.readUTF();
375       int valueSize = input.readInt();
376       if (valueSize < 0) {
377         throw new IOException("Invalid value size: " + valueSize);
378       }
379       // Grow the array incrementally to avoid OutOfMemoryError in the case that a corrupt (and very
380       // large) valueSize was read. In such cases the implementation below is expected to throw
381       // IOException from one of the readFully calls, due to the end of the input being reached.
382       int bytesRead = 0;
383       int nextBytesToRead = Math.min(valueSize, INCREMENTAL_METADATA_READ_LENGTH);
384       byte[] value = Util.EMPTY_BYTE_ARRAY;
385       while (bytesRead != valueSize) {
386         value = Arrays.copyOf(value, bytesRead + nextBytesToRead);
387         input.readFully(value, bytesRead, nextBytesToRead);
388         bytesRead += nextBytesToRead;
389         nextBytesToRead = Math.min(valueSize - bytesRead, INCREMENTAL_METADATA_READ_LENGTH);
390       }
391       metadata.put(name, value);
392     }
393     return new DefaultContentMetadata(metadata);
394   }
395 
396   /**
397    * Serializes itself to a {@link DataOutputStream}.
398    *
399    * @param output Output stream to store the values.
400    * @throws IOException If an error occurs writing to the output.
401    */
writeContentMetadata(DefaultContentMetadata metadata, DataOutputStream output)402   private static void writeContentMetadata(DefaultContentMetadata metadata, DataOutputStream output)
403       throws IOException {
404     Set<Map.Entry<String, byte[]>> entrySet = metadata.entrySet();
405     output.writeInt(entrySet.size());
406     for (Map.Entry<String, byte[]> entry : entrySet) {
407       output.writeUTF(entry.getKey());
408       byte[] value = entry.getValue();
409       output.writeInt(value.length);
410       output.write(value);
411     }
412   }
413 
414   /** Interface for the persistent index. */
415   private interface Storage {
416 
417     /** Initializes the storage for the given cache UID. */
initialize(long uid)418     void initialize(long uid);
419 
420     /**
421      * Returns whether the persisted index exists.
422      *
423      * @throws IOException If an error occurs determining whether the persisted index exists.
424      */
exists()425     boolean exists() throws IOException;
426 
427     /**
428      * Deletes the persisted index.
429      *
430      * @throws IOException If an error occurs deleting the index.
431      */
delete()432     void delete() throws IOException;
433 
434     /**
435      * Loads the persisted index into {@code content} and {@code idToKey}, creating it if it doesn't
436      * already exist.
437      *
438      * <p>If the persisted index is in a permanently bad state (i.e. all further attempts to load it
439      * are also expected to fail) then it will be deleted and the call will return successfully. For
440      * transient failures, {@link IOException} will be thrown.
441      *
442      * @param content The key to content map to populate with persisted data.
443      * @param idToKey The id to key map to populate with persisted data.
444      * @throws IOException If an error occurs loading the index.
445      */
load(HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey)446     void load(HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey)
447         throws IOException;
448 
449     /**
450      * Writes the persisted index, creating it if it doesn't already exist and replacing any
451      * existing content if it does.
452      *
453      * @param content The key to content map to persist.
454      * @throws IOException If an error occurs persisting the index.
455      */
storeFully(HashMap<String, CachedContent> content)456     void storeFully(HashMap<String, CachedContent> content) throws IOException;
457 
458     /**
459      * Ensures incremental changes to the index since the initial {@link #initialize(long)} or last
460      * {@link #storeFully(HashMap)} are persisted. The storage will have been notified of all such
461      * changes via {@link #onUpdate(CachedContent)} and {@link #onRemove(CachedContent, boolean)}.
462      *
463      * @param content The key to content map to persist.
464      * @throws IOException If an error occurs persisting the index.
465      */
storeIncremental(HashMap<String, CachedContent> content)466     void storeIncremental(HashMap<String, CachedContent> content) throws IOException;
467 
468     /**
469      * Called when a {@link CachedContent} is added or updated.
470      *
471      * @param cachedContent The updated {@link CachedContent}.
472      */
onUpdate(CachedContent cachedContent)473     void onUpdate(CachedContent cachedContent);
474 
475     /**
476      * Called when a {@link CachedContent} is removed.
477      *
478      * @param cachedContent The removed {@link CachedContent}.
479      * @param neverStored True if the {@link CachedContent} was added more recently than when the
480      *     index was last stored.
481      */
onRemove(CachedContent cachedContent, boolean neverStored)482     void onRemove(CachedContent cachedContent, boolean neverStored);
483   }
484 
485   /** {@link Storage} implementation that uses an {@link AtomicFile}. */
486   private static class LegacyStorage implements Storage {
487 
488     private static final int VERSION = 2;
489     private static final int VERSION_METADATA_INTRODUCED = 2;
490     private static final int FLAG_ENCRYPTED_INDEX = 1;
491 
492     private final boolean encrypt;
493     @Nullable private final Cipher cipher;
494     @Nullable private final SecretKeySpec secretKeySpec;
495     @Nullable private final Random random;
496     private final AtomicFile atomicFile;
497 
498     private boolean changed;
499     @Nullable private ReusableBufferedOutputStream bufferedOutputStream;
500 
LegacyStorage(File file, @Nullable byte[] secretKey, boolean encrypt)501     public LegacyStorage(File file, @Nullable byte[] secretKey, boolean encrypt) {
502       Cipher cipher = null;
503       SecretKeySpec secretKeySpec = null;
504       if (secretKey != null) {
505         Assertions.checkArgument(secretKey.length == 16);
506         try {
507           cipher = getCipher();
508           secretKeySpec = new SecretKeySpec(secretKey, "AES");
509         } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
510           throw new IllegalStateException(e); // Should never happen.
511         }
512       } else {
513         Assertions.checkArgument(!encrypt);
514       }
515       this.encrypt = encrypt;
516       this.cipher = cipher;
517       this.secretKeySpec = secretKeySpec;
518       random = encrypt ? new Random() : null;
519       atomicFile = new AtomicFile(file);
520     }
521 
522     @Override
initialize(long uid)523     public void initialize(long uid) {
524       // Do nothing. Legacy storage uses a separate file for each cache.
525     }
526 
527     @Override
exists()528     public boolean exists() {
529       return atomicFile.exists();
530     }
531 
532     @Override
delete()533     public void delete() {
534       atomicFile.delete();
535     }
536 
537     @Override
load( HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey)538     public void load(
539         HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey) {
540       Assertions.checkState(!changed);
541       if (!readFile(content, idToKey)) {
542         content.clear();
543         idToKey.clear();
544         atomicFile.delete();
545       }
546     }
547 
548     @Override
storeFully(HashMap<String, CachedContent> content)549     public void storeFully(HashMap<String, CachedContent> content) throws IOException {
550       writeFile(content);
551       changed = false;
552     }
553 
554     @Override
storeIncremental(HashMap<String, CachedContent> content)555     public void storeIncremental(HashMap<String, CachedContent> content) throws IOException {
556       if (!changed) {
557         return;
558       }
559       storeFully(content);
560     }
561 
562     @Override
onUpdate(CachedContent cachedContent)563     public void onUpdate(CachedContent cachedContent) {
564       changed = true;
565     }
566 
567     @Override
onRemove(CachedContent cachedContent, boolean neverStored)568     public void onRemove(CachedContent cachedContent, boolean neverStored) {
569       changed = true;
570     }
571 
readFile( HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey)572     private boolean readFile(
573         HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey) {
574       if (!atomicFile.exists()) {
575         return true;
576       }
577 
578       DataInputStream input = null;
579       try {
580         InputStream inputStream = new BufferedInputStream(atomicFile.openRead());
581         input = new DataInputStream(inputStream);
582         int version = input.readInt();
583         if (version < 0 || version > VERSION) {
584           return false;
585         }
586 
587         int flags = input.readInt();
588         if ((flags & FLAG_ENCRYPTED_INDEX) != 0) {
589           if (cipher == null) {
590             return false;
591           }
592           byte[] initializationVector = new byte[16];
593           input.readFully(initializationVector);
594           IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector);
595           try {
596             cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec);
597           } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
598             throw new IllegalStateException(e);
599           }
600           input = new DataInputStream(new CipherInputStream(inputStream, cipher));
601         } else if (encrypt) {
602           changed = true; // Force index to be rewritten encrypted after read.
603         }
604 
605         int count = input.readInt();
606         int hashCode = 0;
607         for (int i = 0; i < count; i++) {
608           CachedContent cachedContent = readCachedContent(version, input);
609           content.put(cachedContent.key, cachedContent);
610           idToKey.put(cachedContent.id, cachedContent.key);
611           hashCode += hashCachedContent(cachedContent, version);
612         }
613         int fileHashCode = input.readInt();
614         boolean isEOF = input.read() == -1;
615         if (fileHashCode != hashCode || !isEOF) {
616           return false;
617         }
618       } catch (IOException e) {
619         return false;
620       } finally {
621         if (input != null) {
622           Util.closeQuietly(input);
623         }
624       }
625       return true;
626     }
627 
writeFile(HashMap<String, CachedContent> content)628     private void writeFile(HashMap<String, CachedContent> content) throws IOException {
629       DataOutputStream output = null;
630       try {
631         OutputStream outputStream = atomicFile.startWrite();
632         if (bufferedOutputStream == null) {
633           bufferedOutputStream = new ReusableBufferedOutputStream(outputStream);
634         } else {
635           bufferedOutputStream.reset(outputStream);
636         }
637         output = new DataOutputStream(bufferedOutputStream);
638         output.writeInt(VERSION);
639 
640         int flags = encrypt ? FLAG_ENCRYPTED_INDEX : 0;
641         output.writeInt(flags);
642 
643         if (encrypt) {
644           byte[] initializationVector = new byte[16];
645           random.nextBytes(initializationVector);
646           output.write(initializationVector);
647           IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector);
648           try {
649             cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec);
650           } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
651             throw new IllegalStateException(e); // Should never happen.
652           }
653           output.flush();
654           output = new DataOutputStream(new CipherOutputStream(bufferedOutputStream, cipher));
655         }
656 
657         output.writeInt(content.size());
658         int hashCode = 0;
659         for (CachedContent cachedContent : content.values()) {
660           writeCachedContent(cachedContent, output);
661           hashCode += hashCachedContent(cachedContent, VERSION);
662         }
663         output.writeInt(hashCode);
664         atomicFile.endWrite(output);
665         // Avoid calling close twice. Duplicate CipherOutputStream.close calls did
666         // not used to be no-ops: https://android-review.googlesource.com/#/c/272799/
667         output = null;
668       } finally {
669         Util.closeQuietly(output);
670       }
671     }
672 
673     /**
674      * Calculates a hash code for a {@link CachedContent} which is compatible with a particular
675      * index version.
676      */
hashCachedContent(CachedContent cachedContent, int version)677     private int hashCachedContent(CachedContent cachedContent, int version) {
678       int result = cachedContent.id;
679       result = 31 * result + cachedContent.key.hashCode();
680       if (version < VERSION_METADATA_INTRODUCED) {
681         long length = ContentMetadata.getContentLength(cachedContent.getMetadata());
682         result = 31 * result + (int) (length ^ (length >>> 32));
683       } else {
684         result = 31 * result + cachedContent.getMetadata().hashCode();
685       }
686       return result;
687     }
688 
689     /**
690      * Reads a {@link CachedContent} from a {@link DataInputStream}.
691      *
692      * @param version Version of the encoded data.
693      * @param input Input stream containing values needed to initialize CachedContent instance.
694      * @throws IOException If an error occurs during reading values.
695      */
readCachedContent(int version, DataInputStream input)696     private CachedContent readCachedContent(int version, DataInputStream input) throws IOException {
697       int id = input.readInt();
698       String key = input.readUTF();
699       DefaultContentMetadata metadata;
700       if (version < VERSION_METADATA_INTRODUCED) {
701         long length = input.readLong();
702         ContentMetadataMutations mutations = new ContentMetadataMutations();
703         ContentMetadataMutations.setContentLength(mutations, length);
704         metadata = DefaultContentMetadata.EMPTY.copyWithMutationsApplied(mutations);
705       } else {
706         metadata = readContentMetadata(input);
707       }
708       return new CachedContent(id, key, metadata);
709     }
710 
711     /**
712      * Writes a {@link CachedContent} to a {@link DataOutputStream}.
713      *
714      * @param output Output stream to store the values.
715      * @throws IOException If an error occurs during writing values to output.
716      */
writeCachedContent(CachedContent cachedContent, DataOutputStream output)717     private void writeCachedContent(CachedContent cachedContent, DataOutputStream output)
718         throws IOException {
719       output.writeInt(cachedContent.id);
720       output.writeUTF(cachedContent.key);
721       writeContentMetadata(cachedContent.getMetadata(), output);
722     }
723   }
724 
725   /** {@link Storage} implementation that uses an SQL database. */
726   private static final class DatabaseStorage implements Storage {
727 
728     private static final String TABLE_PREFIX = DatabaseProvider.TABLE_PREFIX + "CacheIndex";
729     private static final int TABLE_VERSION = 1;
730 
731     private static final String COLUMN_ID = "id";
732     private static final String COLUMN_KEY = "key";
733     private static final String COLUMN_METADATA = "metadata";
734 
735     private static final int COLUMN_INDEX_ID = 0;
736     private static final int COLUMN_INDEX_KEY = 1;
737     private static final int COLUMN_INDEX_METADATA = 2;
738 
739     private static final String WHERE_ID_EQUALS = COLUMN_ID + " = ?";
740 
741     private static final String[] COLUMNS = new String[] {COLUMN_ID, COLUMN_KEY, COLUMN_METADATA};
742     private static final String TABLE_SCHEMA =
743         "("
744             + COLUMN_ID
745             + " INTEGER PRIMARY KEY NOT NULL,"
746             + COLUMN_KEY
747             + " TEXT NOT NULL,"
748             + COLUMN_METADATA
749             + " BLOB NOT NULL)";
750 
751     private final DatabaseProvider databaseProvider;
752     private final SparseArray<CachedContent> pendingUpdates;
753 
754     private String hexUid;
755     private String tableName;
756 
delete(DatabaseProvider databaseProvider, long uid)757     public static void delete(DatabaseProvider databaseProvider, long uid)
758         throws DatabaseIOException {
759       delete(databaseProvider, Long.toHexString(uid));
760     }
761 
DatabaseStorage(DatabaseProvider databaseProvider)762     public DatabaseStorage(DatabaseProvider databaseProvider) {
763       this.databaseProvider = databaseProvider;
764       pendingUpdates = new SparseArray<>();
765     }
766 
767     @Override
initialize(long uid)768     public void initialize(long uid) {
769       hexUid = Long.toHexString(uid);
770       tableName = getTableName(hexUid);
771     }
772 
773     @Override
exists()774     public boolean exists() throws DatabaseIOException {
775       return VersionTable.getVersion(
776               databaseProvider.getReadableDatabase(),
777               VersionTable.FEATURE_CACHE_CONTENT_METADATA,
778               hexUid)
779           != VersionTable.VERSION_UNSET;
780     }
781 
782     @Override
delete()783     public void delete() throws DatabaseIOException {
784       delete(databaseProvider, hexUid);
785     }
786 
787     @Override
load( HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey)788     public void load(
789         HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey)
790         throws IOException {
791       Assertions.checkState(pendingUpdates.size() == 0);
792       try {
793         int version =
794             VersionTable.getVersion(
795                 databaseProvider.getReadableDatabase(),
796                 VersionTable.FEATURE_CACHE_CONTENT_METADATA,
797                 hexUid);
798         if (version != TABLE_VERSION) {
799           SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
800           writableDatabase.beginTransactionNonExclusive();
801           try {
802             initializeTable(writableDatabase);
803             writableDatabase.setTransactionSuccessful();
804           } finally {
805             writableDatabase.endTransaction();
806           }
807         }
808 
809         try (Cursor cursor = getCursor()) {
810           while (cursor.moveToNext()) {
811             int id = cursor.getInt(COLUMN_INDEX_ID);
812             String key = cursor.getString(COLUMN_INDEX_KEY);
813             byte[] metadataBytes = cursor.getBlob(COLUMN_INDEX_METADATA);
814 
815             ByteArrayInputStream inputStream = new ByteArrayInputStream(metadataBytes);
816             DataInputStream input = new DataInputStream(inputStream);
817             DefaultContentMetadata metadata = readContentMetadata(input);
818 
819             CachedContent cachedContent = new CachedContent(id, key, metadata);
820             content.put(cachedContent.key, cachedContent);
821             idToKey.put(cachedContent.id, cachedContent.key);
822           }
823         }
824       } catch (SQLiteException e) {
825         content.clear();
826         idToKey.clear();
827         throw new DatabaseIOException(e);
828       }
829     }
830 
831     @Override
storeFully(HashMap<String, CachedContent> content)832     public void storeFully(HashMap<String, CachedContent> content) throws IOException {
833       try {
834         SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
835         writableDatabase.beginTransactionNonExclusive();
836         try {
837           initializeTable(writableDatabase);
838           for (CachedContent cachedContent : content.values()) {
839             addOrUpdateRow(writableDatabase, cachedContent);
840           }
841           writableDatabase.setTransactionSuccessful();
842           pendingUpdates.clear();
843         } finally {
844           writableDatabase.endTransaction();
845         }
846       } catch (SQLException e) {
847         throw new DatabaseIOException(e);
848       }
849     }
850 
851     @Override
storeIncremental(HashMap<String, CachedContent> content)852     public void storeIncremental(HashMap<String, CachedContent> content) throws IOException {
853       if (pendingUpdates.size() == 0) {
854         return;
855       }
856       try {
857         SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
858         writableDatabase.beginTransactionNonExclusive();
859         try {
860           for (int i = 0; i < pendingUpdates.size(); i++) {
861             CachedContent cachedContent = pendingUpdates.valueAt(i);
862             if (cachedContent == null) {
863               deleteRow(writableDatabase, pendingUpdates.keyAt(i));
864             } else {
865               addOrUpdateRow(writableDatabase, cachedContent);
866             }
867           }
868           writableDatabase.setTransactionSuccessful();
869           pendingUpdates.clear();
870         } finally {
871           writableDatabase.endTransaction();
872         }
873       } catch (SQLException e) {
874         throw new DatabaseIOException(e);
875       }
876     }
877 
878     @Override
onUpdate(CachedContent cachedContent)879     public void onUpdate(CachedContent cachedContent) {
880       pendingUpdates.put(cachedContent.id, cachedContent);
881     }
882 
883     @Override
onRemove(CachedContent cachedContent, boolean neverStored)884     public void onRemove(CachedContent cachedContent, boolean neverStored) {
885       if (neverStored) {
886         pendingUpdates.delete(cachedContent.id);
887       } else {
888         pendingUpdates.put(cachedContent.id, null);
889       }
890     }
891 
getCursor()892     private Cursor getCursor() {
893       return databaseProvider
894           .getReadableDatabase()
895           .query(
896               tableName,
897               COLUMNS,
898               /* selection= */ null,
899               /* selectionArgs= */ null,
900               /* groupBy= */ null,
901               /* having= */ null,
902               /* orderBy= */ null);
903     }
904 
initializeTable(SQLiteDatabase writableDatabase)905     private void initializeTable(SQLiteDatabase writableDatabase) throws DatabaseIOException {
906       VersionTable.setVersion(
907           writableDatabase, VersionTable.FEATURE_CACHE_CONTENT_METADATA, hexUid, TABLE_VERSION);
908       dropTable(writableDatabase, tableName);
909       writableDatabase.execSQL("CREATE TABLE " + tableName + " " + TABLE_SCHEMA);
910     }
911 
deleteRow(SQLiteDatabase writableDatabase, int key)912     private void deleteRow(SQLiteDatabase writableDatabase, int key) {
913       writableDatabase.delete(tableName, WHERE_ID_EQUALS, new String[] {Integer.toString(key)});
914     }
915 
addOrUpdateRow(SQLiteDatabase writableDatabase, CachedContent cachedContent)916     private void addOrUpdateRow(SQLiteDatabase writableDatabase, CachedContent cachedContent)
917         throws IOException {
918       ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
919       writeContentMetadata(cachedContent.getMetadata(), new DataOutputStream(outputStream));
920       byte[] data = outputStream.toByteArray();
921 
922       ContentValues values = new ContentValues();
923       values.put(COLUMN_ID, cachedContent.id);
924       values.put(COLUMN_KEY, cachedContent.key);
925       values.put(COLUMN_METADATA, data);
926       writableDatabase.replaceOrThrow(tableName, /* nullColumnHack= */ null, values);
927     }
928 
delete(DatabaseProvider databaseProvider, String hexUid)929     private static void delete(DatabaseProvider databaseProvider, String hexUid)
930         throws DatabaseIOException {
931       try {
932         String tableName = getTableName(hexUid);
933         SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase();
934         writableDatabase.beginTransactionNonExclusive();
935         try {
936           VersionTable.removeVersion(
937               writableDatabase, VersionTable.FEATURE_CACHE_CONTENT_METADATA, hexUid);
938           dropTable(writableDatabase, tableName);
939           writableDatabase.setTransactionSuccessful();
940         } finally {
941           writableDatabase.endTransaction();
942         }
943       } catch (SQLException e) {
944         throw new DatabaseIOException(e);
945       }
946     }
947 
dropTable(SQLiteDatabase writableDatabase, String tableName)948     private static void dropTable(SQLiteDatabase writableDatabase, String tableName) {
949       writableDatabase.execSQL("DROP TABLE IF EXISTS " + tableName);
950     }
951 
getTableName(String hexUid)952     private static String getTableName(String hexUid) {
953       return TABLE_PREFIX + hexUid;
954     }
955   }
956 }
957