361 lines
16 KiB
Java
361 lines
16 KiB
Java
/*
|
|
* Copyright (C) 2022 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 com.android.settingslib.bluetooth;
|
|
|
|
import android.bluetooth.BluetoothProfile;
|
|
import android.content.Context;
|
|
import android.content.SharedPreferences;
|
|
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.time.Instant;
|
|
import java.time.LocalDate;
|
|
import java.time.ZoneId;
|
|
import java.time.temporal.ChronoUnit;
|
|
import java.util.HashMap;
|
|
import java.util.HashSet;
|
|
import java.util.LinkedList;
|
|
import java.util.Set;
|
|
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 = ",";
|
|
static final String CATEGORY_HEARING_AIDS = "A11yHearingAidsUser";
|
|
static final String CATEGORY_NEW_HEARING_AIDS = "A11yNewHearingAidsUser";
|
|
static final String CATEGORY_HEARING_DEVICES = "A11yHearingDevicesUser";
|
|
static final String CATEGORY_NEW_HEARING_DEVICES = "A11yNewHearingDevicesUser";
|
|
|
|
static final int PAIRED_HISTORY_EXPIRED_DAY = 30;
|
|
static final int CONNECTED_HISTORY_EXPIRED_DAY = 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
|
|
* bonding(connecting) process.
|
|
*
|
|
* @param bondEntry The entry page id where the bonding process starts
|
|
* @param device The bonding(connecting) hearing aid device
|
|
*/
|
|
public static void setBondEntryForDevice(int bondEntry, CachedBluetoothDevice device) {
|
|
sDeviceAddressToBondEntryMap.put(device.getAddress(), bondEntry);
|
|
}
|
|
|
|
/**
|
|
* Logs hearing aid device information to statsd, including device mode, device side, and entry
|
|
* page id where the binding(connecting) process starts.
|
|
*
|
|
* Only logs the info once after hearing aid is bonded(connected). Clears the map entry of this
|
|
* device when logging is completed.
|
|
*
|
|
* @param device The bonded(connected) hearing aid device
|
|
*/
|
|
public static void logHearingAidInfo(CachedBluetoothDevice device) {
|
|
final String deviceAddress = device.getAddress();
|
|
if (sDeviceAddressToBondEntryMap.containsKey(deviceAddress)) {
|
|
final int bondEntry = sDeviceAddressToBondEntryMap.getOrDefault(deviceAddress, -1);
|
|
final int deviceMode = device.getDeviceMode();
|
|
final int deviceSide = device.getDeviceSide();
|
|
FrameworkStatsLog.write(FrameworkStatsLog.HEARING_AID_INFO_REPORTED, deviceMode,
|
|
deviceSide, bondEntry);
|
|
|
|
sDeviceAddressToBondEntryMap.remove(deviceAddress);
|
|
} else {
|
|
Log.w(TAG, "The device address was not found. Hearing aid device info is not logged.");
|
|
}
|
|
}
|
|
|
|
@VisibleForTesting
|
|
static HashMap<String, Integer> getDeviceAddressToBondEntryMap() {
|
|
return sDeviceAddressToBondEntryMap;
|
|
}
|
|
|
|
/**
|
|
* Updates corresponding history if we found the device is a hearing device after profile state
|
|
* changed.
|
|
*
|
|
* @param context the request context
|
|
* @param cachedDevice the remote device
|
|
* @param profile the profile that has a state changed
|
|
* @param profileState the new profile state
|
|
*/
|
|
public static void updateHistoryIfNeeded(Context context, CachedBluetoothDevice cachedDevice,
|
|
LocalBluetoothProfile profile, int profileState) {
|
|
|
|
if (isJustBonded(cachedDevice.getAddress())) {
|
|
// Saves bonded timestamp as the source for judging whether to display
|
|
// the survey
|
|
if (cachedDevice.getProfiles().stream().anyMatch(
|
|
p -> (p instanceof HearingAidProfile || p instanceof HapClientProfile))) {
|
|
HearingAidStatsLogUtils.addCurrentTimeToHistory(context,
|
|
HearingAidStatsLogUtils.HistoryType.TYPE_HEARING_AIDS_PAIRED);
|
|
} else if (cachedDevice.getProfiles().stream().anyMatch(
|
|
p -> (p instanceof A2dpSinkProfile || p instanceof HeadsetProfile))) {
|
|
HearingAidStatsLogUtils.addCurrentTimeToHistory(context,
|
|
HearingAidStatsLogUtils.HistoryType.TYPE_HEARING_DEVICES_PAIRED);
|
|
}
|
|
removeFromJustBonded(cachedDevice.getAddress());
|
|
}
|
|
|
|
// Saves connected timestamp as the source for judging whether to display
|
|
// the survey
|
|
if (profileState == BluetoothProfile.STATE_CONNECTED) {
|
|
if (profile instanceof HearingAidProfile || profile instanceof HapClientProfile) {
|
|
HearingAidStatsLogUtils.addCurrentTimeToHistory(context,
|
|
HearingAidStatsLogUtils.HistoryType.TYPE_HEARING_AIDS_CONNECTED);
|
|
} else if (profile instanceof A2dpSinkProfile || profile instanceof HeadsetProfile) {
|
|
HearingAidStatsLogUtils.addCurrentTimeToHistory(context,
|
|
HearingAidStatsLogUtils.HistoryType.TYPE_HEARING_DEVICES_CONNECTED);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
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
|
|
*/
|
|
private 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
|
|
*/
|
|
private static boolean isJustBonded(String address) {
|
|
return sJustBondedDeviceAddressSet.contains(address);
|
|
}
|
|
|
|
/**
|
|
* 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(timestamp, history.peekLast())) {
|
|
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, ""));
|
|
removeRecordsBeforeDay(history, PAIRED_HISTORY_EXPIRED_DAY);
|
|
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, ""));
|
|
removeRecordsBeforeDay(history, CONNECTED_HISTORY_EXPIRED_DAY);
|
|
return history;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static void removeRecordsBeforeDay(LinkedList<Long> history, int day) {
|
|
if (history == null || history.isEmpty()) {
|
|
return;
|
|
}
|
|
long currentTime = System.currentTimeMillis();
|
|
while (history.peekFirst() != null
|
|
&& dayDifference(currentTime, history.peekFirst()) >= day) {
|
|
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) {
|
|
return dayDifference(t1, t2) == 0;
|
|
}
|
|
private static long dayDifference(long t1, long t2) {
|
|
ZoneId zoneId = ZoneId.systemDefault();
|
|
LocalDate date1 = Instant.ofEpochMilli(t1).atZone(zoneId).toLocalDate();
|
|
LocalDate date2 = Instant.ofEpochMilli(t2).atZone(zoneId).toLocalDate();
|
|
return Math.abs(ChronoUnit.DAYS.between(date1, date2));
|
|
}
|
|
|
|
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() {}
|
|
}
|