Loading core/java/android/util/HashedStringCache.java 0 → 100644 +205 −0 Original line number Diff line number Diff line /* * 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; } } } core/java/com/android/internal/app/ChooserActivity.java +31 −1 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading @@ -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; Loading Loading @@ -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; Loading Loading @@ -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; Loading Loading @@ -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; Loading @@ -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; Loading @@ -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); } Loading core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java +5 −0 Original line number Diff line number Diff line Loading @@ -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() { } } core/tests/coretests/src/com/android/internal/app/ChooserActivityTest.java +134 −0 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading Loading @@ -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); Loading Loading @@ -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(); } Loading core/tests/coretests/src/com/android/internal/app/ChooserWrapperActivity.java +7 −0 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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> Loading Loading
core/java/android/util/HashedStringCache.java 0 → 100644 +205 −0 Original line number Diff line number Diff line /* * 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; } } }
core/java/com/android/internal/app/ChooserActivity.java +31 −1 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading @@ -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; Loading Loading @@ -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; Loading Loading @@ -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; Loading Loading @@ -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; Loading @@ -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; Loading @@ -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); } Loading
core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java +5 −0 Original line number Diff line number Diff line Loading @@ -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() { } }
core/tests/coretests/src/com/android/internal/app/ChooserActivityTest.java +134 −0 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading Loading @@ -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); Loading Loading @@ -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(); } Loading
core/tests/coretests/src/com/android/internal/app/ChooserWrapperActivity.java +7 −0 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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> Loading