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

Commit 0636f4d5 authored by Angela Wang's avatar Angela Wang Committed by Android (Google) Code Review
Browse files

Merge changes from topic "cherrypicker-L23300000962634398:N20400001397711178" into main

* changes:
  Stop updating BT devices event history if user is categorized
  Save BT hearing device event history for HaTS surveys
  Handle BT hearing device event history for HaTS surveys
parents c167c3d1 85baffad
Loading
Loading
Loading
Loading
+38 −0
Original line number Diff line number Diff line
@@ -277,6 +277,38 @@ public class CachedBluetoothDevice implements Comparable<CachedBluetoothDevice>
                mRemovedProfiles.add(profile);
                mLocalNapRoleConnected = false;
            }

            if (!HearingAidStatsLogUtils.isUserCategorized(mContext)) {
                if (HearingAidStatsLogUtils.isJustBonded(getAddress())) {
                    // Saves bonded timestamp as the source for judging whether to display
                    // the survey
                    if (getProfiles().stream().anyMatch(
                            p -> (p instanceof HearingAidProfile
                                    || p instanceof HapClientProfile))) {
                        HearingAidStatsLogUtils.addCurrentTimeToHistory(mContext,
                                HearingAidStatsLogUtils.HistoryType.TYPE_HEARING_AIDS_PAIRED);
                    } else if (getProfiles().stream().anyMatch(
                            p -> (p instanceof A2dpSinkProfile || p instanceof HeadsetProfile))) {
                        HearingAidStatsLogUtils.addCurrentTimeToHistory(mContext,
                                HearingAidStatsLogUtils.HistoryType.TYPE_HEARING_DEVICES_PAIRED);
                    }
                    HearingAidStatsLogUtils.removeFromJustBonded(getAddress());
                }

                // Saves connected timestamp as the source for judging whether to display
                // the survey
                if (newProfileState == BluetoothProfile.STATE_CONNECTED) {
                    if (profile instanceof HearingAidProfile
                            || profile instanceof HapClientProfile) {
                        HearingAidStatsLogUtils.addCurrentTimeToHistory(mContext,
                                HearingAidStatsLogUtils.HistoryType.TYPE_HEARING_AIDS_CONNECTED);
                    } else if (profile instanceof A2dpSinkProfile
                            || profile instanceof HeadsetProfile) {
                        HearingAidStatsLogUtils.addCurrentTimeToHistory(mContext,
                                HearingAidStatsLogUtils.HistoryType.TYPE_HEARING_DEVICES_CONNECTED);
                    }
                }
            }
        }

        fetchActiveDevices();
@@ -899,6 +931,12 @@ public class CachedBluetoothDevice implements Comparable<CachedBluetoothDevice>
            if (mDevice.isBondingInitiatedLocally()) {
                connect();
            }

            if (!HearingAidStatsLogUtils.isUserCategorized(mContext)) {
                // Saves this device as just bonded and checks if it's an hearing device after
                // profiles are connected. This is for judging whether to display the survey.
                HearingAidStatsLogUtils.addToJustBonded(getAddress());
            }
        }
    }

+278 −0
Original line number Diff line number Diff line
@@ -16,18 +16,74 @@

package com.android.settingslib.bluetooth;

import android.content.Context;
import android.content.SharedPreferences;
import android.icu.text.SimpleDateFormat;
import android.icu.util.TimeZone;
import android.util.Log;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.FrameworkStatsLog;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/** Utils class to report hearing aid metrics to statsd */
public final class HearingAidStatsLogUtils {

    private static final String TAG = "HearingAidStatsLogUtils";
    private static final boolean DEBUG = true;
    private static final String ACCESSIBILITY_PREFERENCE = "accessibility_prefs";
    private static final String BT_HEARING_AIDS_PAIRED_HISTORY = "bt_hearing_aids_paired_history";
    private static final String BT_HEARING_AIDS_CONNECTED_HISTORY =
            "bt_hearing_aids_connected_history";
    private static final String BT_HEARING_DEVICES_PAIRED_HISTORY =
            "bt_hearing_devices_paired_history";
    private static final String BT_HEARING_DEVICES_CONNECTED_HISTORY =
            "bt_hearing_devices_connected_history";
    private static final String BT_HEARING_USER_CATEGORY = "bt_hearing_user_category";

