1 /*
2  * Copyright (C) 2019 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.settings.bluetooth;
18 
19 import android.bluetooth.BluetoothAdapter;
20 import android.bluetooth.BluetoothDevice;
21 import android.content.ContentResolver;
22 import android.content.Context;
23 import android.content.Intent;
24 import android.database.Cursor;
25 import android.graphics.Bitmap;
26 import android.graphics.PorterDuff;
27 import android.graphics.PorterDuffColorFilter;
28 import android.graphics.drawable.Drawable;
29 import android.net.Uri;
30 import android.os.Handler;
31 import android.os.Looper;
32 import android.provider.MediaStore;
33 import android.text.TextUtils;
34 import android.util.Log;
35 import android.util.Pair;
36 import android.view.View;
37 import android.view.ViewGroup;
38 import android.widget.ImageView;
39 import android.widget.LinearLayout;
40 import android.widget.TextView;
41 
42 import androidx.annotation.VisibleForTesting;
43 import androidx.preference.PreferenceScreen;
44 
45 import com.android.settings.R;
46 import com.android.settings.core.BasePreferenceController;
47 import com.android.settings.fuelgauge.BatteryMeterView;
48 import com.android.settingslib.bluetooth.BluetoothUtils;
49 import com.android.settingslib.bluetooth.CachedBluetoothDevice;
50 import com.android.settingslib.core.lifecycle.LifecycleObserver;
51 import com.android.settingslib.core.lifecycle.events.OnDestroy;
52 import com.android.settingslib.core.lifecycle.events.OnStart;
53 import com.android.settingslib.core.lifecycle.events.OnStop;
54 import com.android.settingslib.utils.StringUtil;
55 import com.android.settingslib.utils.ThreadUtils;
56 import com.android.settingslib.widget.LayoutPreference;
57 
58 import java.io.IOException;
59 import java.util.HashMap;
60 import java.util.Map;
61 import java.util.concurrent.TimeUnit;
62 
63 /**
64  * This class adds a header with device name and status (connected/disconnected, etc.).
65  */
66 public class AdvancedBluetoothDetailsHeaderController extends BasePreferenceController implements
67         LifecycleObserver, OnStart, OnStop, OnDestroy, CachedBluetoothDevice.Callback {
68     private static final String TAG = "AdvancedBtHeaderCtrl";
69     private static final int LOW_BATTERY_LEVEL = 15;
70     private static final int CASE_LOW_BATTERY_LEVEL = 19;
71     private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
72 
73     private static final String PATH = "time_remaining";
74     private static final String QUERY_PARAMETER_ADDRESS = "address";
75     private static final String QUERY_PARAMETER_BATTERY_ID = "battery_id";
76     private static final String QUERY_PARAMETER_BATTERY_LEVEL = "battery_level";
77     private static final String QUERY_PARAMETER_TIMESTAMP = "timestamp";
78     private static final String BATTERY_ESTIMATE = "battery_estimate";
79     private static final String ESTIMATE_READY = "estimate_ready";
80     private static final String DATABASE_ID = "id";
81     private static final String DATABASE_BLUETOOTH = "Bluetooth";
82     private static final long TIME_OF_HOUR = TimeUnit.SECONDS.toMillis(3600);
83     private static final long TIME_OF_MINUTE = TimeUnit.SECONDS.toMillis(60);
84     private static final int LEFT_DEVICE_ID = 1;
85     private static final int RIGHT_DEVICE_ID = 2;
86     private static final int CASE_DEVICE_ID = 3;
87     private static final int MAIN_DEVICE_ID = 4;
88     private static final float HALF_ALPHA = 0.5f;
89 
90     @VisibleForTesting
91     LayoutPreference mLayoutPreference;
92     @VisibleForTesting
93     final Map<String, Bitmap> mIconCache;
94     private CachedBluetoothDevice mCachedDevice;
95     @VisibleForTesting
96     BluetoothAdapter mBluetoothAdapter;
97     @VisibleForTesting
98     Handler mHandler = new Handler(Looper.getMainLooper());
99     @VisibleForTesting
100     boolean mIsRegisterCallback = false;
101     @VisibleForTesting
102     final BluetoothAdapter.OnMetadataChangedListener mMetadataListener =
103             new BluetoothAdapter.OnMetadataChangedListener() {
104                 @Override
105                 public void onMetadataChanged(BluetoothDevice device, int key, byte[] value) {
106                     if (DEBUG) {
107                         Log.d(TAG, String.format("Metadata updated in Device %s: %d = %s.", device,
108                                 key, value == null ? null : new String(value)));
109                     }
110                     refresh();
111                 }
112             };
113 
AdvancedBluetoothDetailsHeaderController(Context context, String prefKey)114     public AdvancedBluetoothDetailsHeaderController(Context context, String prefKey) {
115         super(context, prefKey);
116         mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
117         mIconCache = new HashMap<>();
118     }
119 
120     @Override
getAvailabilityStatus()121     public int getAvailabilityStatus() {
122         if (mCachedDevice == null) {
123             return CONDITIONALLY_UNAVAILABLE;
124         }
125         return Utils.isAdvancedDetailsHeader(mCachedDevice.getDevice())
126                 ? AVAILABLE : CONDITIONALLY_UNAVAILABLE;
127     }
128 
129     @Override
displayPreference(PreferenceScreen screen)130     public void displayPreference(PreferenceScreen screen) {
131         super.displayPreference(screen);
132         mLayoutPreference = screen.findPreference(getPreferenceKey());
133         mLayoutPreference.setVisible(isAvailable());
134     }
135 
136     @Override
onStart()137     public void onStart() {
138         if (!isAvailable()) {
139             return;
140         }
141         mIsRegisterCallback = true;
142         mCachedDevice.registerCallback(this);
143         mBluetoothAdapter.addOnMetadataChangedListener(mCachedDevice.getDevice(),
144                 mContext.getMainExecutor(), mMetadataListener);
145 
146         refresh();
147     }
148 
149     @Override
onStop()150     public void onStop() {
151         if (!mIsRegisterCallback) {
152             return;
153         }
154         mCachedDevice.unregisterCallback(this);
155         mBluetoothAdapter.removeOnMetadataChangedListener(mCachedDevice.getDevice(),
156                 mMetadataListener);
157         mIsRegisterCallback = false;
158     }
159 
160     @Override
onDestroy()161     public void onDestroy() {
162         // Destroy icon bitmap associated with this header
163         for (Bitmap bitmap : mIconCache.values()) {
164             if (bitmap != null) {
165                 bitmap.recycle();
166             }
167         }
168         mIconCache.clear();
169     }
170 
init(CachedBluetoothDevice cachedBluetoothDevice)171     public void init(CachedBluetoothDevice cachedBluetoothDevice) {
172         mCachedDevice = cachedBluetoothDevice;
173     }
174 
175     @VisibleForTesting
refresh()176     void refresh() {
177         if (mLayoutPreference != null && mCachedDevice != null) {
178             final TextView title = mLayoutPreference.findViewById(R.id.entity_header_title);
179             title.setText(mCachedDevice.getName());
180             final TextView summary = mLayoutPreference.findViewById(R.id.entity_header_summary);
181             summary.setText(mCachedDevice.getConnectionSummary(true /* shortSummary */));
182 
183             if (!mCachedDevice.isConnected() || mCachedDevice.isBusy()) {
184                 updateDisconnectLayout();
185                 return;
186             }
187             final BluetoothDevice device = mCachedDevice.getDevice();
188             final String deviceType = BluetoothUtils.getStringMetaData(device,
189                     BluetoothDevice.METADATA_DEVICE_TYPE);
190             if (TextUtils.equals(deviceType, BluetoothDevice.DEVICE_TYPE_WATCH)
191                     || TextUtils.equals(deviceType, BluetoothDevice.DEVICE_TYPE_DEFAULT)) {
192                 mLayoutPreference.findViewById(R.id.layout_left).setVisibility(View.GONE);
193                 mLayoutPreference.findViewById(R.id.layout_right).setVisibility(View.GONE);
194 
195                 updateSubLayout(mLayoutPreference.findViewById(R.id.layout_middle),
196                         BluetoothDevice.METADATA_MAIN_ICON,
197                         BluetoothDevice.METADATA_MAIN_BATTERY,
198                         BluetoothDevice.METADATA_MAIN_LOW_BATTERY_THRESHOLD,
199                         BluetoothDevice.METADATA_MAIN_CHARGING,
200                         /* titleResId */ 0,
201                         MAIN_DEVICE_ID);
202             } else if (TextUtils.equals(deviceType,
203                     BluetoothDevice.DEVICE_TYPE_UNTETHERED_HEADSET)
204                     || BluetoothUtils.getBooleanMetaData(device,
205                     BluetoothDevice.METADATA_IS_UNTETHERED_HEADSET)) {
206                 updateSubLayout(mLayoutPreference.findViewById(R.id.layout_left),
207                         BluetoothDevice.METADATA_UNTETHERED_LEFT_ICON,
208                         BluetoothDevice.METADATA_UNTETHERED_LEFT_BATTERY,
209                         BluetoothDevice.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD,
210                         BluetoothDevice.METADATA_UNTETHERED_LEFT_CHARGING,
211                         R.string.bluetooth_left_name,
212                         LEFT_DEVICE_ID);
213 
214                 updateSubLayout(mLayoutPreference.findViewById(R.id.layout_middle),
215                         BluetoothDevice.METADATA_UNTETHERED_CASE_ICON,
216                         BluetoothDevice.METADATA_UNTETHERED_CASE_BATTERY,
217                         BluetoothDevice.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD,
218                         BluetoothDevice.METADATA_UNTETHERED_CASE_CHARGING,
219                         R.string.bluetooth_middle_name,
220                         CASE_DEVICE_ID);
221 
222                 updateSubLayout(mLayoutPreference.findViewById(R.id.layout_right),
223                         BluetoothDevice.METADATA_UNTETHERED_RIGHT_ICON,
224                         BluetoothDevice.METADATA_UNTETHERED_RIGHT_BATTERY,
225                         BluetoothDevice.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD,
226                         BluetoothDevice.METADATA_UNTETHERED_RIGHT_CHARGING,
227                         R.string.bluetooth_right_name,
228                         RIGHT_DEVICE_ID);
229             }
230         }
231     }
232 
233     @VisibleForTesting
createBtBatteryIcon(Context context, int level, boolean charging)234     Drawable createBtBatteryIcon(Context context, int level, boolean charging) {
235         final BatteryMeterView.BatteryMeterDrawable drawable =
236                 new BatteryMeterView.BatteryMeterDrawable(context,
237                         context.getColor(R.color.meter_background_color),
238                         context.getResources().getDimensionPixelSize(
239                                 R.dimen.advanced_bluetooth_battery_meter_width),
240                         context.getResources().getDimensionPixelSize(
241                                 R.dimen.advanced_bluetooth_battery_meter_height));
242         drawable.setBatteryLevel(level);
243         drawable.setColorFilter(new PorterDuffColorFilter(
244                 com.android.settings.Utils.getColorAttrDefaultColor(context,
245                         android.R.attr.colorControlNormal),
246                 PorterDuff.Mode.SRC));
247         drawable.setCharging(charging);
248 
249         return drawable;
250     }
251 
updateSubLayout(LinearLayout linearLayout, int iconMetaKey, int batteryMetaKey, int lowBatteryMetaKey, int chargeMetaKey, int titleResId, int deviceId)252     private void updateSubLayout(LinearLayout linearLayout, int iconMetaKey, int batteryMetaKey,
253             int lowBatteryMetaKey, int chargeMetaKey, int titleResId, int deviceId) {
254         if (linearLayout == null) {
255             return;
256         }
257         final BluetoothDevice bluetoothDevice = mCachedDevice.getDevice();
258         final String iconUri = BluetoothUtils.getStringMetaData(bluetoothDevice, iconMetaKey);
259         final ImageView imageView = linearLayout.findViewById(R.id.header_icon);
260         if (iconUri != null) {
261             updateIcon(imageView, iconUri);
262         } else {
263             final Pair<Drawable, String> pair =
264                     BluetoothUtils.getBtRainbowDrawableWithDescription(mContext, mCachedDevice);
265             imageView.setImageDrawable(pair.first);
266             imageView.setContentDescription(pair.second);
267         }
268         final int batteryLevel = BluetoothUtils.getIntMetaData(bluetoothDevice, batteryMetaKey);
269         final boolean charging = BluetoothUtils.getBooleanMetaData(bluetoothDevice, chargeMetaKey);
270         if (DEBUG) {
271             Log.d(TAG, "updateSubLayout() icon : " + iconMetaKey + ", battery : " + batteryMetaKey
272                     + ", charge : " + chargeMetaKey + ", batteryLevel : " + batteryLevel
273                     + ", charging : " + charging + ", iconUri : " + iconUri);
274         }
275         if (deviceId == LEFT_DEVICE_ID || deviceId == RIGHT_DEVICE_ID) {
276             showBatteryPredictionIfNecessary(linearLayout, deviceId, batteryLevel);
277         }
278         final TextView batterySummaryView = linearLayout.findViewById(R.id.bt_battery_summary);
279         if (isUntetheredHeadset(bluetoothDevice)) {
280             if (batteryLevel != BluetoothUtils.META_INT_ERROR) {
281                 linearLayout.setVisibility(View.VISIBLE);
282                 batterySummaryView.setText(
283                         com.android.settings.Utils.formatPercentage(batteryLevel));
284                 batterySummaryView.setVisibility(View.VISIBLE);
285                 int lowBatteryLevel = BluetoothUtils.getIntMetaData(bluetoothDevice,
286                         lowBatteryMetaKey);
287                 if (lowBatteryLevel == BluetoothUtils.META_INT_ERROR) {
288                     if (batteryMetaKey == BluetoothDevice.METADATA_UNTETHERED_CASE_BATTERY) {
289                         lowBatteryLevel = CASE_LOW_BATTERY_LEVEL;
290                     } else {
291                         lowBatteryLevel = LOW_BATTERY_LEVEL;
292                     }
293                 }
294                 showBatteryIcon(linearLayout, batteryLevel, lowBatteryLevel, charging);
295             } else {
296                 if (deviceId == MAIN_DEVICE_ID) {
297                     linearLayout.setVisibility(View.VISIBLE);
298                     linearLayout.findViewById(R.id.bt_battery_icon).setVisibility(View.GONE);
299                     int level = bluetoothDevice.getBatteryLevel();
300                     if (level != BluetoothDevice.BATTERY_LEVEL_UNKNOWN
301                             && level != BluetoothDevice.BATTERY_LEVEL_BLUETOOTH_OFF) {
302                         batterySummaryView.setText(
303                                 com.android.settings.Utils.formatPercentage(level));
304                         batterySummaryView.setVisibility(View.VISIBLE);
305                     } else {
306                         batterySummaryView.setVisibility(View.GONE);
307                     }
308                 } else {
309                     // Hide it if it doesn't have battery information
310                     linearLayout.setVisibility(View.GONE);
311                 }
312             }
313         } else {
314             batterySummaryView.setVisibility(View.GONE);
315         }
316         final TextView textView = linearLayout.findViewById(R.id.header_title);
317         if (deviceId == MAIN_DEVICE_ID) {
318             textView.setVisibility(View.GONE);
319         } else {
320             textView.setText(titleResId);
321             textView.setVisibility(View.VISIBLE);
322         }
323     }
324 
isUntetheredHeadset(BluetoothDevice bluetoothDevice)325     private boolean isUntetheredHeadset(BluetoothDevice bluetoothDevice) {
326         return BluetoothUtils.getBooleanMetaData(bluetoothDevice,
327                 BluetoothDevice.METADATA_IS_UNTETHERED_HEADSET)
328                 || TextUtils.equals(BluetoothUtils.getStringMetaData(bluetoothDevice,
329                 BluetoothDevice.METADATA_DEVICE_TYPE),
330                 BluetoothDevice.DEVICE_TYPE_UNTETHERED_HEADSET);
331     }
332 
showBatteryPredictionIfNecessary(LinearLayout linearLayout, int batteryId, int batteryLevel)333     private void showBatteryPredictionIfNecessary(LinearLayout linearLayout, int batteryId,
334             int batteryLevel) {
335         ThreadUtils.postOnBackgroundThread(() -> {
336             final Uri contentUri = new Uri.Builder()
337                     .scheme(ContentResolver.SCHEME_CONTENT)
338                     .authority(mContext.getString(R.string.config_battery_prediction_authority))
339                     .appendPath(PATH)
340                     .appendPath(DATABASE_ID)
341                     .appendPath(DATABASE_BLUETOOTH)
342                     .appendQueryParameter(QUERY_PARAMETER_ADDRESS, mCachedDevice.getAddress())
343                     .appendQueryParameter(QUERY_PARAMETER_BATTERY_ID, String.valueOf(batteryId))
344                     .appendQueryParameter(QUERY_PARAMETER_BATTERY_LEVEL,
345                             String.valueOf(batteryLevel))
346                     .appendQueryParameter(QUERY_PARAMETER_TIMESTAMP,
347                             String.valueOf(System.currentTimeMillis()))
348                     .build();
349 
350             final String[] columns = new String[] {BATTERY_ESTIMATE, ESTIMATE_READY};
351             final Cursor cursor =
352                     mContext.getContentResolver().query(contentUri, columns, null, null, null);
353             if (cursor == null) {
354                 Log.w(TAG, "showBatteryPredictionIfNecessary() cursor is null!");
355                 return;
356             }
357             try {
358                 for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) {
359                     final int estimateReady =
360                             cursor.getInt(cursor.getColumnIndex(ESTIMATE_READY));
361                     final long batteryEstimate =
362                             cursor.getLong(cursor.getColumnIndex(BATTERY_ESTIMATE));
363                     if (DEBUG) {
364                         Log.d(TAG, "showBatteryTimeIfNecessary() batteryId : " + batteryId
365                                 + ", ESTIMATE_READY : " + estimateReady
366                                 + ", BATTERY_ESTIMATE : " + batteryEstimate);
367                     }
368                     showBatteryPredictionIfNecessary(estimateReady, batteryEstimate,
369                             linearLayout);
370                 }
371             } finally {
372                 cursor.close();
373             }
374         });
375     }
376 
377     @VisibleForTesting
showBatteryPredictionIfNecessary(int estimateReady, long batteryEstimate, LinearLayout linearLayout)378     void showBatteryPredictionIfNecessary(int estimateReady, long batteryEstimate,
379             LinearLayout linearLayout) {
380         ThreadUtils.postOnMainThread(() -> {
381             final TextView textView = linearLayout.findViewById(R.id.bt_battery_prediction);
382             if (estimateReady == 1) {
383                 textView.setVisibility(View.VISIBLE);
384                 textView.setText(
385                         StringUtil.formatElapsedTime(
386                                 mContext,
387                                 batteryEstimate,
388                                 /* withSeconds */ false,
389                                 /* collapseTimeUnit */  false));
390             } else {
391                 textView.setVisibility(View.GONE);
392             }
393         });
394     }
395 
showBatteryIcon(LinearLayout linearLayout, int level, int lowBatteryLevel, boolean charging)396     private void showBatteryIcon(LinearLayout linearLayout, int level, int lowBatteryLevel,
397             boolean charging) {
398         final boolean enableLowBattery = level <= lowBatteryLevel && !charging;
399         final ImageView imageView = linearLayout.findViewById(R.id.bt_battery_icon);
400         if (enableLowBattery) {
401             imageView.setImageDrawable(mContext.getDrawable(R.drawable.ic_battery_alert_24dp));
402             LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(
403                     mContext.getResources().getDimensionPixelSize(
404                             R.dimen.advanced_bluetooth_battery_width),
405                     mContext.getResources().getDimensionPixelSize(
406                             R.dimen.advanced_bluetooth_battery_height));
407             layoutParams.rightMargin = mContext.getResources().getDimensionPixelSize(
408                     R.dimen.advanced_bluetooth_battery_right_margin);
409             imageView.setLayoutParams(layoutParams);
410         } else {
411             imageView.setImageDrawable(createBtBatteryIcon(mContext, level, charging));
412             LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(
413                     ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
414             imageView.setLayoutParams(layoutParams);
415         }
416         imageView.setVisibility(View.VISIBLE);
417     }
418 
updateDisconnectLayout()419     private void updateDisconnectLayout() {
420         mLayoutPreference.findViewById(R.id.layout_left).setVisibility(View.GONE);
421         mLayoutPreference.findViewById(R.id.layout_right).setVisibility(View.GONE);
422 
423         // Hide title, battery icon and battery summary
424         final LinearLayout linearLayout = mLayoutPreference.findViewById(R.id.layout_middle);
425         linearLayout.setVisibility(View.VISIBLE);
426         linearLayout.findViewById(R.id.header_title).setVisibility(View.GONE);
427         linearLayout.findViewById(R.id.bt_battery_summary).setVisibility(View.GONE);
428         linearLayout.findViewById(R.id.bt_battery_icon).setVisibility(View.GONE);
429 
430         // Only show bluetooth icon
431         final BluetoothDevice bluetoothDevice = mCachedDevice.getDevice();
432         final String iconUri = BluetoothUtils.getStringMetaData(bluetoothDevice,
433                 BluetoothDevice.METADATA_MAIN_ICON);
434         if (DEBUG) {
435             Log.d(TAG, "updateDisconnectLayout() iconUri : " + iconUri);
436         }
437         if (iconUri != null) {
438             final ImageView imageView = linearLayout.findViewById(R.id.header_icon);
439             updateIcon(imageView, iconUri);
440         }
441     }
442 
443     /**
444      * Update icon by {@code iconUri}. If icon exists in cache, use it; otherwise extract it
445      * from uri in background thread and update it in main thread.
446      */
447     @VisibleForTesting
updateIcon(ImageView imageView, String iconUri)448     void updateIcon(ImageView imageView, String iconUri) {
449         if (mIconCache.containsKey(iconUri)) {
450             imageView.setAlpha(1f);
451             imageView.setImageBitmap(mIconCache.get(iconUri));
452             return;
453         }
454 
455         imageView.setAlpha(HALF_ALPHA);
456         ThreadUtils.postOnBackgroundThread(() -> {
457             final Uri uri = Uri.parse(iconUri);
458             try {
459                 mContext.getContentResolver().takePersistableUriPermission(uri,
460                         Intent.FLAG_GRANT_READ_URI_PERMISSION);
461 
462                 final Bitmap bitmap = MediaStore.Images.Media.getBitmap(
463                         mContext.getContentResolver(), uri);
464                 ThreadUtils.postOnMainThread(() -> {
465                     mIconCache.put(iconUri, bitmap);
466                     imageView.setAlpha(1f);
467                     imageView.setImageBitmap(bitmap);
468                 });
469             } catch (IOException e) {
470                 Log.e(TAG, "Failed to get bitmap for: " + iconUri, e);
471             } catch (SecurityException e) {
472                 Log.e(TAG, "Failed to take persistable permission for: " + uri, e);
473             }
474         });
475     }
476 
477     @Override
onDeviceAttributesChanged()478     public void onDeviceAttributesChanged() {
479         if (mCachedDevice != null) {
480             refresh();
481         }
482     }
483 }
484