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