    private static final String HISTORY_RECORD_DELIMITER = ",";
    private static final String CATEGORY_HEARING_AIDS = "A11yHearingAidsUser";
    private static final String CATEGORY_NEW_HEARING_AIDS = "A11yNewHearingAidsUser";
    private static final String CATEGORY_HEARING_DEVICES = "A11yHearingDevicesUser";
    private static final String CATEGORY_NEW_HEARING_DEVICES = "A11yNewHearingDevicesUser";

    private static final long PAIRED_HISTORY_EXPIRED_TIME = TimeUnit.DAYS.toMillis(30);
    private static final long CONNECTED_HISTORY_EXPIRED_TIME = TimeUnit.DAYS.toMillis(7);
    private static final int VALID_PAIRED_EVENT_COUNT = 1;
    private static final int VALID_CONNECTED_EVENT_COUNT = 7;

    /**
     * Type of different Bluetooth device events history related to hearing.
     */
    @Retention(RetentionPolicy.SOURCE)
    @IntDef({
            HistoryType.TYPE_UNKNOWN,
            HistoryType.TYPE_HEARING_AIDS_PAIRED,
            HistoryType.TYPE_HEARING_AIDS_CONNECTED,
            HistoryType.TYPE_HEARING_DEVICES_PAIRED,
            HistoryType.TYPE_HEARING_DEVICES_CONNECTED})
    public @interface HistoryType {
        int TYPE_UNKNOWN = -1;
        int TYPE_HEARING_AIDS_PAIRED = 0;
        int TYPE_HEARING_AIDS_CONNECTED = 1;
        int TYPE_HEARING_DEVICES_PAIRED = 2;
        int TYPE_HEARING_DEVICES_CONNECTED = 3;
    }

    private static final HashMap<String, Integer> sDeviceAddressToBondEntryMap = new HashMap<>();
    private static final Set<String> sJustBondedDeviceAddressSet = new HashSet<>();

    /**
     * Sets the mapping from hearing aid device to the bond entry where this device starts it's
@@ -69,5 +125,227 @@ public final class HearingAidStatsLogUtils {
        return sDeviceAddressToBondEntryMap;
    }

    /**
     * Indicates if user is categorized as one of {@link #CATEGORY_HEARING_AIDS},
     * {@link #CATEGORY_NEW_HEARING_AIDS}, {@link #CATEGORY_HEARING_DEVICES}, and
     * {@link #CATEGORY_NEW_HEARING_DEVICES}.
     *
     * @param context the request context
     * @return true if user is already categorized as one of interested group
     */
    public static boolean isUserCategorized(Context context) {
        String userCategory = getSharedPreferences(context).getString(BT_HEARING_USER_CATEGORY, "");
        return !userCategory.isEmpty();
    }

