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