1 /*
2  * Copyright (C) 2022 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.adservices.shared.storage;
18 
19 import static com.android.adservices.shared.util.LogUtil.DEBUG;
20 import static com.android.adservices.shared.util.LogUtil.VERBOSE;
21 
22 import android.annotation.Nullable;
23 import android.os.PersistableBundle;
24 import android.util.AtomicFile;
25 
26 import com.android.adservices.shared.util.LogUtil;
27 import com.android.internal.annotations.GuardedBy;
28 import com.android.internal.annotations.VisibleForTesting;
29 import com.android.internal.util.Preconditions;
30 import com.android.modules.utils.build.SdkLevel;
31 
32 import java.io.ByteArrayInputStream;
33 import java.io.ByteArrayOutputStream;
34 import java.io.File;
35 import java.io.FileNotFoundException;
36 import java.io.FileOutputStream;
37 import java.io.IOException;
38 import java.io.PrintWriter;
39 import java.util.HashMap;
40 import java.util.HashSet;
41 import java.util.Map;
42 import java.util.Objects;
43 import java.util.Set;
44 import java.util.concurrent.locks.Lock;
45 import java.util.concurrent.locks.ReadWriteLock;
46 import java.util.concurrent.locks.ReentrantReadWriteLock;
47 import java.util.stream.Collectors;
48 
49 /**
50  * A simple datastore utilizing {@link android.util.AtomicFile} and {@link
51  * android.os.PersistableBundle} to read/write a simple key/value map to file.
52  *
53  * <p>The datastore is loaded from file only when initialized and written to file on every write.
54  * When using this datastore, it is up to the caller to ensure that each datastore file is accessed
55  * by exactly one datastore object. If multiple writing threads or processes attempt to use
56  * different instances pointing to the same file, transactions may be lost.
57  *
58  * <p>Keys must be non-{@code null}, non-empty strings, and values must be booleans.
59  *
60  * @threadsafe
61  */
62 public class BooleanFileDatastore {
63     public static final int NO_PREVIOUS_VERSION = -1;
64 
65     private final int mDatastoreVersion;
66 
67     private final ReadWriteLock mReadWriteLock = new ReentrantReadWriteLock();
68     private final Lock mReadLock = mReadWriteLock.readLock();
69     private final Lock mWriteLock = mReadWriteLock.writeLock();
70 
71     private final AtomicFile mAtomicFile;
72     private final Map<String, Boolean> mLocalMap = new HashMap<>();
73 
74     private final String mVersionKey;
75     private int mPreviousStoredVersion;
76 
BooleanFileDatastore( String parentPath, String filename, int datastoreVersion, String versionKey)77     public BooleanFileDatastore(
78             String parentPath, String filename, int datastoreVersion, String versionKey) {
79         this(newFile(parentPath, filename), datastoreVersion, versionKey);
80     }
81 
BooleanFileDatastore(File file, int datastoreVersion, String versionKey)82     public BooleanFileDatastore(File file, int datastoreVersion, String versionKey) {
83         mAtomicFile = new AtomicFile(Objects.requireNonNull(file));
84         mDatastoreVersion =
85                 Preconditions.checkArgumentNonnegative(
86                         datastoreVersion, "Version must not be negative");
87 
88         mVersionKey = Objects.requireNonNull(versionKey);
89     }
90 
91     /**
92      * Loads data from the datastore file.
93      *
94      * @throws IOException if file read fails
95      */
initialize()96     public final void initialize() throws IOException {
97         if (DEBUG) {
98             LogUtil.d("Reading from store file: %s", mAtomicFile.getBaseFile());
99         }
100         mReadLock.lock();
101         try {
102             readFromFile();
103         } finally {
104             mReadLock.unlock();
105         }
106 
107         // In the future, this could be a good place for upgrade/rollback for schemas
108     }
109 
110     // Writes the class member map to a PersistableBundle which is then written to file.
111     @GuardedBy("mWriteLock")
writeToFile()112     private void writeToFile() throws IOException {
113         final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
114         final PersistableBundle bundleToWrite = new PersistableBundle();
115 
116         for (Map.Entry<String, Boolean> entry : mLocalMap.entrySet()) {
117             bundleToWrite.putBoolean(entry.getKey(), entry.getValue());
118         }
119 
120         // Version unused for now. May be needed in the future for handling migrations.
121         bundleToWrite.putInt(mVersionKey, mDatastoreVersion);
122         bundleToWrite.writeToStream(outputStream);
123 
124         FileOutputStream out = null;
125         try {
126             out = mAtomicFile.startWrite();
127             out.write(outputStream.toByteArray());
128             mAtomicFile.finishWrite(out);
129         } catch (IOException e) {
130             if (out != null) {
131                 mAtomicFile.failWrite(out);
132             }
133             LogUtil.e(e, "Write to file failed");
134             throw e;
135         }
136     }
137 
138     // Note that this completely replaces the loaded datastore with the file's data, instead of
139     // appending new file data.
140     @GuardedBy("mReadLock")
readFromFile()141     private void readFromFile() throws IOException {
142         try {
143             final ByteArrayInputStream inputStream =
144                     new ByteArrayInputStream(mAtomicFile.readFully());
145             final PersistableBundle bundleRead = PersistableBundle.readFromStream(inputStream);
146 
147             mPreviousStoredVersion = bundleRead.getInt(mVersionKey, NO_PREVIOUS_VERSION);
148             bundleRead.remove(mVersionKey);
149             mLocalMap.clear();
150             for (String key : bundleRead.keySet()) {
151                 mLocalMap.put(key, bundleRead.getBoolean(key));
152             }
153         } catch (FileNotFoundException e) {
154             if (VERBOSE) {
155                 LogUtil.v("File not found; continuing with clear database");
156             }
157             mPreviousStoredVersion = NO_PREVIOUS_VERSION;
158             mLocalMap.clear();
159         } catch (IOException e) {
160             LogUtil.e(e, "Read from store file failed");
161             throw e;
162         }
163     }
164 
165     /**
166      * Stores a value to the datastore file.
167      *
168      * <p>This change is committed immediately to file.
169      *
170      * @param key A non-null, non-empty String key to store the {@code value} against
171      * @param value A boolean to be stored
172      * @throws IllegalArgumentException if {@code key} is an empty string
173      * @throws IOException if file write fails
174      * @throws NullPointerException if {@code key} is null
175      */
put(String key, boolean value)176     public final void put(String key, boolean value) throws IOException {
177         Objects.requireNonNull(key);
178         Preconditions.checkStringNotEmpty(key, "Key must not be empty");
179 
180         mWriteLock.lock();
181         try {
182             mLocalMap.put(key, value);
183             writeToFile();
184         } finally {
185             mWriteLock.unlock();
186         }
187     }
188 
189     /**
190      * Stores a value to the datastore file, but only if the key does not already exist.
191      *
192      * <p>If a change is made to the datastore, it is committed immediately to file.
193      *
194      * @param key A non-null, non-empty String key to store the {@code value} against
195      * @param value A boolean to be stored
196      * @return the value that exists in the datastore after the operation completes
197      * @throws IllegalArgumentException if {@code key} is an empty string
198      * @throws IOException if file write fails
199      * @throws NullPointerException if {@code key} is null
200      */
putIfNew(String key, boolean value)201     public final boolean putIfNew(String key, boolean value) throws IOException {
202         Objects.requireNonNull(key);
203         Preconditions.checkStringNotEmpty(key, "Key must not be empty");
204 
205         // Try not to block readers first before trying to write
206         mReadLock.lock();
207         try {
208             Boolean valueInLocalMap = mLocalMap.get(key);
209             if (valueInLocalMap != null) {
210                 return valueInLocalMap;
211             }
212         } finally {
213             mReadLock.unlock();
214         }
215 
216         // Double check that the key wasn't written after the first check
217         mWriteLock.lock();
218         try {
219             Boolean valueInLocalMap = mLocalMap.get(key);
220             if (valueInLocalMap != null) {
221                 return valueInLocalMap;
222             } else {
223                 mLocalMap.put(key, value);
224                 writeToFile();
225                 return value;
226             }
227         } finally {
228             mWriteLock.unlock();
229         }
230     }
231 
232     /**
233      * Retrieves a boolean value from the loaded datastore file.
234      *
235      * @param key A non-null, non-empty String key to fetch a value from
236      * @return The value stored against a {@code key}, or null if it doesn't exist
237      * @throws IllegalArgumentException if {@code key} is an empty string
238      * @throws NullPointerException if {@code key} is null
239      */
240     @Nullable
get(String key)241     public final Boolean get(String key) {
242         Objects.requireNonNull(key);
243         Preconditions.checkStringNotEmpty(key, "Key must not be empty");
244 
245         mReadLock.lock();
246         try {
247             return mLocalMap.get(key);
248         } finally {
249             mReadLock.unlock();
250         }
251     }
252 
253     /**
254      * Retrieves a boolean value from the loaded datastore file.
255      *
256      * @param key A non-null, non-empty String key to fetch a value from
257      * @param defaultValue Value to return if this key does not exist.
258      * @throws IllegalArgumentException if {@code key} is an empty string
259      * @throws NullPointerException if {@code key} is null
260      */
261     @Nullable
get(String key, boolean defaultValue)262     public final Boolean get(String key, boolean defaultValue) {
263         Objects.requireNonNull(key);
264         Preconditions.checkStringNotEmpty(key, "Key must not be empty");
265 
266         mReadLock.lock();
267         try {
268             return mLocalMap.containsKey(key) ? mLocalMap.get(key) : defaultValue;
269         } finally {
270             mReadLock.unlock();
271         }
272     }
273 
274     /** Returns the version that was written prior to the device starting. */
getPreviousStoredVersion()275     public final int getPreviousStoredVersion() {
276         return mPreviousStoredVersion;
277     }
278 
279     /**
280      * Retrieves a {@link Set} of all keys loaded from the datastore file.
281      *
282      * @return A {@link Set} of {@link String} keys currently in the loaded datastore
283      */
keySet()284     public final Set<String> keySet() {
285         mReadLock.lock();
286         try {
287             return getSafeSetCopy(mLocalMap.keySet());
288         } finally {
289             mReadLock.unlock();
290         }
291     }
292 
keySetFilter(boolean filter)293     private Set<String> keySetFilter(boolean filter) {
294         mReadLock.lock();
295         try {
296             return getSafeSetCopy(
297                     mLocalMap.entrySet().stream()
298                             .filter(entry -> entry.getValue().equals(filter))
299                             .map(Map.Entry::getKey)
300                             .collect(Collectors.toSet()));
301         } finally {
302             mReadLock.unlock();
303         }
304     }
305 
306     /**
307      * Retrieves a Set of all keys with value {@code true} loaded from the datastore file.
308      *
309      * @return A Set of String keys currently in the loaded datastore that have value {@code true}
310      */
keySetTrue()311     public final Set<String> keySetTrue() {
312         return keySetFilter(true);
313     }
314 
315     /**
316      * Retrieves a Set of all keys with value {@code false} loaded from the datastore file.
317      *
318      * @return A Set of String keys currently in the loaded datastore that have value {@code false}
319      */
keySetFalse()320     public final Set<String> keySetFalse() {
321         return keySetFilter(false);
322     }
323 
324     /** Gets the version key. */
getVersionKey()325     public final String getVersionKey() {
326         return mVersionKey;
327     }
328 
329     /**
330      * Clears all entries from the datastore file.
331      *
332      * <p>This change is committed immediately to file.
333      *
334      * @throws IOException if file write fails
335      */
clear()336     public final void clear() throws IOException {
337         if (DEBUG) {
338             LogUtil.d("Clearing all entries from datastore");
339         }
340 
341         mWriteLock.lock();
342         try {
343             mLocalMap.clear();
344             writeToFile();
345         } finally {
346             mWriteLock.unlock();
347         }
348     }
349 
clearByFilter(boolean filter)350     private void clearByFilter(boolean filter) throws IOException {
351         mWriteLock.lock();
352         try {
353             mLocalMap.entrySet().removeIf(entry -> entry.getValue().equals(filter));
354             writeToFile();
355         } finally {
356             mWriteLock.unlock();
357         }
358     }
359 
360     /**
361      * Clears all entries from the datastore file that have value {@code true}. Entries with value
362      * {@code false} are not removed.
363      *
364      * <p>This change is committed immediately to file.
365      *
366      * @throws IOException if file write fails
367      */
clearAllTrue()368     public void clearAllTrue() throws IOException {
369         clearByFilter(true);
370     }
371 
372     /**
373      * Clears all entries from the datastore file that have value {@code false}. Entries with value
374      * {@code true} are not removed.
375      *
376      * <p>This change is committed immediately to file.
377      *
378      * @throws IOException if file write fails
379      */
clearAllFalse()380     public void clearAllFalse() throws IOException {
381         clearByFilter(false);
382     }
383 
384     /**
385      * Removes an entry from the datastore file.
386      *
387      * <p>This change is committed immediately to file.
388      *
389      * @param key A non-null, non-empty String key to remove
390      * @throws IllegalArgumentException if {@code key} is an empty string
391      * @throws IOException if file write fails
392      * @throws NullPointerException if {@code key} is null
393      */
remove(String key)394     public void remove(String key) throws IOException {
395         Objects.requireNonNull(key);
396         Preconditions.checkStringNotEmpty(key, "Key must not be empty");
397 
398         mWriteLock.lock();
399         try {
400             mLocalMap.remove(key);
401             writeToFile();
402         } finally {
403             mWriteLock.unlock();
404         }
405     }
406 
407     /**
408      * Removes all entries that begin with the specified prefix from the datastore file.
409      *
410      * <p>This change is committed immediately to file.
411      *
412      * @param prefix A non-null, non-empty string that all keys are matched against
413      * @throws NullPointerException if {@code prefix} is null
414      * @throws IllegalArgumentException if {@code prefix} is an empty string
415      * @throws IOException if file write fails
416      */
removeByPrefix(String prefix)417     public void removeByPrefix(String prefix) throws IOException {
418         Objects.requireNonNull(prefix);
419         Preconditions.checkStringNotEmpty(prefix, "Prefix must not be empty");
420 
421         mWriteLock.lock();
422         try {
423             Set<String> allKeys = mLocalMap.keySet();
424             Set<String> keysToDelete =
425                     allKeys.stream().filter(s -> s.startsWith(prefix)).collect(Collectors.toSet());
426             allKeys.removeAll(keysToDelete); // Modifying the keySet updates the underlying map
427             writeToFile();
428         } finally {
429             mWriteLock.unlock();
430         }
431     }
432 
433     /** Dumps its internal state. */
dump(PrintWriter writer, String prefix)434     public void dump(PrintWriter writer, String prefix) {
435         writer.printf("%smDatastoreVersion: %d\n", prefix, mDatastoreVersion);
436         writer.printf("%smPreviousStoredVersion: %d\n", prefix, mPreviousStoredVersion);
437         writer.printf("%smVersionKey: %s\n", prefix, mVersionKey);
438         writer.printf("%smAtomicFile: %s", prefix, mAtomicFile.getBaseFile().getAbsolutePath());
439         if (SdkLevel.isAtLeastS()) {
440             writer.printf(" (last modified at %d)", mAtomicFile.getLastModifiedTime());
441         }
442         int size = mLocalMap.size();
443         writer.printf(":\n%s%d entries\n", prefix, size);
444 
445         // TODO(b/299942046): decide whether it's ok to dump the entries themselves (perhaps passing
446         // an argument).
447     }
448 
449     /** For tests only */
450     @VisibleForTesting
tearDownForTesting()451     public void tearDownForTesting() {
452         mWriteLock.lock();
453         try {
454             mAtomicFile.delete();
455             mLocalMap.clear();
456         } finally {
457             mWriteLock.unlock();
458         }
459     }
460 
newFile(String parentPath, String filename)461     private static File newFile(String parentPath, String filename) {
462         Preconditions.checkStringNotEmpty(parentPath, "parentPath must not be empty or null");
463         Preconditions.checkStringNotEmpty(filename, "filename must not be empty or null");
464         File parent = new File(parentPath);
465         if (!parent.exists()) {
466             throw new IllegalArgumentException(
467                     "parentPath doesn't exist: " + parent.getAbsolutePath());
468         }
469         if (!parent.isDirectory()) {
470             throw new IllegalArgumentException(
471                     "parentPath is not a directory: " + parent.getAbsolutePath());
472         }
473         return new File(parent, filename);
474     }
475 
476     // TODO(b/335869310): change it to using ImmutableSet.
getSafeSetCopy(Set<T> sourceSet)477     private static <T> Set<T> getSafeSetCopy(Set<T> sourceSet) {
478         return new HashSet<>(sourceSet);
479     }
480 }
481