--- /dev/null
+/*
+ * 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;
+ }
+ }
+}
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;
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;
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;
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;
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;
// 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;
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;
}
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);
}
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() { }
}
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;
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;
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);
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();
}
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;
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>
// 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