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