OSDN Git Service

Add logging for direct share target
authorSusi Kharraz-Post <susikp@google.com>
Mon, 1 Apr 2019 15:07:59 +0000 (11:07 -0400)
committerSusi Kharraz-Post <susikp@google.com>
Wed, 3 Apr 2019 21:54:25 +0000 (17:54 -0400)
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

core/java/android/util/HashedStringCache.java [new file with mode: 0644]
core/java/com/android/internal/app/ChooserActivity.java
core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java
core/tests/coretests/src/com/android/internal/app/ChooserActivityTest.java
core/tests/coretests/src/com/android/internal/app/ChooserWrapperActivity.java
proto/src/metrics_constants/metrics_constants.proto

diff --git a/core/java/android/util/HashedStringCache.java b/core/java/android/util/HashedStringCache.java
new file mode 100644 (file)
index 0000000..8ce8514
--- /dev/null
@@ -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;
+        }
+    }
+}
index a1d0cdc..d553c6c 100644 (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);
             }
 
index fd74c04..495a5fb 100644 (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() { }
 }
index 185fa07..00b4a22 100644 (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();
     }
index 5e71129..44e56ea 100644 (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>
index f487fc8..a88ae9e 100644 (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