    /**
     * Returns the user category if the user is already categorized. Otherwise, checks the
     * history and sees if the user is categorized as one of {@link #CATEGORY_HEARING_AIDS},
     * {@link #CATEGORY_NEW_HEARING_AIDS}, {@link #CATEGORY_HEARING_DEVICES}, and
     * {@link #CATEGORY_NEW_HEARING_DEVICES}.
     *
     * @param context the request context
     * @return the category which user belongs to
     */
    public static synchronized String getUserCategory(Context context) {
        String userCategory = getSharedPreferences(context).getString(BT_HEARING_USER_CATEGORY, "");
        if (!userCategory.isEmpty()) {
            return userCategory;
        }

        LinkedList<Long> hearingAidsConnectedHistory = getHistory(context,
                HistoryType.TYPE_HEARING_AIDS_CONNECTED);
        if (hearingAidsConnectedHistory != null
                && hearingAidsConnectedHistory.size() >= VALID_CONNECTED_EVENT_COUNT) {
            LinkedList<Long> hearingAidsPairedHistory = getHistory(context,
                    HistoryType.TYPE_HEARING_AIDS_PAIRED);
            // Since paired history will be cleared after 30 days. If there's any record within 30
            // days, the user will be categorized as CATEGORY_NEW_HEARING_AIDS. Otherwise, the user
            // will be categorized as CATEGORY_HEARING_AIDS.
            if (hearingAidsPairedHistory != null
                    && hearingAidsPairedHistory.size() >= VALID_PAIRED_EVENT_COUNT) {
                userCategory = CATEGORY_NEW_HEARING_AIDS;
            } else {
                userCategory = CATEGORY_HEARING_AIDS;
            }
        }

        LinkedList<Long> hearingDevicesConnectedHistory = getHistory(context,
                HistoryType.TYPE_HEARING_DEVICES_CONNECTED);
        if (hearingDevicesConnectedHistory != null
                && hearingDevicesConnectedHistory.size() >= VALID_CONNECTED_EVENT_COUNT) {
            LinkedList<Long> hearingDevicesPairedHistory = getHistory(context,
                    HistoryType.TYPE_HEARING_DEVICES_PAIRED);
            // Since paired history will be cleared after 30 days. If there's any record within 30
            // days, the user will be categorized as CATEGORY_NEW_HEARING_DEVICES. Otherwise, the
            // user will be categorized as CATEGORY_HEARING_DEVICES.
            if (hearingDevicesPairedHistory != null
                    && hearingDevicesPairedHistory.size() >= VALID_PAIRED_EVENT_COUNT) {
                userCategory = CATEGORY_NEW_HEARING_DEVICES;
            } else {
                userCategory = CATEGORY_HEARING_DEVICES;
            }
        }

        if (!userCategory.isEmpty()) {
            // History become useless once user is categorized. Clear all history.
            SharedPreferences.Editor editor = getSharedPreferences(context).edit();
            editor.putString(BT_HEARING_USER_CATEGORY, userCategory).apply();
            clearHistory(context);
            sJustBondedDeviceAddressSet.clear();
        }
        return userCategory;
    }

    /**
     * Maintains a temporarily list of just bonded device address. After the device profiles are
     * connected, {@link HearingAidStatsLogUtils#removeFromJustBonded} will be called to remove the
     * address.
     * @param address the device address
     */
    public static void addToJustBonded(String address) {
        sJustBondedDeviceAddressSet.add(address);
    }

    /**
     * Removes the device address from the just bonded list.
     * @param address the device address
     */
    public static void removeFromJustBonded(String address) {
        sJustBondedDeviceAddressSet.remove(address);
    }

    /**
     * Checks whether the device address is in the just bonded list.
     * @param address the device address
     * @return true if the device address is in the just bonded list
     */
    public static boolean isJustBonded(String address) {
        return sJustBondedDeviceAddressSet.contains(address);
    }

    /**
     * Clears all BT hearing devices related history stored in shared preference.
     * @param context the request context
     */
    private static synchronized void clearHistory(Context context) {
        SharedPreferences.Editor editor = getSharedPreferences(context).edit();
        editor.remove(BT_HEARING_AIDS_PAIRED_HISTORY)
                .remove(BT_HEARING_AIDS_CONNECTED_HISTORY)
                .remove(BT_HEARING_DEVICES_PAIRED_HISTORY)
                .remove(BT_HEARING_DEVICES_CONNECTED_HISTORY)
                .apply();
    }

    /**
     * Adds current timestamp into BT hearing devices related history.
     * @param context the request context
     * @param type the type of history to store the data. See {@link HistoryType}.
     */
    public static void addCurrentTimeToHistory(Context context, @HistoryType int type) {
        addToHistory(context, type, System.currentTimeMillis());
    }

