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