Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 0c70be67 authored by Susi Kharraz-Post's avatar Susi Kharraz-Post Committed by Android (Google) Code Review
Browse files

Merge "Add logging for direct share target"

parents 412e35af 14cbfcdb
Loading
Loading
Loading
Loading
+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;
        }
    }
}
+31 −1
Original line number Diff line number Diff line
@@ -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);
            }

+5 −0
Original line number Diff line number Diff line
@@ -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() { }
}
+134 −0
Original line number Diff line number Diff line
@@ -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();
    }
+7 −0
Original line number Diff line number Diff line
@@ -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>
Loading