• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 2022 Google LLC
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 com.google.android.libraries.mobiledatadownload.internal.logging;
17 
18 import static com.google.android.libraries.mobiledatadownload.internal.MddConstants.SPLIT_CHAR;
19 
20 import static java.util.concurrent.TimeUnit.MILLISECONDS;
21 
22 import android.content.Context;
23 import android.content.SharedPreferences;
24 
25 import com.google.android.libraries.mobiledatadownload.TimeSource;
26 import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupsMetadataUtil;
27 import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupsMetadataUtil.GroupKeyDeserializationException;
28 import com.google.android.libraries.mobiledatadownload.internal.util.SharedPreferencesUtil;
29 import com.google.android.libraries.mobiledatadownload.tracing.PropagatedExecutionSequencer;
30 import com.google.common.base.Optional;
31 import com.google.common.base.Splitter;
32 import com.google.common.base.Supplier;
33 import com.google.common.base.Suppliers;
34 import com.google.common.primitives.Ints;
35 import com.google.common.util.concurrent.ListenableFuture;
36 import com.google.mobiledatadownload.internal.MetadataProto.FileGroupLoggingState;
37 import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
38 import com.google.mobiledatadownload.internal.MetadataProto.SamplingInfo;
39 import com.google.protobuf.Timestamp;
40 
41 import java.io.IOException;
42 import java.util.ArrayList;
43 import java.util.Calendar;
44 import java.util.GregorianCalendar;
45 import java.util.HashSet;
46 import java.util.List;
47 import java.util.Random;
48 import java.util.Set;
49 import java.util.TimeZone;
50 import java.util.concurrent.Executor;
51 
52 /** LoggingStateStore that uses SharedPreferences for storage. */
53 public final class SharedPreferencesLoggingState implements LoggingStateStore {
54 
55     private static final String SHARED_PREFS_NAME = "LoggingState";
56 
57     private static final String LAST_MAINTENANCE_RUN_SECS_KEY = "last_maintenance_secs";
58 
59     private static final String SALT_KEY = "stable_log_sampling_salt";
60     private static final String SALT_TIMESTAMP_MILLIS_KEY =
61             "log_sampling_salt_set_timestamp_millis";
62 
63     private final Supplier<SharedPreferences> sharedPrefs;
64     private final Executor backgroundExecutor;
65     private final TimeSource timeSource;
66     private final Random random;
67 
68     // Serialize access to SharedPref keys to avoid clobbering.
69     private final PropagatedExecutionSequencer futureSerializer =
70             PropagatedExecutionSequencer.create();
71 
72     /**
73      * Constructs a new instance.
74      *
75      * @param sharedPrefs may be called multiple times, so memoization is recommended. The returned
76      *                    instance must be exclusive to {@link SharedPreferencesLoggingState} since
77      *                    {@link #clear}
78      *                    may clear the data at any time.
79      */
create( Supplier<SharedPreferences> sharedPrefs, TimeSource timeSource, Executor backgroundExecutor, Random random)80     public static SharedPreferencesLoggingState create(
81             Supplier<SharedPreferences> sharedPrefs,
82             TimeSource timeSource,
83             Executor backgroundExecutor,
84             Random random) {
85         return new SharedPreferencesLoggingState(sharedPrefs, timeSource, backgroundExecutor,
86                 random);
87     }
88 
89     /** Constructs a new instance. */
createFromContext( Context context, Optional<String> instanceIdOptional, TimeSource timeSource, Executor backgroundExecutor, Random random)90     public static SharedPreferencesLoggingState createFromContext(
91             Context context,
92             Optional<String> instanceIdOptional,
93             TimeSource timeSource,
94             Executor backgroundExecutor,
95             Random random) {
96         // Avoid calling getSharedPreferences on the main thread.
97         Supplier<SharedPreferences> sharedPrefs =
98                 Suppliers.memoize(
99                         () ->
100                                 SharedPreferencesUtil.getSharedPreferences(
101                                         context, SHARED_PREFS_NAME, instanceIdOptional));
102         return new SharedPreferencesLoggingState(sharedPrefs, timeSource, backgroundExecutor,
103                 random);
104     }
105 
SharedPreferencesLoggingState( Supplier<SharedPreferences> sharedPrefs, TimeSource timeSource, Executor backgroundExecutor, Random random)106     private SharedPreferencesLoggingState(
107             Supplier<SharedPreferences> sharedPrefs,
108             TimeSource timeSource,
109             Executor backgroundExecutor,
110             Random random) {
111         this.sharedPrefs = sharedPrefs;
112         this.timeSource = timeSource;
113         this.backgroundExecutor = backgroundExecutor;
114         this.random = random;
115     }
116 
117     /** Data fields for each Entry persisted in SharedPreferences. */
118     private enum Key {
119         CELLULAR_USAGE("cu"),
120         WIFI_USAGE("wu");
121 
122         final String sharedPrefsSuffix;
123 
Key(String sharedPrefsSuffix)124         Key(String sharedPrefsSuffix) {
125             this.sharedPrefsSuffix = sharedPrefsSuffix;
126         }
127     }
128 
129     /** Bridge between FileGroupLoggingState and its SharedPreferences representation. */
130     private static final class Entry {
131 
132         final GroupKey groupKey;
133         final long buildId;
134         final int fileGroupVersionNumber;
135 
136         /** Prefix used in SharedPreference keys. */
137         final String spKeyPrefix;
138 
fromLoggingState(FileGroupLoggingState loggingState)139         static Entry fromLoggingState(FileGroupLoggingState loggingState) {
140             return new Entry(
141                     /* groupKey= */ loggingState.getGroupKey(),
142                     /* buildId= */ loggingState.getBuildId(),
143                     /* fileGroupVersionNumber= */ loggingState.getFileGroupVersionNumber());
144         }
145 
146         /**
147          * @throws IllegalArgumentException if the key can't be parsed
148          */
fromSpKey(String spKey)149         static Entry fromSpKey(String spKey) {
150             List<String> parts = Splitter.on(SPLIT_CHAR).splitToList(spKey);
151             try {
152                 return new Entry(
153                         /* groupKey= */ FileGroupsMetadataUtil.deserializeGroupKey(parts.get(0)),
154                         /* buildId= */ Long.parseLong(parts.get(1)),
155                         /* fileGroupVersionNumber= */ Integer.parseInt(parts.get(2)));
156             } catch (GroupKeyDeserializationException | ArrayIndexOutOfBoundsException e) {
157                 throw new IllegalArgumentException("Failed to parse SharedPrefs key: " + spKey, e);
158             }
159         }
160 
Entry(GroupKey groupKey, long buildId, int fileGroupVersionNumber)161         private Entry(GroupKey groupKey, long buildId, int fileGroupVersionNumber) {
162             this.groupKey = groupKey;
163             this.buildId = buildId;
164             this.fileGroupVersionNumber = fileGroupVersionNumber;
165             this.spKeyPrefix =
166                     FileGroupsMetadataUtil.getSerializedGroupKey(groupKey)
167                             + SPLIT_CHAR
168                             + buildId
169                             + SPLIT_CHAR
170                             + fileGroupVersionNumber;
171         }
172 
getSharedPrefsKey(Key key)173         String getSharedPrefsKey(Key key) {
174             return spKeyPrefix + SPLIT_CHAR + key.sharedPrefsSuffix;
175         }
176     }
177 
178     @Override
getAndResetDaysSinceLastMaintenance()179     public ListenableFuture<Optional<Integer>> getAndResetDaysSinceLastMaintenance() {
180         return futureSerializer.submit(
181                 () -> {
182                     long currentTimestamp = timeSource.currentTimeMillis();
183 
184                     Optional<Integer> daysSinceLastMaintenance;
185                     boolean hasEverDoneMaintenance =
186                             sharedPrefs.get().contains(LAST_MAINTENANCE_RUN_SECS_KEY);
187                     if (hasEverDoneMaintenance) {
188                         long persistedTimestamp = sharedPrefs.get().getLong(
189                                 LAST_MAINTENANCE_RUN_SECS_KEY, 0);
190                         long currentStartOfDay = truncateTimestampToStartOfDay(currentTimestamp);
191                         long previousStartOfDay = truncateTimestampToStartOfDay(persistedTimestamp);
192                         // Note: ignore MillisTo_Days java optional suggestion because Duration
193                         // is api
194                         // 26+.
195                         daysSinceLastMaintenance =
196                                 Optional.of(
197                                         Ints.saturatedCast(
198                                                 MILLISECONDS.toDays(
199                                                         currentStartOfDay - previousStartOfDay)));
200                     } else {
201                         daysSinceLastMaintenance = Optional.absent();
202                     }
203 
204                     SharedPreferences.Editor editor = sharedPrefs.get().edit();
205                     editor.putLong(LAST_MAINTENANCE_RUN_SECS_KEY, currentTimestamp);
206                     commitOrThrow(editor);
207 
208                     return daysSinceLastMaintenance;
209                 },
210                 backgroundExecutor);
211     }
212 
213     @Override
incrementDataUsage(FileGroupLoggingState dataUsageIncrements)214     public ListenableFuture<Void> incrementDataUsage(FileGroupLoggingState dataUsageIncrements) {
215         return futureSerializer.submit(
216                 () -> {
217                     Entry entry = Entry.fromLoggingState(dataUsageIncrements);
218 
219                     long currentCellarUsage =
220                             sharedPrefs.get().getLong(entry.getSharedPrefsKey(Key.CELLULAR_USAGE),
221                                     0);
222                     long currentWifiUsage =
223                             sharedPrefs.get().getLong(entry.getSharedPrefsKey(Key.WIFI_USAGE), 0);
224                     long updatedCellarUsage =
225                             currentCellarUsage + dataUsageIncrements.getCellularUsage();
226                     long updatedWifiUsage = currentWifiUsage + dataUsageIncrements.getWifiUsage();
227 
228                     SharedPreferences.Editor editor = sharedPrefs.get().edit();
229                     editor.putLong(entry.getSharedPrefsKey(Key.CELLULAR_USAGE), updatedCellarUsage);
230                     editor.putLong(entry.getSharedPrefsKey(Key.WIFI_USAGE), updatedWifiUsage);
231 
232                     return commitOrThrow(editor);
233                 },
234                 backgroundExecutor);
235     }
236 
237     @Override
238     public ListenableFuture<List<FileGroupLoggingState>> getAndResetAllDataUsage() {
239         return futureSerializer.submit(
240                 () -> {
241                     List<FileGroupLoggingState> allLoggingStates = new ArrayList<>();
242                     Set<String> allLoggingStateKeys = new HashSet<>();
243                     SharedPreferences.Editor editor = sharedPrefs.get().edit();
244 
245                     for (String key : sharedPrefs.get().getAll().keySet()) {
246                         Entry entry;
247                         try {
248                             entry = Entry.fromSpKey(key);
249                         } catch (IllegalArgumentException e) {
250                             continue; // This isn't a LoggingState entry
251                         }
252                         if (allLoggingStateKeys.contains(entry.spKeyPrefix)) {
253                             continue;
254                         }
255                         allLoggingStateKeys.add(entry.spKeyPrefix);
256 
257                         FileGroupLoggingState loggingState =
258                                 FileGroupLoggingState.newBuilder()
259                                         .setGroupKey(entry.groupKey)
260                                         .setBuildId(entry.buildId)
261                                         .setFileGroupVersionNumber(entry.fileGroupVersionNumber)
262                                         .setCellularUsage(
263                                                 sharedPrefs.get().getLong(
264                                                         entry.getSharedPrefsKey(Key.CELLULAR_USAGE),
265                                                         0))
266                                         .setWifiUsage(
267                                                 sharedPrefs.get().getLong(
268                                                         entry.getSharedPrefsKey(Key.WIFI_USAGE), 0))
269                                         .build();
270                         allLoggingStates.add(loggingState);
271 
272                         editor.remove(entry.getSharedPrefsKey(Key.CELLULAR_USAGE));
273                         editor.remove(entry.getSharedPrefsKey(Key.WIFI_USAGE));
274                     }
275                     commitOrThrow(editor);
276 
277                     return allLoggingStates;
278                 },
279                 backgroundExecutor);
280     }
281 
282     @Override
283     public ListenableFuture<Void> clear() {
284         return futureSerializer.submit(
285                 () -> {
286                     SharedPreferences.Editor editor = sharedPrefs.get().edit();
287                     editor.clear();
288                     return commitOrThrow(editor);
289                 },
290                 backgroundExecutor);
291     }
292 
293     @Override
294     public ListenableFuture<SamplingInfo> getStableSamplingInfo() {
295         return futureSerializer.submit(
296                 () -> {
297                     long salt;
298                     long persistedTimestampMillis;
299 
300                     boolean hasCreatedSalt = sharedPrefs.get().contains(SALT_KEY);
301                     if (hasCreatedSalt) {
302                         salt = sharedPrefs.get().getLong(SALT_KEY, 0);
303                         persistedTimestampMillis = sharedPrefs.get().getLong(
304                                 SALT_TIMESTAMP_MILLIS_KEY, 0);
305                     } else {
306                         salt = random.nextLong();
307                         persistedTimestampMillis = timeSource.currentTimeMillis();
308 
309                         SharedPreferences.Editor editor = sharedPrefs.get().edit();
310                         editor.putLong(SALT_KEY, salt);
311                         editor.putLong(SALT_TIMESTAMP_MILLIS_KEY, persistedTimestampMillis);
312                         commitOrThrow(editor);
313                     }
314 
315                     Timestamp timestamp = TimestampsUtil.fromMillis(persistedTimestampMillis);
316                     return SamplingInfo.newBuilder()
317                             .setStableLogSamplingSalt(salt)
318                             .setLogSamplingSaltSetTimestamp(timestamp)
319                             .build();
320                 },
321                 backgroundExecutor);
322     }
323 
324     // Use UTC time zone here so we don't have to worry about time zone change or daylight savings.
325     private static final TimeZone UTC_TIMEZONE = TimeZone.getTimeZone("UTC");
326 
327     // TODO(b/237533403): extract as shareable code with ProtoDataStoreLoggingState
328     private static long truncateTimestampToStartOfDay(long timestampMillis) {
329         // We use the regular java.util.Calendar classes here since neither Joda time nor java.time is
330         // supported across all client apps.
331         Calendar cal = new GregorianCalendar(UTC_TIMEZONE);
332         cal.setTimeInMillis(timestampMillis);
333         cal.set(Calendar.HOUR_OF_DAY, 0);
334         cal.set(Calendar.MINUTE, 0);
335         cal.set(Calendar.SECOND, 0);
336         cal.set(Calendar.MILLISECOND, 0);
337         return cal.getTimeInMillis();
338     }
339 
340     /** Calls {@code editor.commit()} and returns void, or throws IOException if the commit failed. */
341     private static Void commitOrThrow(SharedPreferences.Editor editor) throws IOException {
342         if (!editor.commit()) {
343             throw new IOException("Failed to commit");
344         }
345         return null;
346     }
347 }
348