1 /* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.settingslib.bluetooth; 18 19 import android.bluetooth.BluetoothProfile; 20 import android.content.Context; 21 import android.content.SharedPreferences; 22 import android.util.Log; 23 24 import androidx.annotation.IntDef; 25 import androidx.annotation.Nullable; 26 27 import com.android.internal.annotations.VisibleForTesting; 28 import com.android.internal.util.FrameworkStatsLog; 29 30 import java.lang.annotation.Retention; 31 import java.lang.annotation.RetentionPolicy; 32 import java.time.Instant; 33 import java.time.LocalDate; 34 import java.time.ZoneId; 35 import java.time.temporal.ChronoUnit; 36 import java.util.HashMap; 37 import java.util.HashSet; 38 import java.util.LinkedList; 39 import java.util.Set; 40 import java.util.stream.Collectors; 41 42 /** Utils class to report hearing aid metrics to statsd */ 43 public final class HearingAidStatsLogUtils { 44 45 private static final String TAG = "HearingAidStatsLogUtils"; 46 private static final boolean DEBUG = true; 47 private static final String ACCESSIBILITY_PREFERENCE = "accessibility_prefs"; 48 private static final String BT_HEARING_AIDS_PAIRED_HISTORY = "bt_hearing_aids_paired_history"; 49 private static final String BT_HEARING_AIDS_CONNECTED_HISTORY = 50 "bt_hearing_aids_connected_history"; 51 private static final String BT_HEARABLE_DEVICES_PAIRED_HISTORY = 52 "bt_hearing_devices_paired_history"; 53 private static final String BT_HEARABLE_DEVICES_CONNECTED_HISTORY = 54 "bt_hearing_devices_connected_history"; 55 private static final String HISTORY_RECORD_DELIMITER = ","; 56 static final String CATEGORY_HEARING_AIDS = "A11yHearingAidsUser"; 57 static final String CATEGORY_NEW_HEARING_AIDS = "A11yNewHearingAidsUser"; 58 static final String CATEGORY_HEARABLE_DEVICES = "A11yHearingDevicesUser"; 59 static final String CATEGORY_NEW_HEARABLE_DEVICES = "A11yNewHearingDevicesUser"; 60 61 static final int PAIRED_HISTORY_EXPIRED_DAY = 30; 62 static final int CONNECTED_HISTORY_EXPIRED_DAY = 7; 63 private static final int VALID_PAIRED_EVENT_COUNT = 1; 64 private static final int VALID_CONNECTED_EVENT_COUNT = 7; 65 66 /** 67 * Type of different Bluetooth device events history related to hearing. 68 */ 69 @Retention(RetentionPolicy.SOURCE) 70 @IntDef({ 71 HistoryType.TYPE_UNKNOWN, 72 HistoryType.TYPE_HEARING_AIDS_PAIRED, 73 HistoryType.TYPE_HEARING_AIDS_CONNECTED, 74 HistoryType.TYPE_HEARABLE_DEVICES_PAIRED, 75 HistoryType.TYPE_HEARABLE_DEVICES_CONNECTED}) 76 public @interface HistoryType { 77 int TYPE_UNKNOWN = -1; 78 int TYPE_HEARING_AIDS_PAIRED = 0; 79 int TYPE_HEARING_AIDS_CONNECTED = 1; 80 int TYPE_HEARABLE_DEVICES_PAIRED = 2; 81 int TYPE_HEARABLE_DEVICES_CONNECTED = 3; 82 } 83 84 private static final HashMap<String, Integer> sDeviceAddressToBondEntryMap = new HashMap<>(); 85 private static final Set<String> sJustBondedDeviceAddressSet = new HashSet<>(); 86 87 /** 88 * Sets the mapping from hearing aid device to the bond entry where this device starts it's 89 * bonding(connecting) process. 90 * 91 * @param bondEntry The entry page id where the bonding process starts 92 * @param device The bonding(connecting) hearing aid device 93 */ setBondEntryForDevice(int bondEntry, CachedBluetoothDevice device)94 public static void setBondEntryForDevice(int bondEntry, CachedBluetoothDevice device) { 95 sDeviceAddressToBondEntryMap.put(device.getAddress(), bondEntry); 96 } 97 98 /** 99 * Logs hearing aid device information to statsd, including device mode, device side, and entry 100 * page id where the binding(connecting) process starts. 101 * 102 * Only logs the info once after hearing aid is bonded(connected). Clears the map entry of this 103 * device when logging is completed. 104 * 105 * @param device The bonded(connected) hearing aid device 106 */ logHearingAidInfo(CachedBluetoothDevice device)107 public static void logHearingAidInfo(CachedBluetoothDevice device) { 108 final String deviceAddress = device.getAddress(); 109 if (sDeviceAddressToBondEntryMap.containsKey(deviceAddress)) { 110 final int bondEntry = sDeviceAddressToBondEntryMap.getOrDefault(deviceAddress, -1); 111 final int deviceMode = device.getDeviceMode(); 112 final int deviceSide = device.getDeviceSide(); 113 FrameworkStatsLog.write(FrameworkStatsLog.HEARING_AID_INFO_REPORTED, deviceMode, 114 deviceSide, bondEntry); 115 116 sDeviceAddressToBondEntryMap.remove(deviceAddress); 117 } else { 118 Log.w(TAG, "The device address was not found. Hearing aid device info is not logged."); 119 } 120 } 121 122 @VisibleForTesting getDeviceAddressToBondEntryMap()123 static HashMap<String, Integer> getDeviceAddressToBondEntryMap() { 124 return sDeviceAddressToBondEntryMap; 125 } 126 127 /** 128 * Updates corresponding history if we found the device is a hearing related device after 129 * profile state changed. 130 * 131 * @param context the request context 132 * @param cachedDevice the remote device 133 * @param profile the profile that has a state changed 134 * @param profileState the new profile state 135 */ updateHistoryIfNeeded(Context context, CachedBluetoothDevice cachedDevice, LocalBluetoothProfile profile, int profileState)136 public static void updateHistoryIfNeeded(Context context, CachedBluetoothDevice cachedDevice, 137 LocalBluetoothProfile profile, int profileState) { 138 139 if (isJustBonded(cachedDevice.getAddress())) { 140 // Saves bonded timestamp as the source for judging whether to display 141 // the survey 142 if (cachedDevice.getProfiles().stream().anyMatch( 143 p -> (p instanceof HearingAidProfile || p instanceof HapClientProfile))) { 144 HearingAidStatsLogUtils.addCurrentTimeToHistory(context, 145 HearingAidStatsLogUtils.HistoryType.TYPE_HEARING_AIDS_PAIRED); 146 } else if (cachedDevice.getProfiles().stream().anyMatch( 147 p -> (p instanceof A2dpSinkProfile || p instanceof HeadsetProfile))) { 148 HearingAidStatsLogUtils.addCurrentTimeToHistory(context, 149 HearingAidStatsLogUtils.HistoryType.TYPE_HEARABLE_DEVICES_PAIRED); 150 } 151 removeFromJustBonded(cachedDevice.getAddress()); 152 } 153 154 // Saves connected timestamp as the source for judging whether to display 155 // the survey 156 if (profileState == BluetoothProfile.STATE_CONNECTED) { 157 if (profile instanceof HearingAidProfile || profile instanceof HapClientProfile) { 158 HearingAidStatsLogUtils.addCurrentTimeToHistory(context, 159 HearingAidStatsLogUtils.HistoryType.TYPE_HEARING_AIDS_CONNECTED); 160 } else if (profile instanceof A2dpSinkProfile || profile instanceof HeadsetProfile) { 161 HearingAidStatsLogUtils.addCurrentTimeToHistory(context, 162 HearingAidStatsLogUtils.HistoryType.TYPE_HEARABLE_DEVICES_CONNECTED); 163 } 164 } 165 } 166 167 /** 168 * Returns the user category if the user is already categorized. Otherwise, checks the 169 * history and sees if the user is categorized as one of {@link #CATEGORY_HEARING_AIDS}, 170 * {@link #CATEGORY_NEW_HEARING_AIDS}, {@link #CATEGORY_HEARABLE_DEVICES}, and 171 * {@link #CATEGORY_NEW_HEARABLE_DEVICES}. 172 * 173 * @param context the request context 174 * @return the category which user belongs to 175 */ getUserCategory(Context context)176 public static synchronized String getUserCategory(Context context) { 177 LinkedList<Long> hearingAidsConnectedHistory = getHistory(context, 178 HistoryType.TYPE_HEARING_AIDS_CONNECTED); 179 if (hearingAidsConnectedHistory != null 180 && hearingAidsConnectedHistory.size() >= VALID_CONNECTED_EVENT_COUNT) { 181 LinkedList<Long> hearingAidsPairedHistory = getHistory(context, 182 HistoryType.TYPE_HEARING_AIDS_PAIRED); 183 // Since paired history will be cleared after 30 days. If there's any record within 30 184 // days, the user will be categorized as CATEGORY_NEW_HEARING_AIDS. Otherwise, the user 185 // will be categorized as CATEGORY_HEARING_AIDS. 186 if (hearingAidsPairedHistory != null 187 && hearingAidsPairedHistory.size() >= VALID_PAIRED_EVENT_COUNT) { 188 return CATEGORY_NEW_HEARING_AIDS; 189 } else { 190 return CATEGORY_HEARING_AIDS; 191 } 192 } 193 194 LinkedList<Long> hearableDevicesConnectedHistory = getHistory(context, 195 HistoryType.TYPE_HEARABLE_DEVICES_CONNECTED); 196 if (hearableDevicesConnectedHistory != null 197 && hearableDevicesConnectedHistory.size() >= VALID_CONNECTED_EVENT_COUNT) { 198 LinkedList<Long> hearableDevicesPairedHistory = getHistory(context, 199 HistoryType.TYPE_HEARABLE_DEVICES_PAIRED); 200 // Since paired history will be cleared after 30 days. If there's any record within 30 201 // days, the user will be categorized as CATEGORY_NEW_HEARABLE_DEVICES. Otherwise, the 202 // user will be categorized as CATEGORY_HEARABLE_DEVICES. 203 if (hearableDevicesPairedHistory != null 204 && hearableDevicesPairedHistory.size() >= VALID_PAIRED_EVENT_COUNT) { 205 return CATEGORY_NEW_HEARABLE_DEVICES; 206 } else { 207 return CATEGORY_HEARABLE_DEVICES; 208 } 209 } 210 return ""; 211 } 212 213 /** 214 * Maintains a temporarily list of just bonded device address. After the device profiles are 215 * connected, {@link HearingAidStatsLogUtils#removeFromJustBonded} will be called to remove the 216 * address. 217 * @param address the device address 218 */ addToJustBonded(String address)219 public static void addToJustBonded(String address) { 220 sJustBondedDeviceAddressSet.add(address); 221 } 222 223 /** 224 * Removes the device address from the just bonded list. 225 * @param address the device address 226 */ removeFromJustBonded(String address)227 private static void removeFromJustBonded(String address) { 228 sJustBondedDeviceAddressSet.remove(address); 229 } 230 231 /** 232 * Checks whether the device address is in the just bonded list. 233 * @param address the device address 234 * @return true if the device address is in the just bonded list 235 */ isJustBonded(String address)236 private static boolean isJustBonded(String address) { 237 return sJustBondedDeviceAddressSet.contains(address); 238 } 239 240 /** 241 * Adds current timestamp into BT hearing related devices history. 242 * @param context the request context 243 * @param type the type of history to store the data. See {@link HistoryType}. 244 */ addCurrentTimeToHistory(Context context, @HistoryType int type)245 public static void addCurrentTimeToHistory(Context context, @HistoryType int type) { 246 addToHistory(context, type, System.currentTimeMillis()); 247 } 248 addToHistory(Context context, @HistoryType int type, long timestamp)249 static synchronized void addToHistory(Context context, @HistoryType int type, 250 long timestamp) { 251 252 LinkedList<Long> history = getHistory(context, type); 253 if (history == null) { 254 if (DEBUG) { 255 Log.w(TAG, "Couldn't find shared preference name matched type=" + type); 256 } 257 return; 258 } 259 if (history.peekLast() != null && isSameDay(timestamp, history.peekLast())) { 260 if (DEBUG) { 261 Log.w(TAG, "Skip this record, it's same day record"); 262 } 263 return; 264 } 265 history.add(timestamp); 266 SharedPreferences.Editor editor = getSharedPreferences(context).edit(); 267 editor.putString(HISTORY_TYPE_TO_SP_NAME_MAPPING.get(type), 268 convertToHistoryString(history)).apply(); 269 } 270 271 @Nullable getHistory(Context context, @HistoryType int type)272 static synchronized LinkedList<Long> getHistory(Context context, @HistoryType int type) { 273 String spName = HISTORY_TYPE_TO_SP_NAME_MAPPING.get(type); 274 if (BT_HEARING_AIDS_PAIRED_HISTORY.equals(spName) 275 || BT_HEARABLE_DEVICES_PAIRED_HISTORY.equals(spName)) { 276 LinkedList<Long> history = convertToHistoryList( 277 getSharedPreferences(context).getString(spName, "")); 278 removeRecordsBeforeDay(history, PAIRED_HISTORY_EXPIRED_DAY); 279 return history; 280 } else if (BT_HEARING_AIDS_CONNECTED_HISTORY.equals(spName) 281 || BT_HEARABLE_DEVICES_CONNECTED_HISTORY.equals(spName)) { 282 LinkedList<Long> history = convertToHistoryList( 283 getSharedPreferences(context).getString(spName, "")); 284 removeRecordsBeforeDay(history, CONNECTED_HISTORY_EXPIRED_DAY); 285 return history; 286 } 287 return null; 288 } 289 removeRecordsBeforeDay(LinkedList<Long> history, int day)290 private static void removeRecordsBeforeDay(LinkedList<Long> history, int day) { 291 if (history == null || history.isEmpty()) { 292 return; 293 } 294 long currentTime = System.currentTimeMillis(); 295 while (history.peekFirst() != null 296 && dayDifference(currentTime, history.peekFirst()) >= day) { 297 history.poll(); 298 } 299 } 300 convertToHistoryString(LinkedList<Long> history)301 private static String convertToHistoryString(LinkedList<Long> history) { 302 return history.stream().map(Object::toString).collect( 303 Collectors.joining(HISTORY_RECORD_DELIMITER)); 304 } convertToHistoryList(String string)305 private static LinkedList<Long> convertToHistoryList(String string) { 306 if (string == null || string.isEmpty()) { 307 return new LinkedList<>(); 308 } 309 LinkedList<Long> ll = new LinkedList<>(); 310 String[] elements = string.split(HISTORY_RECORD_DELIMITER); 311 for (String e: elements) { 312 if (e.isEmpty()) continue; 313 ll.offer(Long.parseLong(e)); 314 } 315 return ll; 316 } 317 318 /** 319 * Check if two timestamps are in the same date according to current timezone. This function 320 * doesn't consider the original timezone when the timestamp is saved. 321 * 322 * @param t1 the first epoch timestamp 323 * @param t2 the second epoch timestamp 324 * @return {@code true} if two timestamps are on the same day 325 */ isSameDay(long t1, long t2)326 private static boolean isSameDay(long t1, long t2) { 327 return dayDifference(t1, t2) == 0; 328 } dayDifference(long t1, long t2)329 private static long dayDifference(long t1, long t2) { 330 ZoneId zoneId = ZoneId.systemDefault(); 331 LocalDate date1 = Instant.ofEpochMilli(t1).atZone(zoneId).toLocalDate(); 332 LocalDate date2 = Instant.ofEpochMilli(t2).atZone(zoneId).toLocalDate(); 333 return Math.abs(ChronoUnit.DAYS.between(date1, date2)); 334 } 335 getSharedPreferences(Context context)336 private static SharedPreferences getSharedPreferences(Context context) { 337 return context.getSharedPreferences(ACCESSIBILITY_PREFERENCE, Context.MODE_PRIVATE); 338 } 339 340 private static final HashMap<Integer, String> HISTORY_TYPE_TO_SP_NAME_MAPPING; 341 static { 342 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)343 HISTORY_TYPE_TO_SP_NAME_MAPPING.put( 344 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)345 HISTORY_TYPE_TO_SP_NAME_MAPPING.put( 346 HistoryType.TYPE_HEARING_AIDS_CONNECTED, BT_HEARING_AIDS_CONNECTED_HISTORY); HISTORY_TYPE_TO_SP_NAME_MAPPING.put( HistoryType.TYPE_HEARABLE_DEVICES_PAIRED, BT_HEARABLE_DEVICES_PAIRED_HISTORY)347 HISTORY_TYPE_TO_SP_NAME_MAPPING.put( 348 HistoryType.TYPE_HEARABLE_DEVICES_PAIRED, BT_HEARABLE_DEVICES_PAIRED_HISTORY); HISTORY_TYPE_TO_SP_NAME_MAPPING.put( HistoryType.TYPE_HEARABLE_DEVICES_CONNECTED, BT_HEARABLE_DEVICES_CONNECTED_HISTORY)349 HISTORY_TYPE_TO_SP_NAME_MAPPING.put( 350 HistoryType.TYPE_HEARABLE_DEVICES_CONNECTED, BT_HEARABLE_DEVICES_CONNECTED_HISTORY); 351 } HearingAidStatsLogUtils()352 private HearingAidStatsLogUtils() {} 353 } 354