    static synchronized void addToHistory(Context context, @HistoryType int type,
            long timestamp) {

        LinkedList<Long> history = getHistory(context, type);
        if (history == null) {
            if (DEBUG) {
                Log.w(TAG, "Couldn't find shared preference name matched type=" + type);
            }
            return;
        }
        if (history.peekLast() != null && isSameDay(history.peekLast(), timestamp)) {
            if (DEBUG) {
                Log.w(TAG, "Skip this record, it's same day record");
            }
            return;
        }
        history.add(timestamp);
        SharedPreferences.Editor editor = getSharedPreferences(context).edit();
        editor.putString(HISTORY_TYPE_TO_SP_NAME_MAPPING.get(type),
                convertToHistoryString(history)).apply();
    }

    @Nullable
    static synchronized LinkedList<Long> getHistory(Context context, @HistoryType int type) {
        String spName = HISTORY_TYPE_TO_SP_NAME_MAPPING.get(type);
        if (BT_HEARING_AIDS_PAIRED_HISTORY.equals(spName)
                || BT_HEARING_DEVICES_PAIRED_HISTORY.equals(spName)) {
            LinkedList<Long> history = convertToHistoryList(
                    getSharedPreferences(context).getString(spName, ""));
            removeRecordsBeforeTime(history, PAIRED_HISTORY_EXPIRED_TIME);
            return history;
        } else if (BT_HEARING_AIDS_CONNECTED_HISTORY.equals(spName)
                || BT_HEARING_DEVICES_CONNECTED_HISTORY.equals(spName)) {
            LinkedList<Long> history = convertToHistoryList(
                    getSharedPreferences(context).getString(spName, ""));
            removeRecordsBeforeTime(history, CONNECTED_HISTORY_EXPIRED_TIME);
            return history;
        }
        return null;
    }

    private static void removeRecordsBeforeTime(LinkedList<Long> history, long time) {
        if (history == null) {
            return;
        }
        Long currentTime = System.currentTimeMillis();
        while (history.peekFirst() != null
                && currentTime - history.peekFirst() > time) {
            history.poll();
        }
    }

    private static String convertToHistoryString(LinkedList<Long> history) {
        return history.stream().map(Object::toString).collect(
                Collectors.joining(HISTORY_RECORD_DELIMITER));
    }
    private static LinkedList<Long> convertToHistoryList(String string) {
        if (string == null || string.isEmpty()) {
            return new LinkedList<>();
        }
        LinkedList<Long> ll = new LinkedList<>();
        String[] elements = string.split(HISTORY_RECORD_DELIMITER);
        for (String e: elements) {
            if (e.isEmpty()) continue;
            ll.offer(Long.parseLong(e));
        }
        return ll;
    }

    /**
     * Check if two timestamps are in the same date according to current timezone. This function
     * doesn't consider the original timezone when the timestamp is saved.
     *
     * @param t1 the first epoch timestamp
     * @param t2 the second epoch timestamp
     * @return {@code true} if two timestamps are on the same day
     */
    private static boolean isSameDay(long t1, long t2) {
        final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd", Locale.getDefault());
        sdf.setTimeZone(TimeZone.getDefault());
        String dateString1 = sdf.format(t1);
        String dateString2 = sdf.format(t2);
        return dateString1.equals(dateString2);
    }

    private static SharedPreferences getSharedPreferences(Context context) {
        return context.getSharedPreferences(ACCESSIBILITY_PREFERENCE, Context.MODE_PRIVATE);
    }

    private static final HashMap<Integer, String> HISTORY_TYPE_TO_SP_NAME_MAPPING;
    static {
        HISTORY_TYPE_TO_SP_NAME_MAPPING = new HashMap<>();
        HISTORY_TYPE_TO_SP_NAME_MAPPING.put(
                HistoryType.TYPE_HEARING_AIDS_PAIRED, BT_HEARING_AIDS_PAIRED_HISTORY);
        HISTORY_TYPE_TO_SP_NAME_MAPPING.put(
                HistoryType.TYPE_HEARING_AIDS_CONNECTED, BT_HEARING_AIDS_CONNECTED_HISTORY);
        HISTORY_TYPE_TO_SP_NAME_MAPPING.put(
                HistoryType.TYPE_HEARING_DEVICES_PAIRED, BT_HEARING_DEVICES_PAIRED_HISTORY);
        HISTORY_TYPE_TO_SP_NAME_MAPPING.put(
                HistoryType.TYPE_HEARING_DEVICES_CONNECTED, BT_HEARING_DEVICES_CONNECTED_HISTORY);
    }
    private HearingAidStatsLogUtils() {}
}
+50 −0
Original line number Diff line number Diff line
@@ -20,6 +20,10 @@ import static com.google.common.truth.Truth.assertThat;

