Add logging for direct share target

To answer the question if users share mainly with 1 or 2 direct targets
or with a multitude of contacts, we need to log the direct target +
package name. For privacy, this gets hashed with a salt that expires by
default every 7 days. The PH flag will allow us to change the expiration
time if we obtain PWG permission for that.

Bug: 126365511
Test: New test in ChooserActivityTest + manual testing of consistency
and flag rollout using adb shell device_config put systemui
hash_salt_max_days with multiple values

Change-Id: Ib4255b3eb39ca91ccb5803dc036ffe0ea83a27c9
This commit is contained in:
Susi Kharraz-Post
2019-04-01 11:07:59 -04:00
parent 92aa9b2fba
commit 14cbfcdbd0
6 changed files with 390 additions and 1 deletions

View File

@@ -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;
}
}
}

View File

@@ -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);
}

View File

@@ -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() { }
}

View File

@@ -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();
}

View File

@@ -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>

View File

@@ -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