diff options
6 files changed, 390 insertions, 1 deletions
diff --git a/core/java/android/util/HashedStringCache.java b/core/java/android/util/HashedStringCache.java new file mode 100644 index 000000000000..8ce85148c7c2 --- /dev/null +++ b/core/java/android/util/HashedStringCache.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Environment; +import android.os.storage.StorageManager; +import android.text.TextUtils; + +import java.io.File; +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +/** + * HashedStringCache provides hashing functionality with an underlying LRUCache and expiring salt. + * Salt and expiration time are being stored under the tag passed in by the calling package -- + * intended usage is the calling package name. + * TODO: Add unit tests b/129870147 + * @hide + */ +public class HashedStringCache { + private static HashedStringCache sHashedStringCache = null; + private static final Charset UTF_8 = Charset.forName("UTF-8"); + private static final int HASH_CACHE_SIZE = 100; + private static final int HASH_LENGTH = 8; + private static final String HASH_SALT = "_hash_salt"; + private static final String HASH_SALT_DATE = "_hash_salt_date"; + private static final String HASH_SALT_GEN = "_hash_salt_gen"; + // For privacy we need to rotate the salt regularly + private static final long DAYS_TO_MILLIS = 1000 * 60 * 60 * 24; + private static final int MAX_SALT_DAYS = 100; + private final LruCache<String, String> mHashes; + private final SecureRandom mSecureRandom; + private final Object mPreferenceLock = new Object(); + private final MessageDigest mDigester; + private byte[] mSalt; + private int mSaltGen; + private SharedPreferences mSharedPreferences; + + private static final String TAG = "HashedStringCache"; + private static final boolean DEBUG = false; + + private HashedStringCache() { + mHashes = new LruCache<>(HASH_CACHE_SIZE); + mSecureRandom = new SecureRandom(); + try { + mDigester = MessageDigest.getInstance("MD5"); + } catch (NoSuchAlgorithmException impossible) { + // this can't happen - MD5 is always present + throw new RuntimeException(impossible); + } + } + + /** + * @return - instance of the HashedStringCache + * @hide + */ + public static HashedStringCache getInstance() { + if (sHashedStringCache == null) { + sHashedStringCache = new HashedStringCache(); + } + return sHashedStringCache; + } + + /** + * Take the string and context and create a hash of the string. Trigger refresh on salt if salt + * is more than 7 days old + * @param context - callers context to retrieve SharedPreferences + * @param clearText - string that needs to be hashed + * @param tag - class name to use for storing values in shared preferences + * @param saltExpirationDays - number of days we may keep the same salt + * special value -1 will short-circuit and always return null. + * @return - HashResult containing the hashed string and the generation of the hash salt, null + * if clearText string is empty + * + * @hide + */ + public HashResult hashString(Context context, String tag, String clearText, + int saltExpirationDays) { + if (TextUtils.isEmpty(clearText) || saltExpirationDays == -1) { + return null; + } + + populateSaltValues(context, tag, saltExpirationDays); + String hashText = mHashes.get(clearText); + if (hashText != null) { + return new HashResult(hashText, mSaltGen); + } + + mDigester.reset(); + mDigester.update(mSalt); + mDigester.update(clearText.getBytes(UTF_8)); + byte[] bytes = mDigester.digest(); + int len = Math.min(HASH_LENGTH, bytes.length); + hashText = Base64.encodeToString(bytes, 0, len, Base64.NO_PADDING | Base64.NO_WRAP); + mHashes.put(clearText, hashText); + + return new HashResult(hashText, mSaltGen); + } + + /** + * Populates the mSharedPreferences and checks if there is a salt present and if it's older than + * 7 days + * @param tag - class name to use for storing values in shared preferences + * @param saltExpirationDays - number of days we may keep the same salt + * @param saltDate - the date retrieved from configuration + * @return - true if no salt or salt is older than 7 days + */ + private boolean checkNeedsNewSalt(String tag, int saltExpirationDays, long saltDate) { + if (saltDate == 0 || saltExpirationDays < -1) { + return true; + } + if (saltExpirationDays > MAX_SALT_DAYS) { + saltExpirationDays = MAX_SALT_DAYS; + } + long now = System.currentTimeMillis(); + long delta = now - saltDate; + // Check for delta < 0 to make sure we catch if someone puts their phone far in the + // future and then goes back to normal time. + return delta >= saltExpirationDays * DAYS_TO_MILLIS || delta < 0; + } + + /** + * Populate the salt and saltGen member variables if they aren't already set / need refreshing. + * @param context - to get sharedPreferences + * @param tag - class name to use for storing values in shared preferences + * @param saltExpirationDays - number of days we may keep the same salt + */ + private void populateSaltValues(Context context, String tag, int saltExpirationDays) { + synchronized (mPreferenceLock) { + // check if we need to refresh the salt + mSharedPreferences = getHashSharedPreferences(context); + long saltDate = mSharedPreferences.getLong(tag + HASH_SALT_DATE, 0); + boolean needsNewSalt = checkNeedsNewSalt(tag, saltExpirationDays, saltDate); + if (needsNewSalt) { + mHashes.evictAll(); + } + if (mSalt == null || needsNewSalt) { + String saltString = mSharedPreferences.getString(tag + HASH_SALT, null); + mSaltGen = mSharedPreferences.getInt(tag + HASH_SALT_GEN, 0); + if (saltString == null || needsNewSalt) { + mSaltGen++; + byte[] saltBytes = new byte[16]; + mSecureRandom.nextBytes(saltBytes); + saltString = Base64.encodeToString(saltBytes, + Base64.NO_PADDING | Base64.NO_WRAP); + mSharedPreferences.edit() + .putString(tag + HASH_SALT, saltString) + .putInt(tag + HASH_SALT_GEN, mSaltGen) + .putLong(tag + HASH_SALT_DATE, System.currentTimeMillis()).apply(); + if (DEBUG) { + Log.d(TAG, "created a new salt: " + saltString); + } + } + mSalt = saltString.getBytes(UTF_8); + } + } + } + + /** + * Android:ui doesn't have persistent preferences, so need to fall back on this hack originally + * from ChooserActivity.java + * @param context + * @return + */ + private SharedPreferences getHashSharedPreferences(Context context) { + final File prefsFile = new File(new File( + Environment.getDataUserCePackageDirectory( + StorageManager.UUID_PRIVATE_INTERNAL, + context.getUserId(), context.getPackageName()), + "shared_prefs"), + "hashed_cache.xml"); + return context.getSharedPreferences(prefsFile, Context.MODE_PRIVATE); + } + + /** + * Helper class to hold hashed string and salt generation. + */ + public class HashResult { + public String hashedString; + public int saltGeneration; + + public HashResult(String hString, int saltGen) { + hashedString = hString; + saltGeneration = saltGen; + } + } +} diff --git a/core/java/com/android/internal/app/ChooserActivity.java b/core/java/com/android/internal/app/ChooserActivity.java index a1d0cdc5789f..d553c6c11dcf 100644 --- a/core/java/com/android/internal/app/ChooserActivity.java +++ b/core/java/com/android/internal/app/ChooserActivity.java @@ -74,6 +74,7 @@ import android.os.RemoteException; import android.os.ResultReceiver; import android.os.UserHandle; import android.os.UserManager; +import android.provider.DeviceConfig; import android.provider.DocumentsContract; import android.provider.Downloads; import android.provider.OpenableColumns; @@ -83,6 +84,7 @@ import android.service.chooser.IChooserTargetResult; import android.service.chooser.IChooserTargetService; import android.text.TextUtils; import android.util.AttributeSet; +import android.util.HashedStringCache; import android.util.Log; import android.util.Size; import android.util.Slog; @@ -106,6 +108,7 @@ import android.widget.Toast; import com.android.internal.R; import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.ImageUtils; @@ -170,6 +173,11 @@ public class ChooserActivity extends ResolverActivity { private static final int QUERY_TARGET_SERVICE_LIMIT = 5; private static final int WATCHDOG_TIMEOUT_MILLIS = 3000; + private static final int DEFAULT_SALT_EXPIRATION_DAYS = 7; + private int mMaxHashSaltDays = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.HASH_SALT_MAX_DAYS, + DEFAULT_SALT_EXPIRATION_DAYS); + private Bundle mReplacementExtras; private IntentSender mChosenComponentSender; private IntentSender mRefinementIntentSender; @@ -201,7 +209,8 @@ public class ChooserActivity extends ResolverActivity { private static final int SHORTCUT_MANAGER_SHARE_TARGET_RESULT_COMPLETED = 4; private static final int LIST_VIEW_UPDATE_MESSAGE = 5; - private static final int LIST_VIEW_UPDATE_INTERVAL_IN_MILLIS = 250; + @VisibleForTesting + public static final int LIST_VIEW_UPDATE_INTERVAL_IN_MILLIS = 250; private boolean mListViewDataChanged = false; @@ -991,6 +1000,7 @@ public class ChooserActivity extends ResolverActivity { // Lower values mean the ranking was better. int cat = 0; int value = which; + HashedStringCache.HashResult directTargetHashed = null; switch (mChooserListAdapter.getPositionTargetType(which)) { case ChooserListAdapter.TARGET_CALLER: cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET; @@ -998,6 +1008,17 @@ public class ChooserActivity extends ResolverActivity { break; case ChooserListAdapter.TARGET_SERVICE: cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET; + value -= mChooserListAdapter.getCallerTargetCount(); + // Log the package name + target name to answer the question if most users + // share to mostly the same person or to a bunch of different people. + ChooserTarget target = + mChooserListAdapter.mServiceTargets.get(value).getChooserTarget(); + directTargetHashed = HashedStringCache.getInstance().hashString( + this, + TAG, + target.getComponentName().getPackageName() + + target.getTitle().toString(), + mMaxHashSaltDays); break; case ChooserListAdapter.TARGET_STANDARD: cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET; @@ -1007,6 +1028,15 @@ public class ChooserActivity extends ResolverActivity { } if (cat != 0) { + LogMaker targetLogMaker = new LogMaker(cat).setSubtype(value); + if (directTargetHashed != null) { + targetLogMaker.addTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_NAME, directTargetHashed.hashedString); + targetLogMaker.addTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_SALT_GEN, + directTargetHashed.saltGeneration); + } + getMetricsLogger().write(targetLogMaker); MetricsLogger.action(this, cat, value); } diff --git a/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java b/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java index fd74c0486bd7..495a5fbb6665 100644 --- a/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java +++ b/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java @@ -95,5 +95,10 @@ public final class SystemUiDeviceConfigFlags { public static final String COMPACT_MEDIA_SEEKBAR_ENABLED = "compact_media_notification_seekbar_enabled"; + /** + * (int) Maximum number of days to retain the salt for hashing direct share targets in logging + */ + public static final String HASH_SALT_MAX_DAYS = "hash_salt_max_days"; + private SystemUiDeviceConfigFlags() { } } diff --git a/core/tests/coretests/src/com/android/internal/app/ChooserActivityTest.java b/core/tests/coretests/src/com/android/internal/app/ChooserActivityTest.java index 185fa0750ff1..00b4a225c55f 100644 --- a/core/tests/coretests/src/com/android/internal/app/ChooserActivityTest.java +++ b/core/tests/coretests/src/com/android/internal/app/ChooserActivityTest.java @@ -39,6 +39,7 @@ import android.app.usage.UsageStatsManager; import android.content.ClipData; import android.content.ClipDescription; import android.content.ClipboardManager; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.ResolveInfo; @@ -47,8 +48,10 @@ import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; +import android.graphics.drawable.Icon; import android.metrics.LogMaker; import android.net.Uri; +import android.service.chooser.ChooserTarget; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; @@ -735,6 +738,120 @@ public class ChooserActivityTest { onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); } + // This test is too long and too slow and should not be taken as an example for future tests. + // This is necessary because it tests that multiple calls result in the same result but + // normally a test this long should be broken into smaller tests testing individual components. + @Test + public void testDirectTargetSelectionLogging() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + when(sOverrides.resolverListController.getResolversForIntent(Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + + // Set up resources + MetricsLogger mockLogger = sOverrides.metricsLogger; + ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); + // Create direct share target + List<ChooserTarget> serviceTargets = createDirectShareTargets(1); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // Start activity + final ChooserWrapperActivity activity = mActivityRule + .launchActivity(Intent.createChooser(sendIntent, null)); + + // Insert the direct share target + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> activity.getAdapter().addServiceResults( + activity.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent), + serviceTargets, + false) + ); + // Thread.sleep shouldn't be a thing in an integration test but it's + // necessary here because of the way the code is structured + // TODO: restructure the tests b/129870719 + Thread.sleep(ChooserActivity.LIST_VIEW_UPDATE_INTERVAL_IN_MILLIS); + + assertThat("Chooser should have 3 targets (2apps, 1 direct)", + activity.getAdapter().getCount(), is(3)); + assertThat("Chooser should have exactly one selectable direct target", + activity.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat("The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + // Currently we're seeing 3 invocations + // 1. ChooserActivity.onCreate() + // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() + // 3. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), + is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); + String hashedName = (String) logMakerCaptor + .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_HASHED_TARGET_NAME); + assertThat("Hash is not predictable but must be obfuscated", + hashedName, is(not(name))); + + // Running the same again to check if the hashed name is the same as before. + + Intent sendIntent2 = createSendTextIntent(); + + // Start activity + final ChooserWrapperActivity activity2 = mActivityRule + .launchActivity(Intent.createChooser(sendIntent2, null)); + waitForIdle(); + + // Insert the direct share target + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> activity2.getAdapter().addServiceResults( + activity2.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent), + serviceTargets, + false) + ); + // Thread.sleep shouldn't be a thing in an integration test but it's + // necessary here because of the way the code is structured + // TODO: restructure the tests b/129870719 + Thread.sleep(ChooserActivity.LIST_VIEW_UPDATE_INTERVAL_IN_MILLIS); + + assertThat("Chooser should have 3 targets (2apps, 1 direct)", + activity2.getAdapter().getCount(), is(3)); + assertThat("Chooser should have exactly one selectable direct target", + activity2.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat("The resolver info must match the resolver info used to create the target", + activity2.getAdapter().getItem(0).getResolveInfo(), is(ri)); + + // Click on the direct target + onView(withText(name)) + .perform(click()); + waitForIdle(); + + // Currently we're seeing 6 invocations (3 from above, doubled up) + // 4. ChooserActivity.onCreate() + // 5. ChooserActivity$ChooserRowAdapter.createContentPreviewView() + // 6. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(6)).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(5).getCategory(), + is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); + String hashedName2 = (String) logMakerCaptor + .getAllValues().get(5).getTaggedData(MetricsEvent.FIELD_HASHED_TARGET_NAME); + assertThat("Hashing the same name should result in the same hashed value", + hashedName2, is(hashedName)); + } + private Intent createSendTextIntent() { Intent sendIntent = new Intent(); sendIntent.setAction(Intent.ACTION_SEND); @@ -798,6 +915,23 @@ public class ChooserActivityTest { return infoList; } + private List<ChooserTarget> createDirectShareTargets(int numberOfResults) { + Icon icon = Icon.createWithBitmap(createBitmap()); + String testTitle = "testTitle"; + List<ChooserTarget> targets = new ArrayList<>(); + for (int i = 0; i < numberOfResults; i++) { + ComponentName componentName = ResolverDataProvider.createComponentName(i); + ChooserTarget tempTarget = new ChooserTarget( + testTitle + i, + icon, + (float) (1 - ((i + 1) / 10.0)), + componentName, + null); + targets.add(tempTarget); + } + return targets; + } + private void waitForIdle() { InstrumentationRegistry.getInstrumentation().waitForIdleSync(); } diff --git a/core/tests/coretests/src/com/android/internal/app/ChooserWrapperActivity.java b/core/tests/coretests/src/com/android/internal/app/ChooserWrapperActivity.java index 5e71129ebf7b..44e56eaf0593 100644 --- a/core/tests/coretests/src/com/android/internal/app/ChooserWrapperActivity.java +++ b/core/tests/coretests/src/com/android/internal/app/ChooserWrapperActivity.java @@ -21,7 +21,9 @@ import static org.mockito.Mockito.mock; import android.app.usage.UsageStatsManager; import android.content.ContentResolver; import android.content.Context; +import android.content.Intent; import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; import android.database.Cursor; import android.graphics.Bitmap; import android.net.Uri; @@ -121,6 +123,11 @@ public class ChooserWrapperActivity extends ChooserActivity { return super.isWorkProfile(); } + public DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri, + CharSequence pLabel, CharSequence pInfo, Intent pOrigIntent) { + return new DisplayResolveInfo(originalIntent, pri, pLabel, pInfo, pOrigIntent); + } + /** * We cannot directly mock the activity created since instrumentation creates it. * <p> diff --git a/proto/src/metrics_constants/metrics_constants.proto b/proto/src/metrics_constants/metrics_constants.proto index f487fc8c470f..a88ae9e8b952 100644 --- a/proto/src/metrics_constants/metrics_constants.proto +++ b/proto/src/metrics_constants/metrics_constants.proto @@ -7167,6 +7167,14 @@ message MetricsEvent { // OS: Q ACTION_DISPLAY_WHITE_BALANCE_SETTING_CHANGED = 1703; + // Action: ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET + // Direct share target hashed with rotating salt + FIELD_HASHED_TARGET_NAME = 1704; + + // Action: ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET + // Salt generation for the above hashed direct share target + FIELD_HASHED_TARGET_SALT_GEN = 1705; + // ---- End Q Constants, all Q constants go above this line ---- // Add new aosp constants above this line. // END OF AOSP CONSTANTS |