import static org.mockito.Mockito.when;

import android.content.Context;

import androidx.test.core.app.ApplicationProvider;

import com.android.internal.util.FrameworkStatsLog;

import org.junit.Rule;
@@ -31,15 +35,21 @@ import org.mockito.junit.MockitoRule;
import org.robolectric.RobolectricTestRunner;

import java.util.HashMap;
import java.util.LinkedList;
import java.util.concurrent.TimeUnit;

@RunWith(RobolectricTestRunner.class)
public class HearingAidStatsLogUtilsTest {

    private static final String TEST_DEVICE_ADDRESS = "00:A1:A1:A1:A1:A1";
    private static final int TEST_HISTORY_TYPE =
            HearingAidStatsLogUtils.HistoryType.TYPE_HEARING_AIDS_CONNECTED;

    @Rule
    public final MockitoRule mockito = MockitoJUnit.rule();

    private final Context mContext = ApplicationProvider.getApplicationContext();

    @Mock
    private CachedBluetoothDevice mCachedBluetoothDevice;

@@ -71,4 +81,44 @@ public class HearingAidStatsLogUtilsTest {
                HearingAidStatsLogUtils.getDeviceAddressToBondEntryMap();
        assertThat(map.containsKey(TEST_DEVICE_ADDRESS)).isFalse();
    }

    @Test
    public void addCurrentTimeToHistory_addNewData() {
        final long currentTime = System.currentTimeMillis();
        final long lastData = currentTime - TimeUnit.DAYS.toMillis(2);
        HearingAidStatsLogUtils.addToHistory(mContext, TEST_HISTORY_TYPE, lastData);

        HearingAidStatsLogUtils.addCurrentTimeToHistory(mContext, TEST_HISTORY_TYPE);

        LinkedList<Long> history = HearingAidStatsLogUtils.getHistory(mContext, TEST_HISTORY_TYPE);
        assertThat(history).isNotNull();
        assertThat(history.size()).isEqualTo(2);
    }
    @Test
    public void addCurrentTimeToHistory_skipSameDateData() {
        final long currentTime = System.currentTimeMillis();
        final long lastData = currentTime - 1;
        HearingAidStatsLogUtils.addToHistory(mContext, TEST_HISTORY_TYPE, lastData);

        HearingAidStatsLogUtils.addCurrentTimeToHistory(mContext, TEST_HISTORY_TYPE);

        LinkedList<Long> history = HearingAidStatsLogUtils.getHistory(mContext, TEST_HISTORY_TYPE);
        assertThat(history).isNotNull();
        assertThat(history.size()).isEqualTo(1);
        assertThat(history.getFirst()).isEqualTo(lastData);
    }

    @Test
    public void addCurrentTimeToHistory_cleanUpExpiredData() {
        final long currentTime = System.currentTimeMillis();
        final long expiredData = currentTime - TimeUnit.DAYS.toMillis(10);
        HearingAidStatsLogUtils.addToHistory(mContext, TEST_HISTORY_TYPE, expiredData);

        HearingAidStatsLogUtils.addCurrentTimeToHistory(mContext, TEST_HISTORY_TYPE);

        LinkedList<Long> history = HearingAidStatsLogUtils.getHistory(mContext, TEST_HISTORY_TYPE);
        assertThat(history).isNotNull();
        assertThat(history.size()).isEqualTo(1);
        assertThat(history.getFirst()).isNotEqualTo(expiredData);
    }
}