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